货拉拉技术

货拉拉iOS疑难Crash治理-TTS problem iOS 17

一. 背景

我们司机端从 iOS17开始存在着一个文本转语音功能的崩溃,主要集中在司机导航、司机履约语音提醒等方面,因为这些方面都用到了文本转语音进行播报的功能,从崩溃收集版本信息来看,该崩溃集中在 iOS17.0-iOS17.2之间相关版本,

Image

具体崩溃堆栈用两种:

一种: -[AVAudioEnginedealloc]+56

Image

一种: -[AVAudioEnginestop]+48

Image

我们从苹果论坛也能找到对应的问题的相关反馈https://developer.apple.com/forums/thread/732335。

  1. Thread9 name:

  2. Thread9Crashed:

  3. 0 libobjc.A.dylib 0x000000019eeff248 objc_retain_x8 +16

  4. 1AudioToolboxCore0x00000001b2da9d80 auoop::RenderPipeUser::~RenderPipeUser()+112(AUOOPRenderPipePool.mm:400)

  5. 2AudioToolboxCore0x00000001b2e110b4-[AUAudioUnit_XPC internalDeallocateRenderResources]+92(AUAudioUnit_XPC.mm:904)

  6. 3AVFAudio0x00000001bfa4cc04AUInterfaceBaseV3::Uninitialize()+60(AUInterface.mm:524)

  7. 4AVFAudio0x00000001bfa894bcAVAudioEngineGraph::PerformCommand(AUGraphNodeBaseV3&,AVAudioEngineGraph::ENodeCommand,void*,unsignedint)const+772(AVAudioEngineGraph.mm:3317)

  8. 5AVFAudio0x00000001bfa93550AVAudioEngineGraph::_Uninitialize(NSError**)+132(AVAudioEngineGraph.mm:1469)

  9. 6AVFAudio0x00000001bfa4b50cAVAudioEngineImpl::Stop(NSError**)+396(AVAudioEngine.mm:1081)

  10. 7AVFAudio0x00000001bfa4b094-[AVAudioEngine stop]+48(AVAudioEngine.mm:193)

  11. 8TextToSpeech0x00000001c70b3c5c __55-[TTSSynthesisProviderAudioEngine renderSpeechRequest:]_block_invoke +1756(TTSSynthesisProviderAudioEngine.m:613)

  12. 9 libdispatch.dylib 0x00000001ae4b0740 _dispatch_call_block_and_release +32(init.c:1519)

  13. 10 libdispatch.dylib 0x00000001ae4b2378 _dispatch_client_callout +20(object.m:560)

  14. 11 libdispatch.dylib 0x00000001ae4b990c _dispatch_lane_serial_drain +748(queue.c:3885)

  15. 12 libdispatch.dylib 0x00000001ae4ba470 _dispatch_lane_invoke +432(queue.c:3976)

  16. 13 libdispatch.dylib 0x00000001ae4c5074 _dispatch_root_queue_drain_deferred_wlh +288(queue.c:6913)

  17. 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)

Image

我们由于这个不是最顶层的崩溃堆栈,所以偏移指令 92-4=88,我们看 88指令指向的是 auoop::RenderPipeUser::~RenderPipeUser()的析构方法。

为什么需要看偏移指令 88,可以查看文章: [货拉拉iOS疑难Crash治理-系统键盘语音]

我们调试进入 RenderPipeUser的析构方法: auoop::RenderPipeUser::~RenderPipeUser()+112(AUOOPRenderPipePool.mm:400)

Image

由于这里不是最顶层的崩溃堆栈,所以偏移指令 112-4=108,我们看 108指令指向的是 0x198f7dbbc<+108>:bl0x19e7a2b70方法。

我们接着调试进入 0x19e7a2b70函数,查看相关堆栈

Image

从这个堆栈,我们可以看到这里主要是对对象做内存管理操作,对对象进行 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的所有变量和方法,

  1. #import <objc/runtime.h>

  2. #import "FJFClassInfoPrint.h"

  3. @implementationFJFClassInfoPrint

  4. +(void)printClassVarWithClassName:(NSString*)className {

  5. unsignedint numIvars;//成员变量个数

  6. Ivar*vars = class_copyIvarList(NSClassFromString(className),&numIvars);

  7. //Ivar *vars = class_copyIvarList([UIView class], &numIvars);

  8. NSString*key=nil;

  9. for(int i =0; i < numIvars; i++){

  10. Ivar thisIvar = vars[i];

  11. key =[NSString stringWithUTF8String:ivar_getName(thisIvar)];//获取成员变量的名字

  12. NSLog(@"%@ variable name :%@",className, key);

  13. key =[NSString stringWithUTF8String:ivar_getTypeEncoding(thisIvar)];//获取成员变量的数据类型

  14. NSLog(@"%@ variable type :%@",className, key);

  15. }

  16. free(vars);

  17. }

  18. /// 获取 类的实例方法

  19. +(void)printInstanceMethodWithClassName:(NSString*)className {

  20. unsignedint numIvars =0;

  21. Method*meth = class_copyMethodList(NSClassFromString(className),&numIvars);

  22. //Method *meth = class_copyMethodList([UIView class], &numIvars);

  23. for(int i =0; i < numIvars; i++){

  24. Method thisIvar = meth[i];

  25. SEL sel = method_getName(thisIvar);

  26. constchar*name = sel_getName(sel);

  27. NSLog(@"%@ Instance Method :%s",className, name);

  28. }

  29. free(meth);

  30. }

  31. /// 获取类的类方法

  32. +(void)printClassMethodWithClassName:(NSString*)className {

  33. Class tmpCls =NSClassFromString(className);

  34. Class metaCls = object_getClass(tmpCls);// 获取元类

  35. unsignedint methodCount =0;

  36. Method*methods = class_copyMethodList(metaCls,&methodCount);

  37. for(unsignedint i =0; i < methodCount; i++){

  38. Method method = methods[i];

  39. SEL selector = method_getName(method);

  40. NSString*name =NSStringFromSelector(selector);

  41. NSLog(@"%@ Class Methods: %@", className, name);

  42. }

  43. free(methods);

  44. }

