如何找到程序崩溃的 “凶手” ()

1. 前言 在 iOS 应用程序开发过程中,我们难免会碰到因各种异常而导致应用程序崩溃的情况。
对于开发过程中遇到的崩溃,我们可以根据本地崩溃信息快速定位问题。但对于线上版本发生的一些崩溃情况,我们只能通过收集崩溃信息来分析具体的原因。虽然 Apple 提供了崩溃信息上报的功能,但是并非所有的用户都开启了该功能。因此,对于数据采集 SDK 来说,采集崩溃信息并上报是一项必不可少的功能。
下面针对神策分析 iOS SDK 崩溃采集模块进行解析,希望能够给大家提供一些参考。
2. 崩溃类型 采集应用程序的崩溃信息,主要分为以下两种场景:
NSException 异常;
Unix 信号异常。
设计崩溃采集方案之前,我们不妨先认识一下 NSException 和 Unix 信号。
2.1. NSException NSException 是 Foundation 框架提供的一个类。用于封装一些异常信息,在需要的时候向外抛出。封装的异常信息包括异常名称、异常原因、调用堆栈。
@interface NSException : NSObject @property (readonly, copy) NSExceptionName name; @property (nullable, readonly, copy) NSString reason; @property (readonly, copy) NSArray callStackSymbols; @end
在 iOS 应用程序中,最常见的就是通过 @throw 抛出的异常,如图 2-1 所示:
如何找到程序崩溃的 “凶手” ()
文章图片

