一. 背景
我们司机端从iOS17
开始存在着一个文本转语音功能的崩溃,主要集中在司机导航、司机履约语音提醒等方面,因为这些方面都用到了文本转语音进行播报的功能,从崩溃收集版本信息来看,该崩溃集中在iOS17.0 - iOS17.2
之间相关版本,
具体崩溃堆栈用两种:
一种:-[AVAudioEngine dealloc] + 56
一种: -[AVAudioEngine stop] + 48
我们从苹果论坛也能找到对应的问题的相关反馈developer.apple.com/forums/thre…
Thread 9 name:
Thread 9 Crashed:
0 libobjc.A.dylib 0x000000019eeff248 objc_retain_x8 + 16
1 AudioToolboxCore 0x00000001b2da9d80 auoop::RenderPipeUser::~RenderPipeUser() + 112 (AUOOPRenderPipePool.mm:400)
2 AudioToolboxCore 0x00000001b2e110b4 -[AUAudioUnit_XPC internalDeallocateRenderResources] + 92 (AUAudioUnit_XPC.mm:904)
3 AVFAudio 0x00000001bfa4cc04 AUInterfaceBaseV3::Uninitialize() + 60 (AUInterface.mm:524)
4 AVFAudio 0x00000001bfa894bc AVAudioEngineGraph::PerformCommand(AUGraphNodeBaseV3&, AVAudioEngineGraph::ENodeCommand, void*, unsigned int) const + 772 (AVAudioEngineGraph.mm:3317)
5 AVFAudio 0x00000001bfa93550 AVAudioEngineGraph::_Uninitialize(NSError**) + 132 (AVAudioEngineGraph.mm:1469)
6 AVFAudio 0x00000001bfa4b50c AVAudioEngineImpl::Stop(NSError**) + 396 (AVAudioEngine.mm:1081)
7 AVFAudio 0x00000001bfa4b094 -[AVAudioEngine stop] + 48 (AVAudioEngine.mm:193)
8 TextToSpeech 0x00000001c70b3c5c __55-[TTSSynthesisProviderAudioEngine renderSpeechRequest:]_block_invoke + 1756 (TTSSynthesisProviderAudioEngine.m:613)
9 libdispatch.dylib 0x00000001ae4b0740 _dispatch_call_block_and_release + 32 (init.c:1519)
10 libdispatch.dylib 0x00000001ae4b2378 _dispatch_client_callout + 20 (object.m:560)
11 libdispatch.dylib 0x00000001ae4b990c _dispatch_lane_serial_drain + 748 (queue.c:3885)
12 libdispatch.dylib 0x00000001ae4ba470 _dispatch_lane_invoke + 432 (queue.c:3976)
13 libdispatch.dylib 0x00000001ae4c5074 _dispatch_root_queue_drain_deferred_wlh + 288 (queue.c:6913)
14 libdispatch.dylib 0x00000001ae4c48e8 _dispatch_workloop_worker_thread + 404 (queue.c:6507)
因此很明显这是一个苹果系统在iOS17
版本升级所引入的崩溃,然后在iOS17.2
之后版本修复了该崩溃。
二. 原因排查
首先从崩溃类型是EXC_BAD_ACCESS (SIGSEGV)
,我们可以大概确定某个对象野指针导致了这个崩溃。
接着我们用相同或相近的系统版本在release
模式上运行调试运行。
为什么用相同或相近的系统版本在
release
模式运行,可以查看文章: [货拉拉iOS疑难Crash治理-系统键盘语音]
然后我们断点到: -[AUAudioUnit_XPC internalDeallocateRenderResources] + 92 (AUAudioUnit_XPC.mm:904)
我们由于这个不是最顶层的崩溃堆栈,所以偏移指令92-4=88
,我们看88
指令指向的是auoop::RenderPipeUser::~RenderPipeUser()
的析构方法。
为什么需要看偏移指令
88
,可以查看文章: [货拉拉iOS疑难Crash治理-系统键盘语音]
我们调试进入RenderPipeUser
的析构方法:auoop::RenderPipeUser::~RenderPipeUser() + 112 (AUOOPRenderPipePool.mm:400)
由于这里不是最顶层的崩溃堆栈,所以偏移指令112-4=108
,我们看108
指令指向的是0x198f7dbbc <+108>: bl 0x19e7a2b70
方法。
我们接着调试进入0x19e7a2b70
函数,查看相关堆栈
从这个堆栈,我们可以看到这里主要是对对象做内存管理操作,对对象进行objc_reatin
。
我们打印当前$x0
发现是个NSXPCConnection
对象。
结合最顶层的崩溃堆栈信息0 libobjc.A.dylib 0x000000019eeff248 objc_retain_x8 + 16
, 我们可以确定是NSXPCConnection
的对象,遭遇了野指针问题导致的崩溃。
x0 寄存器中的保存的就是那个被销毁了的对象指针。
x1 寄存器中保存的就是产生崩溃的对象的方法名称的地址。
x13 寄存器中保存的就是对象的isa指针值。
x16 寄存器中保存的就是对象的Class指针对象。
po $x0
来显示对象信息,p (char*)$x1
来显示方法名称
但由于当前auoop::RenderPipeUser
对象里面,这是一个C++
对象,我们没办法得知该对象里面的方法和变量。
因此我们只能再往上一层,查看下AUAudioUnit_XPC
类相关的方法和属性,看下是否有相关的突破口。
于是我们通过runtime
打印AUAudioUnit_XPC
的所有变量和方法,
#import <objc/runtime.h>
#import "FJFClassInfoPrint.h"
@implementation FJFClassInfoPrint
+ (void)printClassVarWithClassName:(NSString *)className {
unsigned int numIvars; //成员变量个数
Ivar *vars = class_copyIvarList(NSClassFromString(className), &numIvars);
//Ivar *vars = class_copyIvarList([UIView class], &numIvars);
NSString *key=nil;
for(int i = 0; i < numIvars; i++) {
Ivar thisIvar = vars[i];
key = [NSString stringWithUTF8String:ivar_getName(thisIvar)]; //获取成员变量的名字
NSLog(@"%@ variable name :%@",className, key);
key = [NSString stringWithUTF8String:ivar_getTypeEncoding(thisIvar)]; //获取成员变量的数据类型
NSLog(@"%@ variable type :%@",className, key);
}
free(vars);
}
/// 获取 类的实例方法
+ (void)printInstanceMethodWithClassName:(NSString *)className {
unsigned int numIvars = 0;
Method *meth = class_copyMethodList(NSClassFromString(className), &numIvars);
//Method *meth = class_copyMethodList([UIView class], &numIvars);
for(int i = 0; i < numIvars; i++) {
Method thisIvar = meth[i];
SEL sel = method_getName(thisIvar);
const char *name = sel_getName(sel);
NSLog(@"%@ Instance Method :%s",className, name);
}
free(meth);
}
/// 获取类的类方法
+ (void)printClassMethodWithClassName:(NSString *)className {
Class tmpCls = NSClassFromString(className);
Class metaCls = object_getClass(tmpCls); // 获取元类
unsigned int methodCount = 0;
Method *methods = class_copyMethodList(metaCls, &methodCount);
for (unsigned int i = 0; i < methodCount; i++) {
Method method = methods[i];
SEL selector = method_getName(method);
NSString *name = NSStringFromSelector(selector);
NSLog(@"%@ Class Methods: %@", className, name);
}
free(methods);
}
从打印AUAudioUnit_XPC
出来的方法和属性我们可以发现:auoop::RenderPipeUser
对象_renderPipeUser
和NSXPCConnection
的对象_xpcConnection
AUAudioUnit_XPC method :.cxx_destruct
AUAudioUnit_XPC method :dealloc
AUAudioUnit_XPC method :.cxx_construct
AUAudioUnit_XPC method :allocateRenderResourcesAndReturnError:
AUAudioUnit_XPC method :internalDeallocateRenderResources
AUAudioUnit_XPC variable name :_renderPipeUser
AUAudioUnit_XPC variable type :{optionalauoop::RenderPipeUser=""(?="_null_state"c"_val"{RenderPipeUser="mPipeSubPool"^{PipeSubPool}"mRenderClientUser"{AUOOPRenderClientUser="au"@"AUAudioUnit_XPC""xpcConnection"@"NSXPCConnection""musicalContextBlock"@?"transportStateBlock"@?"MIDIOutputEventBlock"@?"MIDIOutputEventListBlock"@?"serviceProcessAUInstanceToken"I"isOffline"B"isMIDIProcessor"B}"mInvalidated"{atomic="_a"{__cxx_atomic_impl<bool, std::__cxx_atomic_base_impl>="__a_value"AB}}})"_engaged"B}
AUAudioUnit_XPC variable name :_xpcConnection
AUAudioUnit_XPC variable type :@"NSXPCConnection"
这里我们详细解析下_renderPipeUser
和对应的类型信息:
AUAudioUnit_XPC variable type :{optionalauoop::RenderPipeUser=""(?="_null_state"c"_val"{RenderPipeUser="mPipeSubPool"^{PipeSubPool}"mRenderClientUser"{AUOOPRenderClientUser="au"@"AUAudioUnit_XPC""xpcConnection"@"NSXPCConnection""musicalContextBlock"@?"transportStateBlock"@?"MIDIOutputEventBlock"@?"MIDIOutputEventListBlock"@?"serviceProcessAUInstanceToken"I"isOffline"B"isMIDIProcessor"B}"mInvalidated"{atomic="_a"{__cxx_atomic_impl<bool, std::__cxx_atomic_base_impl>="__a_value"AB}}})"_engaged"B}
- optionalauoop::RenderPipeUser:表示一个可选的auoop::RenderPipeUser类型的对象。optional是一个C++17引入的模板类,用于表示一个值可以存在或不存在。
- auoop::RenderPipeUser:这是一个自定义类型,它包含了一个音频处理管道的用户所相关的数据和行为。auoop可能是一个自定义的命名空间。
- mPipeSubPool:^符号在Objective-C中表示这是一个指针,很可能是一个内存池(pool)用于分配和管理音频处理管道中的资源。
- mRenderClientUser:这是一个结构体,可能包含了客户端用于与音频处理管道交互所需的信息。
- AUOOPRenderClientUser:这是一个更具体的结构体,它可能包含了一个AUAudioUnit对象,一个NSXPCConnection对象,以及其他几个块(block)和标志位。
- au:一个指向AUAudioUnit的指针,这是Apple音频处理单元的C++类。
- xpcConnection:一个指向NSXPCConnection的指针,这是XPC服务的一部分,用于进程间通信。
- musicalContextBlock,transportStateBlock,MIDIOutputEventBlock,MIDIOutputEventListBlock:这些是Objective-C块,可能用于回调,处理音乐上下文、传输状态、MIDI事件等。
- serviceProcessAUInstanceToken:一个整型值,可能用于标识或管理与音频单元实例相关的服务进程。
- isOffline,isMIDIProcessor:布尔值,表示音频处理单元是否处于离线状态,以及它是否是MIDI处理器。
- mInvalidated:这是一个原子布尔值,可能用于标记对象是否已失效。
- atomic:一个原子布尔类型,用于线程安全地读取和修改布尔值。
- __cxx_atomic_impl:这是C++标准库中用于实现原子操作的内部结构。
- __a_value:原子值的实际存储。
- _engaged:一个布尔值,可能表示optional对象是否已经“参与”或“设置”了一个实际的值。
我们可以看到auoop::RenderPipeUser
对象_renderPipeUser
的内部,也持有了xpcConnection
一个指向NSXPCConnection
的指针。
我们可以推测_renderPipeUser
持有了xpcConnection
指针跟AUAudioUnit_XPC
对象持有的_xpcConnection
指针应该指向了同一个对象。
接下来我们要证实_renderPipeUser
持有了xpcConnection
指针跟AUAudioUnit_XPC
对象持有的_xpcConnection
指针应该指向了同一个对象。
我们hook
了AUAudioUnit_XPC
的allocateRenderResourcesAndReturnError:
方法,然后遍历AUAudioUnit_XPC
实例里面的变量,找到_xpcConnection
查看它的指针值,跟_renderPipeUser
里面的指针值进行比较。
通过打印出来的指针地址都是: 0x283d078e0
,我们可以确定_renderPipeUser
持有了xpcConnection
指针跟AUAudioUnit_XPC
对象持有的_xpcConnection
指针应该指向了同一个对象。。
而从上面两个堆栈可以看到,-[AVAudioEngine dealloc]
和-[AVAudioEngine stop]
都会调用AVAudioEngineImpl::Stop(NSError**) + 396 (AVAudioEngine.mm:1081)
,调用都在子线程调用,
因此我们可以推测出是多线程的操作,导致了xpcConnection
对象野指针。
三. 解决方案
-
方案一(失败方案)
该方案并没有成功解决这个崩溃,但也属于探索过程的一部分,如果有兴趣可以看下,没兴趣可以直接看后面的治理方案。
A. 治理方案
既然了解到根本原因是因为多线程操作导致了AUAudioUnit_XPC
对象的_xpcConnection
对象野指针,那如何解决呢?
因为_xpcConnection
在AUAudioUnit_XPC
实例对象里面是一个内部私有变量,且没有提供关于访问这个对象的set
、get
方法。
因此我这里想到的第一个方法是,可以寻找一个时机即AUAudioUnit_XPC
对象的_xpcConnection
变量,赋值给_renderPipeUser
的xpcConnection
之前,将_xpcConnection
从NSXPCConnection
类型转换为XLAudioUnitXpcWrapper
类型。
经查找只有AUAudioUnit_XPC
方法列表里面的allocateRenderResourcesAndReturnError:
方法,在调用之前,_xpcConnection
是初始化的,且未赋值给_renderPipeUser
的xpcConnection
。
然后将原本的_xpcConnection
放到XLAudioUnitXpcWrapper
里面,XLAudioUnitXpcWrapper
继承自NSProxy
方法,当调用XLAudioUnitXpcWrapper
的相关方法的时候,- (void)forwardInvocation:(NSInvocation *)invocation
方法转发,将执行操作放到串行队列里面由去_xpcConnection
执行,从而来保证对_xpcConnection
相关操作都是在串行队列里面执行,来保证线程安全。
@interface XLAudioUnitXpcWrapper : NSProxy
@end
@implementation XLAudioUnitXpcWrapper
{
NSXPCConnection *_xpcConnection;
}
- (instancetype)initWithXpcConnection:(NSXPCConnection *)xpcConnection {
self = [[self class] alloc];
_xpcConnection = xpcConnection;
return self;
}
- (NSMethodSignature *)methodSignatureForSelector:(SEL)sel {
return [_xpcConnection methodSignatureForSelector:sel];
}
- (void)forwardInvocation:(NSInvocation *)invocation {
static dispatch_queue_t queue = nil; // 是否要关联 target?
if (queue == nil) {
queue = dispatch_queue_create("com.platform.taskqueue", 0x0);
}
dispatch_sync(queue, ^{
[invocation invokeWithTarget:_xpcConnection];
});
}
@end
+ (void)startAVAudioEngineCrashFix {
Class audioUnitClass = NSClassFromString(@"AUAudioUnit_XPC");
[audioUnitClass hd_hookMethod:NSSelectorFromString(@"allocateRenderResourcesAndReturnError:") option:HDHookOptionBefore handle:^(HDInvocation *invocation){
id obj = invocation.target;
unsigned int count = 0;
Ivar *ivars = class_copyIvarList(audioUnitClass, &count);
for (int i = 0; i < count; i ++) {
Ivar ivar = ivars[i];
const char *ivar_name = ivar_getName(ivar);
if (strcmp([@"_xpcConnection" cStringUsingEncoding:NSUTF8StringEncoding], ivar_name) == 0) {
id ivar_value = object_getIvar(obj, ivar);
if (![ivar_value isKindOfClass:[NSXPCConnection class]]) {
break;
}
XLAudioUnitXpcWrapper *tmpWrapper = [[XLAudioUnitXpcWrapper alloc] initWithXpcConnection:(NSXPCConnection *)ivar_value];
[obj setValue:tmpWrapper forKey:@"_xpcConnection"];
break;
}
}
} error:nil];
}
然后将代码在在iOS 17.1.1
的系统上的手机上运行,执行文本转语音的相关代码
let synth = AVSpeechSynthesizer()
let utterance = AVSpeechUtterance(string: "Here we go")
synth.speak(utterance) // synthprovider.offlineRendering problem
运行后会在-[AVAudioEngine connect:to:format:]
方法内部抛出异常。
这时候具体分析抛出异常的堆栈,我们可以看到AVAudioEngineGraph.mm
是OC
和C++
混编,也就是说这里对于_xpcConnection
变量,使用的引用计数管理是MRC
。
因此将修复的文件设置为MRC
.
- 同时添加版本限制,只针对
iOS17.0 - iOS17.2
的系统开启,同时添加降级方案。
B. 上线结果
上线之后原先的崩溃没出现,但依然出现了新的崩溃,且新崩溃量跟之前崩溃量差不多,因此将该修复方案降级回来。
该方案之所以会引发别的崩溃,主要原因在于消息转发只适用于runtime
的调用方法,_xpcConnection
在OC
和C++
内部存在着直接通过地址偏移的调用方式,导致这些调用没有走消息转发,因此出现了其他额外的崩溃。
-
方案二(成功方案)
A. 治理方案
由于上面第一种治理方案失效,而_xpcConnection
在AUAudioUnit_XPC
实例对象里面是一个内部私有变量,因此直接针对_xpcConnection
对象的治理方案,暂时行不通,只能从其他方面入手来优化这个崩溃。
首先排查了下项目中用到语音合成(文本转语音)类:AVSpeechSynthesizer
的地方,都是封装为单例来调用。
而且我自己在iOS 17.1.1
的系统上的手机上模拟了司机接单、履约的整个过程,断点调试并没有走到-[AVAudioEngine stop] + 48
和-[AVAudioEngine dealloc] + 56
方法。
而从统计上看,iOS17.0 - iOS 17.2
系统设备的司机每天活跃量每天差不多在1500
上下,而这个崩溃每天崩溃量最多5-6
个,也就表明很有可能只有在出现崩溃的时候,才会走[AVAudioEngine stop]
和-[AVAudioEngine dealloc]
方法,因此我对这两个方法进行hook
,然后判断当前线程堆栈里面含有TextToSpeech
文本,表明这是一个文本转语音相关调用,就进行埋点上报和写日志操作。
从埋点反馈上报只有1-2
个的[AVAudioEngine stop]
的堆栈上报,之所以这么少是因为其他时候调用这两个方法,刚好发生崩溃的时候导致埋点没有上报。
从以上相关信息可以推测,在iOS17.0 - iOS17.2
系统设备的司机,导航等语音合成播报正常不会触发-[AVAudioEngine stop] + 48
和-[AVAudioEngine dealloc] + 56
方法,只有出现异常的时候才会走这两个方法,出现异常的概率也很小。
所以我们尝试hook
这两个-[AVAudioEngine stop]
和-[AVAudioEngine dealloc]
方法,判断如果当前堆栈里面包含TextToSpeech
表明这是一个文本转语音相关调用,则不去执行原方法。
@implementation AVAudioEngine (XLSystemCrashFix)
+ (void)startAVAudioEngineCrashFix {
BOOL isHookDealloc = [self hookInstanceMethodOf:NSSelectorFromString(@"dealloc") with: @selector(xl_dealloc)];
BOOL isHookStop = [self hookInstanceMethodOf:NSSelectorFromString(@"stop") with: @selector(xl_stop)];
if ((isHookDealloc == false) || (isHookStop == false)) {
#if DEBUG
NSLog(@"AVAudioEngine hook unSuccess");
#endif
}
}
+ (void)updateDeallocDelayEnable:(BOOL)delllocDelayEnable {
[[HLLSafeBox standardBox] setBool:delllocDelayEnable forKey:XLAVAudioEngineDellocKey];
}
+ (void)updateStopDelayEnable:(BOOL)stopDelayEnable {
[[HLLSafeBox standardBox] setBool:stopDelayEnable forKey:XLAVAudioEngineStopKey];
}
- (void)xl_dealloc {
NSString *tmpString = [NSThread.callStackSymbols componentsJoinedByString:@"\n"];
BOOL isContainTextSpeech = [tmpString containsString:@"TextToSpeech"];
if ([[HLLSafeBox standardBox] boolForKey:XLAVAudioEngineDellocKey]) {
if (isContainTextSpeech == false) {
[self xl_dealloc];
}
} else {
[self xl_dealloc];
}
if (isContainTextSpeech) {
[[NSNotificationCenter defaultCenter] postNotificationName:XLAVAudioEngineCrashNoti object:tmpString];
}
}
- (void)xl_stop {
NSString *tmpString = [NSThread.callStackSymbols componentsJoinedByString:@"\n"];
BOOL isContainTextSpeech = [tmpString containsString:@"TextToSpeech"];
if ([[HLLSafeBox standardBox] boolForKey:XLAVAudioEngineStopKey]) {
if (isContainTextSpeech == false) {
[self xl_stop];
}
} else {
[self xl_stop];
}
if (isContainTextSpeech) {
[[NSNotificationCenter defaultCenter] postNotificationName:XLAVAudioEngineCrashNoti object:tmpString];
}
}
@end
这里之所以hook
了-[AVAudioEngine stop]
和-[AVAudioEngine dealloc]
方法,而不hook
住-[AUAudioUnit_XPC internalDeallocateRenderResources]
,主要因为虽然调试中没法复现语音合成播报的崩溃堆栈,但因为AVAudioEngine
是系统类,可以直接调用,相关stop
等方法,从理论上来说不执行这个方法,不会有什么影响。而对于AUAudioUnit_XPC
了解较少。
B. 上线结果
上线之后,我们可以看到,最新的版本是1.7.10
版本,而这个崩溃最后出现的版本是1.7.0
,也就表明上线之后,新版本就没再出现过。
虽然上线之后没有再产生这个类型的崩溃,但依然需要去校验下这个方案有没有产生额外的风险或者问题。
因此我们从-[AVAudioEngine stop]
和-[AVAudioEngine dealloc]
埋点上报统计到的司机中,抽查了一部分司机,查看了司机实时日志,从日志发现司机上报埋点后,其他操作流程依然正常,接单、履约都没有出现异常。也回访了几个司机,在上报埋点后的时间段后,没有出现任何异常。
然后也查看了其他稳定下数据比如卡顿、卡死、abort
、启动等方面数据,也没有新增额外case
或者数据出现波动。
经过多方面校验、认证后,认为这个崩溃修复是合理、有用的。
四. 总结
以上主要介绍了针对这个崩溃分析和治理方案的探索过程,中间也经历了很多次修复尝试,虽然最后的治理方案,并没有针对根本的崩溃原因进行治理,而是选择从一个方面去规避,来解决这个崩溃。这也是很常见的治理思路,因为崩溃在系统底层,从底层去改风险大、复杂度高,所以在了解原理后,从上层调用或者业务侧去规避解决,也不失为一种好方法。
若本文有错误之处或其他治理方案或者技术上关于其他类型Crash
的讨论交流的,欢迎评论区留言。