从打印 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里面的指针值进行比较。

Image

Image

通过打印出来的指针地址都是: 0x283d078e0,我们可以确定 _renderPipeUser持有了 xpcConnection指针跟 AUAudioUnit_XPC对象持有的 _xpcConnection指针应该指向了同一个对象。。

而从上面两个堆栈可以看到, -[AVAudioEnginedealloc]和 -[AVAudioEnginestop]都会调用 AVAudioEngineImpl::Stop(NSError**)+396(AVAudioEngine.mm:1081),调用都在子线程调用,

因此我们可以推测出是多线程的操作,导致了 xpcConnection对象野指针。

三. 解决方案

  1. ## 方案一(失败方案)

该方案并没有成功解决这个崩溃,但也属于探索过程的一部分,如果有兴趣可以看下,没兴趣可以直接看后面的治理方案。

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相关操作都是在串行队列里面执行,来保证线程安全。

  1. @interfaceXLAudioUnitXpcWrapper:NSProxy

  2. @end

  3. @implementationXLAudioUnitXpcWrapper

  4. {

  5. NSXPCConnection*_xpcConnection;

  6. }

  7. -(instancetype)initWithXpcConnection:(NSXPCConnection*)xpcConnection {

  8. self=[[selfclass] alloc];

  9. _xpcConnection = xpcConnection;

  10. returnself;

  11. }

  12. -(NSMethodSignature*)methodSignatureForSelector:(SEL)sel {

  13. return[_xpcConnection methodSignatureForSelector:sel];

  14. }

  15. -(void)forwardInvocation:(NSInvocation*)invocation {

  16. staticdispatch_queue_t queue =nil;// 是否要关联 target?

  17. if(queue ==nil){

  18. queue = dispatch_queue_create("com.platform.taskqueue",0x0);

  19. }

  20. dispatch_sync(queue,^{

  21. [invocation invokeWithTarget:_xpcConnection];

  22. });

  23. }

  24. @end

  25. +(void)startAVAudioEngineCrashFix {

  26. Class audioUnitClass =NSClassFromString(@"AUAudioUnit_XPC");

  27. [audioUnitClass hd_hookMethod:NSSelectorFromString(@"allocateRenderResourcesAndReturnError:") option:HDHookOptionBefore handle:^(HDInvocation*invocation){

  28. id obj = invocation.target;

  29. unsignedint count =0;

  30. Ivar*ivars = class_copyIvarList(audioUnitClass,&count);

  31. for(int i =0; i < count; i ++){

  32. Ivar ivar = ivars[i];

  33. constchar*ivar_name = ivar_getName(ivar);

  34. if(strcmp([@"_xpcConnection" cStringUsingEncoding:NSUTF8StringEncoding], ivar_name)==0){

  35. id ivar_value = object_getIvar(obj, ivar);

  36. if(![ivar_value isKindOfClass:[NSXPCConnectionclass]]){

  37. break;

  38. }

  39. XLAudioUnitXpcWrapper*tmpWrapper =[[XLAudioUnitXpcWrapper alloc] initWithXpcConnection:(NSXPCConnection*)ivar_value];

  40. [obj setValue:tmpWrapper forKey:@"_xpcConnection"];

  41. break;

  42. }

  43. }

  44. } error:nil];

  45. }

然后将代码在在 iOS17.1.1的系统上的手机上运行,执行文本转语音的相关代码

  1. let synth =AVSpeechSynthesizer()

  2. let utterance =AVSpeechUtterance(string:"Here we go")

  3. synth.speak(utterance)// synthprovider.offlineRendering problem

运行后会在 -[AVAudioEngineconnect:to:format:]方法内部抛出异常。

Image

这时候具体分析抛出异常的堆栈,我们可以看到 AVAudioEngineGraph.mm是 OC和 C++混编,也就是说这里对于 _xpcConnection变量,使用的引用计数管理是 MRC。

Image

因此将修复的文件设置为 MRC.

  • 同时添加版本限制,只针对 iOS17.0-iOS17.2的系统开启,同时添加降级方案。

