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

1,957 阅读13分钟

一. 背景

我们司机端从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对象_renderPipeUserNSXPCConnection的对象_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指针应该指向了同一个对象。

我们hookAUAudioUnit_XPCallocateRenderResourcesAndReturnError:方法,然后遍历AUAudioUnit_XPC实例里面的变量,找到_xpcConnection查看它的指针值,跟_renderPipeUser里面的指针值进行比较。

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

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

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

三. 解决方案

  1. 方案一(失败方案)

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

A. 治理方案

既然了解到根本原因是因为多线程操作导致了AUAudioUnit_XPC对象的_xpcConnection对象野指针,那如何解决呢?

因为_xpcConnectionAUAudioUnit_XPC实例对象里面是一个内部私有变量,且没有提供关于访问这个对象的setget方法。

因此我这里想到的第一个方法是,可以寻找一个时机即AUAudioUnit_XPC对象的_xpcConnection变量,赋值给_renderPipeUserxpcConnection之前,将_xpcConnectionNSXPCConnection类型转换为XLAudioUnitXpcWrapper类型。

经查找只有AUAudioUnit_XPC方法列表里面的allocateRenderResourcesAndReturnError:方法,在调用之前,_xpcConnection是初始化的,且未赋值给_renderPipeUserxpcConnection

然后将原本的_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.mmOCC++混编,也就是说这里对于_xpcConnection变量,使用的引用计数管理是MRC

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

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

B. 上线结果

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

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

  1. 方案二(成功方案)

A. 治理方案

由于上面第一种治理方案失效,而_xpcConnectionAUAudioUnit_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的讨论交流的,欢迎评论区留言。