图 2-1 异常处理流程(图片来源于 Apple 开发者文档)
比如常见的数组越界访问异常:
@throw [NSException exceptionWithName:@"NSRangeException" reason:@"index 2 beyond bounds [0 .. 1]" userInfo:nil];
运行程序会出现如下异常信息:
Terminating app due to uncaught exception 'NSRangeException', reason: 'index 2 beyond bounds [0 .. 1]'terminating with uncaught exception of type NSException
2.2. Unix 信号 在 iOS 系统自动采集的崩溃日志中,经常可以看到类似下面的日志:
Exception Type: EXC_BAD_ACCESS (SIGSEGV)Exception Subtype: KERNINVALIDADDRESS at 0x0000000001000010VM Region Info: 0x1000010is not in any region. Bytes before following region: 4283498480REGION TYPE START - END [ VSIZE] PRT/MAX SHRMOD REGION DETAIL UNUSED SPACE AT START TEXT 0000000100510000-0000000100514000 [16K] r-x/r-x SM=COW.app/EkuaibaoTermination Signal: Segmentation fault: 11Termination Reason: Namespace SIGNAl, Code 0xb Terminating Process: exc handler [21776]Triggered by Thread: 9
其中,Exception Type 中的两个字段 EXC_BAD_ACCESS 和 SIGSEGV 分别指 Mach 异常和 Unix 信号。
那什么是 Mach 异常和 Unix 信号呢?
Mach 是 macOS 和 iOS 操作系统的微内核,Mach 异常是最底层的内核级异常。Mach 异常会被转换成相应的 Unix 信号,并传递给出错的线程。上述 Exception Type 中的 EXC_BAD_ACCESS 是 Mach 层的异常,被转换成了 Unix 信号 SIGSEGV,然后传递给出错的线程。之所以会将 Mach 异常转换成 Unix 信号,是为了兼容 POSIX 标准(SUS 规范),这样一来,开发者即使不了解 Mach 内核也可以通过 Unix 信号的方式进行兼容开发。
Unix 信号的种类有很多,在 iOS 应用程序中,常见的 Unix 信号有如下几种:
SIGILL:程序非法指令信号,通常是因为可执行文件本身出现错误,或者试图执行数据段。堆栈溢出时也有可能产生该信号;
SIGABRT:程序中止命令中止信号,调用 abort 函数时产生该信号;
SIGBUS:程序内存字节地址未对齐中止信号,比如访问一个 4 字节长的整数,但其地址不是 4 的倍数;
SIGFPE:程序浮点异常信号,通常在浮点运算错误、溢出及除数为 0 等算术错误时都会产生该信号;
SIGKILL:程序结束接收中止信号,用来立即结束程序运行,不能被处理、阻塞和忽略;
SIGSEGV:程序无效内存中止信号,即试图访问未分配的内存,或向没有写权限的内存地址写数据;
SIGPIPE:程序管道破裂信号,通常是在进程间通信时产生该信号;
SIGSTOP:程序进程中止信号,与 SIGKILL 一样不能被处理、阻塞和忽略。
神策分析 iOS SDK 针对 NSException 异常和 Unix 信号异常设计并实现了一套适用于数据分析的崩溃采集方案。
3. NSException 异常采集 3.1. 方案简介 NSException 类中定义的 NSSetUncaughtExceptionHandler 可以设置全局异常处理函数。因此,我们可以先通过 NSSetUncaughtExceptionHandler 设置的函数来处理异常,然后收集异常堆栈信息并触发相应的事件($AppCrashed),来实现 NSException 异常的埋点。
NSSetUncaughtExceptionHandler 函数接收一个 C 语言函数的指针,函数定义如下:
typedef void NSUncaughtExceptionHandler(NSException exception); FOUNDATION_EXPORT void NSSetUncaughtExceptionHandler(NSUncaughtExceptionHandler _Nullable);
3.2. 具体实现 设计采集 $AppCrashed 事件的方法,将堆栈信息记录到事件属性 app_crashed_reason 中:

  • (void)sa_handleUncaughtException:(NSException *)exception { // 采集 $AppCrashed 事件 SensorsAnalyticsSDK *sdk = [SensorsAnalyticsSDK sharedInstance]; if (sdk.configOptions.enableTrackAppCrash) { NSMutableDictionary *properties = [[NSMutableDictionary alloc] init]; if ([exception callStackSymbols]) { // 若有异常堆栈信息即获取异常堆栈信息 NSString *exceptionStack = [[exception callStackSymbols] componentsJoinedByString:@"\n"]; // 采集应用程序崩溃原因 [properties setValue:[NSString stringWithFormat:@"Exception Reason:%@\nException Stack:%@", [exception reason], exceptionStack] forKey:@"app_crashed_reason"]; } else { // 若无异常堆栈信息即获取线程堆栈信息 NSString *exceptionStack = [[NSThread callStackSymbols] componentsJoinedByString:@"\n"]; // 采集应用程序崩溃原因 [properties setValue:[NSString stringWithFormat:@"%@ %@", [exception reason], exceptionStack] forKey:@"app_crashed_reason"]; } // 触发 $AppCrashed 事件 [sdk trackPresetEvent:SA_EVENT_NAME_APP_CRASHED properties:properties]; } NSSetUncaughtExceptionHandler(NULL); }
创建 SensorsAnalyticsExceptionHandler 类并新增 + sharedHandler 方法:
  • (instancetype)sharedHandler { static SensorsAnalyticsExceptionHandler *gSharedHandler = nil; static dispatch_once_t onceToken; dispatch_once(&onceToken, ^{ gSharedHandler = [[SensorsAnalyticsExceptionHandler alloc] init]; }); return gSharedHandler; }
实现 SensorsAnalyticsExceptionHandler 类的初始化方法 - init,设置全局异常处理函数并触发 $AppCrashed 事件:
  • (instancetype)init { self = [super init]; if (self) { [self setupHandlers]; } return self; } - (void)setupHandlers { // 设置全局异常处理函数 NSSetUncaughtExceptionHandler(&SAHandleException); } static void SAHandleException(NSException exception) { SensorsAnalyticsExceptionHandler handler = [SensorsAnalyticsExceptionHandler sharedHandler]; // 处理捕获的 NSException 异常,触发 $AppCrashed 事件 [handler sa_handleUncaughtException:exception]; }
在 SensorsAnalyticsSDK 类的 - initWithConfigOptions:debugMode: 方法中初始化 SensorsAnalyticsExceptionHandler 类的单例对象:
  • (instancetype)initWithConfigOptions:(nonnull SAConfigOptions *)configOptions debugMode:(SensorsAnalyticsDebugMode)debugMode { self = [super init]; if (self) { // 开启崩溃采集功能 if (_configOptions.enableTrackAppCrash) { [[SensorsAnalyticsExceptionHandler sharedHandler]; } } return self; }
3.3. 方案优化 在实际开发过程中,可能会集成多个 SDK,如果这些 SDK 都按照上面介绍的方法采集异常信息,总会有一些 SDK 采集不到异常信息。这是因为通过 NSSetUncaughtExceptionHandler 函数设置的是一个全局异常处理函数,后面设置的异常处理函数会自动覆盖前面设置的异常处理函数。
那么如何解决这个问题呢?
常见的做法是:在调用 NSSetUncaughtExceptionHandler 函数设置全局异常处理函数之前,先通过 NSGetUncaughtExceptionHandler 函数获取之前已设置的异常处理函数并保存,在处理完异常信息后,再主动调用已保存的处理函数,即可解决上面提到的覆盖问题。
新增一个 NSUncaughtExceptionHandler 类型的属性 defaultExceptionHandler ,用来保存之前已经设置的异常处理函数:
@property (nonatomic) NSUncaughtExceptionHandler *defaultExceptionHandler; - (void)setupHandlers { // 备份之前设置的异常处理函数 _defaultExceptionHandler = NSGetUncaughtExceptionHandler(); // 设置全局异常处理函数 NSSetUncaughtExceptionHandler(&SAHandleException); }
触发 $AppCrashed 事件后调用之前已设置的异常处理函数,传递 UncaughtExceptionHandler :
static void SAHandleException(NSException *exception) { // 处理捕获的 NSException 异常,触发 $AppCrashed 事件 // 传递 UncaughtExceptionHandler if (handler.defaultExceptionHandler) { handler.defaultExceptionHandler(exception); }}
通过上面的处理,即可把所有的异常处理函数形成链条,确保之前设置的异常处理函数也能采集到异常信息。
4. Unix 信号异常采集 4.1. 方案简介 在 iOS 应用程序中,一般情况下会采集 SIGILL、SIGABRT、SIGBUS、SIGFPE 和 SIGSEGV 这几个常见的信号,即能满足日常采集应用程序异常信息的需求。我们可以先新增信号处理函数,然后注册信号处理函数,使用 Unix 信号信息构造一个 NSException 对象,复用上节采集 $AppCrashed 事件的方法。
4.2. 具体实现 新增捕获 Unix 信号的处理函数:
static NSString const UncaughtExceptionHandlerSignalExceptionName = @"UncaughtExceptionHandlerSignalExceptionName"; static NSString const UncaughtExceptionHandlerSignalKey = @"UncaughtExceptionHandlerSignalKey"; static void SASignalHandler(int crashSignal, struct __siginfo info, void context) { SensorsAnalyticsExceptionHandler handler = [SensorsAnalyticsExceptionHandler sharedHandler]; // 将 Unix 信号异常构造成 NSException 异常 NSDictionary userInfo = @{UncaughtExceptionHandlerSignalKey: @(crashSignal)}; NSString reason = [NSString stringWithFormat:@"Signal %d was raised.", crashSignal]; NSException exception = [NSException exceptionWithName:UncaughtExceptionHandlerSignalExceptionName reason:reason userInfo:userInfo]; // 处理捕获的 Unix 信号异常,触发 $AppCrashed 事件 [handler sa_handleUncaughtException:exception]; }
注册信号处理函数:
  • (void)setupHandlers { // 备份和设置 NSException 全局异常处理函数 // 定义信号集结构体 struct sigaction action; // 将信号集初始化为空 sigemptyset(&action.sa_mask); // 在处理函数中传入 __siginfo 参数 action.sa_flags = SA_SIGINFO; // 设置信号处理函数 action.sa_sigaction = &SASignalHandler; // 定义需要采集的信号类型 int signals[] = {SIGABRT, SIGILL, SIGSEGV, SIGFPE, SIGBUS}; for (int i = 0; i < sizeof(signals) / sizeof(int); i++) { struct sigaction prev_action; int err = sigaction(signals[i], &action, &prev_action); if (err) { SALogError(@"Errored while trying to set up sigaction for signal %d", signals[i]); } }}
注意:由于 Unix 信号异常对象是我们自己构建的,因此没有堆栈信息,这里默认获取当前线程的堆栈信息。上节 - sa_handleUncaughtException: 方法中已经处理该逻辑。
4.3. 方案优化 同样,为了避免影响其他 SDK 捕获 Unix 信号,我们应当在处理 Unix 信号之前保存已经设置的 Unix 信号异常处理函数。然后,在处理完异常信息后再主动调用保存的 Unix 信号异常处理函数。传递 Unix 信号的逻辑与上节传递 UncaughtExceptionHandler 类似。
新增一个属性 prev_signal_handlers ,用来保存之前已经设置的 Unix 信号异常处理函数:
@property (nonatomic, unsafe_unretained) struct sigaction prev_signal_handlers; - (void)setupHandlers { // 备份和设置 NSException 全局异常处理函数 // 注册信号集 struct sigaction action; sigemptyset(&action.sa_mask); action.sa_flags = SA_SIGINFO; action.sa_sigaction = &SASignalHandler; int signals[] = {SIGABRT, SIGILL, SIGSEGV, SIGFPE, SIGBUS}; for (int i = 0; i < sizeof(signals) / sizeof(int); i++) { struct sigaction prev_action; int err = sigaction(signals[i], &action, &prev_action); if (err == 0) { char address_action = (char )&prev_action; // 保存 Unix 信号异常处理函数 char address_signal = (char *)(_prev_signal_handlers + signals[i]); strlcpy(address_signal, address_action, sizeof(prev_action)); } else { SALogError(@"Errored while trying to set up sigaction for signal %d", signals[i]); } }}
触发 $AppCrashed 事件后向之前保存的异常处理函数传递 Unix 信号并调用:
static void SASignalHandler(int crashSignal, struct __siginfo info, void context) { // 处理捕获的 Unix 信号异常,触发 $AppCrashed 事件 // 获取异常处理函数并其传递 Unix 信号 struct sigaction prev_action = handler.prev_signal_handlers[crashSignal]; if (prev_action.sa_flags & SA_SIGINFO) { if (prev_action.sa_sigaction) { prev_action.sa_sigaction(crashSignal, info, context); } } else if (prev_action.sa_handler && prev_action.sa_handler != SIG_IGN) { // SIG_IGN 表示忽略信号 prev_action.sa_handler(crashSignal); }}
注意:如果其他 SDK 在处理 Unix 信号时忽略了某个信号,那么在触发 $AppCrashed 事件后应当避免向其传递忽略的 Unix 信号,我们在调用 sa_handler 函数时做了判断以处理该逻辑。
5. 补发退出事件 一旦程序发生异常,我们就采集不到 App 退出事件($AppEnd)。这样会造成在用户的行为序列中,出现 App 启动事件($AppStart)和 App 退出事件($AppEnd)不成对的情况。因此,在应用程序发生崩溃时,我们需要补发 $AppEnd 事件:
  • (void)sa_handleUncaughtException:(NSException *)exception { // 采集 $AppCrashed 事件 // 补发 $AppEnd 事件 if (![sdk isAutoTrackEventTypeIgnored:SensorsAnalyticsEventTypeAppEnd]) { [SACommonUtility performBlockOnMainThread:^{ if (UIApplication.sharedApplication.applicationState == UIApplicationStateActive) { [sdk trackAutoEvent:SA_EVENT_NAME_APP_END properties:nil]; } }]; } // 阻塞当前线程,完成 serialQueue 中数据相关的任务 sensorsdata_dispatch_safe_sync(sdk.serialQueue, ^{}); }
在进行这样的处理之后,当应用程序发生异常时,我们不仅可以采集 $AppCrashed 事件,还能正常采集 $AppEnd 事件。
6. 总结 本文主要介绍了神策分析 iOS SDK 崩溃采集模块的具体实现。SDK 崩溃采集涵盖了 NSException 异常和 Unix 信号异常,详细的实现可以参考 iOS SDK 源码。
最后,希望通过这篇文章,大家能够对神策分析 iOS SDK 的崩溃模块有一个系统的了解。
7. 参考文献 https://developer.apple.com/d...
https://developer.apple.com/l...
https://mp.weixin.qq.com/s/hO...
https://zh.wikipedia.org/wiki...
https://blog.51cto.com/arthur...
https://github.com/sensorsdat...
【如何找到程序崩溃的 “凶手” ()】文章来源:公众号神策技术社区

    推荐阅读