货拉拉iOS疑难Crash治理-TTS problem iOS 17
一. 背景
我们司机端从 iOS17开始存在着一个文本转语音功能的崩溃,主要集中在司机导航、司机履约语音提醒等方面,因为这些方面都用到了文本转语音进行播报的功能,从崩溃收集版本信息来看,该崩溃集中在 iOS17.0-iOS17.2之间相关版本,
具体崩溃堆栈用两种:
一种: -[AVAudioEnginedealloc]+56
一种: -[AVAudioEnginestop]+48
我们从苹果论坛也能找到对应的问题的相关反馈https://developer.apple.com/forums/thread/732335。
Thread9 name:
Thread9Crashed:
0 libobjc.A.dylib 0x000000019eeff248 objc_retain_x8 +16
1AudioToolboxCore0x00000001b2da9d80 auoop::RenderPipeUser::~RenderPipeUser()+112(AUOOPRenderPipePool.mm:400)
2AudioToolboxCore0x00000001b2e110b4-[AUAudioUnit_XPC internalDeallocateRenderResources]+92(AUAudioUnit_XPC.mm:904)
3AVFAudio0x00000001bfa4cc04AUInterfaceBaseV3::Uninitialize()+60(AUInterface.mm:524)
4AVFAudio0x00000001bfa894bcAVAudioEngineGraph::PerformCommand(AUGraphNodeBaseV3&,AVAudioEngineGraph::ENodeCommand,void*,unsignedint)const+772(AVAudioEngineGraph.mm:3317)
5AVFAudio0x00000001bfa93550AVAudioEngineGraph::_Uninitialize(NSError**)+132(AVAudioEngineGraph.mm:1469)
6AVFAudio0x00000001bfa4b50cAVAudioEngineImpl::Stop(NSError**)+396(AVAudioEngine.mm:1081)
7AVFAudio0x00000001bfa4b094-[AVAudioEngine stop]+48(AVAudioEngine.mm:193)
8TextToSpeech0x00000001c70b3c5c __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_XPCinternalDeallocateRenderResources]+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>:bl0x19e7a2b70方法。
我们接着调试进入 0x19e7a2b70函数,查看相关堆栈
从这个堆栈,我们可以看到这里主要是对对象做内存管理操作,对对象进行 objc_reatin。
我们打印当前 $x0发现是个 NSXPCConnection对象。
结合最顶层的崩溃堆栈信息 0libobjc.A.dylib0x000000019eeff248objc_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"
@implementationFJFClassInfoPrint
+(void)printClassVarWithClassName:(NSString*)className {
unsignedint 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 {
unsignedint 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);
constchar*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);// 获取元类
unsignedint methodCount =0;
Method*methods = class_copyMethodList(metaCls,&methodCount);
for(unsignedint 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 :{optional=""(?="_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
AUAudioUnit_XPC variable name :_xpcConnection
AUAudioUnit_XPC variable type :@"NSXPCConnection"
这里我们详细解析下 _renderPipeUser和对应的类型信息:
AUAudioUnit_XPC variable type :{optional=""(?="_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
optional:表示一个可选的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指针应该指向了同一个对象。。
而从上面两个堆栈可以看到, -[AVAudioEnginedealloc]和 -[AVAudioEnginestop]都会调用 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相关操作都是在串行队列里面执行,来保证线程安全。
@interfaceXLAudioUnitXpcWrapper:NSProxy
@end
@implementationXLAudioUnitXpcWrapper
{
NSXPCConnection*_xpcConnection;
}
-(instancetype)initWithXpcConnection:(NSXPCConnection*)xpcConnection {
self=[[selfclass] alloc];
_xpcConnection = xpcConnection;
returnself;
}
-(NSMethodSignature*)methodSignatureForSelector:(SEL)sel {
return[_xpcConnection methodSignatureForSelector:sel];
}
-(void)forwardInvocation:(NSInvocation*)invocation {
staticdispatch_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;
unsignedint count =0;
Ivar*ivars = class_copyIvarList(audioUnitClass,&count);
for(int i =0; i < count; i ++){
Ivar ivar = ivars[i];
constchar*ivar_name = ivar_getName(ivar);
if(strcmp([@"_xpcConnection" cStringUsingEncoding:NSUTF8StringEncoding], ivar_name)==0){
id ivar_value = object_getIvar(obj, ivar);
if(![ivar_value isKindOfClass:[NSXPCConnectionclass]]){
break;
}
XLAudioUnitXpcWrapper*tmpWrapper =[[XLAudioUnitXpcWrapper alloc] initWithXpcConnection:(NSXPCConnection*)ivar_value];
[obj setValue:tmpWrapper forKey:@"_xpcConnection"];
break;
}
}
} error:nil];
}
然后将代码在在 iOS17.1.1的系统上的手机上运行,执行文本转语音的相关代码
let synth =AVSpeechSynthesizer()
let utterance =AVSpeechUtterance(string:"Here we go")
synth.speak(utterance)// synthprovider.offlineRendering problem
运行后会在 -[AVAudioEngineconnect:to:format:]方法内部抛出异常。
这时候具体分析抛出异常的堆栈,我们可以看到 AVAudioEngineGraph.mm是 OC和 C++混编,也就是说这里对于 _xpcConnection变量,使用的引用计数管理是 MRC。
因此将修复的文件设置为 MRC.
同时添加版本限制,只针对
iOS17.0-iOS17.2的系统开启,同时添加降级方案。
B. 上线结果
上线之后原先的崩溃没出现,但依然出现了新的崩溃,且新崩溃量跟之前崩溃量差不多,因此将该修复方案降级回来。
该方案之所以会引发别的崩溃,主要原因在于消息转发只适用于 runtime的调用方法, _xpcConnection在 OC和 C++内部存在着直接通过地址偏移的调用方式,导致这些调用没有走消息转发,因此出现了其他额外的崩溃。
## 方案二(成功方案)
A. 治理方案
由于上面第一种治理方案失效,而 _xpcConnection在 AUAudioUnit_XPC实例对象里面是一个内部私有变量,因此直接针对 _xpcConnection对象的治理方案,暂时行不通,只能从其他方面入手来优化这个崩溃。
首先排查了下项目中用到语音合成(文本转语音)类: AVSpeechSynthesizer的地方,都是封装为单例来调用。
而且我自己在 iOS17.1.1的系统上的手机上模拟了司机接单、履约的整个过程,断点调试并没有走到 -[AVAudioEnginestop]+48和 -[AVAudioEnginedealloc]+56方法。
而从统计上看, iOS17.0-iOS17.2系统设备的司机每天活跃量每天差不多在 1500上下,而这个崩溃每天崩溃量最多 5-6个,也就表明很有可能只有在出现崩溃的时候,才会走 [AVAudioEnginestop]和 -[AVAudioEnginedealloc]方法,因此我对这两个方法进行 hook,然后判断当前线程堆栈里面含有 TextToSpeech文本,表明这是一个文本转语音相关调用,就进行埋点上报和写日志操作。
从埋点反馈上报只有 1-2个的 [AVAudioEnginestop]的堆栈上报,之所以这么少是因为其他时候调用这两个方法,刚好发生崩溃的时候导致埋点没有上报。
从以上相关信息可以推测,在 iOS17.0-iOS17.2系统设备的司机,导航等语音合成播报正常不会触发 -[AVAudioEnginestop]+48和 -[AVAudioEnginedealloc]+56方法,只有出现异常的时候才会走这两个方法,出现异常的概率也很小。
所以我们尝试 hook这两个 -[AVAudioEnginestop]和 -[AVAudioEnginedealloc]方法,判断如果当前堆栈里面包含 TextToSpeech表明这是一个文本转语音相关调用,则不去执行原方法。
@implementationAVAudioEngine(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:XLAVAudioEngineCrashNotiobject: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:XLAVAudioEngineCrashNotiobject:tmpString];
}
}
@end
这里之所以 hook了 -[AVAudioEnginestop]和 -[AVAudioEnginedealloc]方法,而不 hook住 -[AUAudioUnit_XPCinternalDeallocateRenderResources],主要因为虽然调试中没法复现语音合成播报的崩溃堆栈,但因为 AVAudioEngine是系统类,可以直接调用,相关 stop等方法,从理论上来说不执行这个方法,不会有什么影响。而对于 AUAudioUnit_XPC了解较少。
B. 上线结果
上线之后,我们可以看到,最新的版本是 1.7.10版本,而这个崩溃最后出现的版本是 1.7.0,也就表明上线之后,新版本就没再出现过。
虽然上线之后没有再产生这个类型的崩溃,但依然需要去校验下这个方案有没有产生额外的风险或者问题。
因此我们从 -[AVAudioEnginestop]和 -[AVAudioEnginedealloc]埋点上报统计到的司机中,抽查了一部分司机,查看了司机实时日志,从日志发现司机上报埋点后,其他操作流程依然正常,接单、履约都没有出现异常。也回访了几个司机,在上报埋点后的时间段后,没有出现任何异常。
然后也查看了其他稳定下数据比如卡顿、卡死、 abort、启动等方面数据,也没有新增额外 case或者数据出现波动。
经过多方面校验、认证后,认为这个崩溃修复是合理、有用的。
四. 总结
以上主要介绍了针对这个崩溃分析和治理方案的探索过程,中间也经历了很多次修复尝试,虽然最后的治理方案,并没有针对根本的崩溃原因进行治理,而是选择从一个方面去规避,来解决这个崩溃。这也是很常见的治理思路,因为崩溃在系统底层,从底层去改风险大、复杂度高,所以在了解原理后,从上层调用或者业务侧去规避解决,也不失为一种好方法。
若本文有错误之处或其他治理方案或者技术上关于其他类型 Crash的讨论交流的,欢迎评论区留言。