B. 上线结果

上线之后原先的崩溃没出现,但依然出现了新的崩溃,且新崩溃量跟之前崩溃量差不多,因此将该修复方案降级回来。

Image

该方案之所以会引发别的崩溃,主要原因在于消息转发只适用于 runtime的调用方法, _xpcConnection在 OC和 C++内部存在着直接通过地址偏移的调用方式,导致这些调用没有走消息转发,因此出现了其他额外的崩溃。

  1. ## 方案二(成功方案)

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表明这是一个文本转语音相关调用,则不去执行原方法。

Image

  1. @implementationAVAudioEngine(XLSystemCrashFix)

  2. +(void)startAVAudioEngineCrashFix {

  3. BOOL isHookDealloc =[self hookInstanceMethodOf:NSSelectorFromString(@"dealloc")with:@selector(xl_dealloc)];

  4. BOOL isHookStop =[self hookInstanceMethodOf:NSSelectorFromString(@"stop")with:@selector(xl_stop)];

  5. if((isHookDealloc ==false)||(isHookStop ==false)){

  6. #if DEBUG

  7. NSLog(@"AVAudioEngine hook unSuccess");

  8. #endif

  9. }

  10. }

  11. +(void)updateDeallocDelayEnable:(BOOL)delllocDelayEnable {

  12. [[HLLSafeBox standardBox] setBool:delllocDelayEnable forKey:XLAVAudioEngineDellocKey];

  13. }

  14. +(void)updateStopDelayEnable:(BOOL)stopDelayEnable {

  15. [[HLLSafeBox standardBox] setBool:stopDelayEnable forKey:XLAVAudioEngineStopKey];

  16. }

  17. -(void)xl_dealloc {

  18. NSString*tmpString =[NSThread.callStackSymbols componentsJoinedByString:@"\n"];

  19. BOOL isContainTextSpeech =[tmpString containsString:@"TextToSpeech"];

  20. if([[HLLSafeBox standardBox] boolForKey:XLAVAudioEngineDellocKey]){

  21. if(isContainTextSpeech ==false){

  22. [self xl_dealloc];

  23. }

  24. }else{

  25. [self xl_dealloc];

  26. }

  27. if(isContainTextSpeech){

  28. [[NSNotificationCenter defaultCenter] postNotificationName:XLAVAudioEngineCrashNotiobject:tmpString];

  29. }

  30. }

  31. -(void)xl_stop {

  32. NSString*tmpString =[NSThread.callStackSymbols componentsJoinedByString:@"\n"];

  33. BOOL isContainTextSpeech =[tmpString containsString:@"TextToSpeech"];

  34. if([[HLLSafeBox standardBox] boolForKey:XLAVAudioEngineStopKey]){

  35. if(isContainTextSpeech ==false){

  36. [self xl_stop];

  37. }

  38. }else{

  39. [self xl_stop];

  40. }

  41. if(isContainTextSpeech){

  42. [[NSNotificationCenter defaultCenter] postNotificationName:XLAVAudioEngineCrashNotiobject:tmpString];

  43. }

  44. }

  45. @end

这里之所以 hook了 -[AVAudioEnginestop]和 -[AVAudioEnginedealloc]方法,而不 hook住 -[AUAudioUnit_XPCinternalDeallocateRenderResources],主要因为虽然调试中没法复现语音合成播报的崩溃堆栈,但因为 AVAudioEngine是系统类,可以直接调用,相关 stop等方法,从理论上来说不执行这个方法,不会有什么影响。而对于 AUAudioUnit_XPC了解较少。

B. 上线结果

上线之后,我们可以看到,最新的版本是 1.7.10版本,而这个崩溃最后出现的版本是 1.7.0,也就表明上线之后,新版本就没再出现过。

Image

虽然上线之后没有再产生这个类型的崩溃,但依然需要去校验下这个方案有没有产生额外的风险或者问题。

因此我们从 -[AVAudioEnginestop]和 -[AVAudioEnginedealloc]埋点上报统计到的司机中,抽查了一部分司机,查看了司机实时日志,从日志发现司机上报埋点后,其他操作流程依然正常,接单、履约都没有出现异常。也回访了几个司机,在上报埋点后的时间段后,没有出现任何异常。

然后也查看了其他稳定下数据比如卡顿、卡死、 abort、启动等方面数据,也没有新增额外 case或者数据出现波动。

经过多方面校验、认证后,认为这个崩溃修复是合理、有用的。

四. 总结

以上主要介绍了针对这个崩溃分析和治理方案的探索过程,中间也经历了很多次修复尝试,虽然最后的治理方案,并没有针对根本的崩溃原因进行治理,而是选择从一个方面去规避,来解决这个崩溃。这也是很常见的治理思路,因为崩溃在系统底层,从底层去改风险大、复杂度高,所以在了解原理后,从上层调用或者业务侧去规避解决,也不失为一种好方法。

若本文有错误之处或其他治理方案或者技术上关于其他类型 Crash的讨论交流的,欢迎评论区留言。