如何找到程序崩溃的 “凶手” ()
1. 前言
在 iOS 应用程序开发过程中,我们难免会碰到因各种异常而导致应用程序崩溃的情况。
对于开发过程中遇到的崩溃,我们可以根据本地崩溃信息快速定位问题。但对于线上版本发生的一些崩溃情况,我们只能通过收集崩溃信息来分析具体的原因。虽然 Apple 提供了崩溃信息上报的功能,但是并非所有的用户都开启了该功能。因此,对于数据采集 SDK 来说,采集崩溃信息并上报是一项必不可少的功能。
下面针对神策分析 iOS SDK 崩溃采集模块进行解析,希望能够给大家提供一些参考。
2. 崩溃类型
采集应用程序的崩溃信息,主要分为以下两种场景:
NSException 异常;
Unix 信号异常。
设计崩溃采集方案之前,我们不妨先认识一下 NSException 和 Unix 信号。
2.1. NSException
NSException 是 Foundation 框架提供的一个类。用于封装一些异常信息,在需要的时候向外抛出。封装的异常信息包括异常名称、异常原因、调用堆栈。
@interface NSException : NSObject
在 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 中:
创建 SensorsAnalyticsExceptionHandler 类并新增 + sharedHandler 方法:
实现 SensorsAnalyticsExceptionHandler 类的初始化方法 - init,设置全局异常处理函数并触发 $AppCrashed 事件:
在 SensorsAnalyticsSDK 类的 - initWithConfigOptions:debugMode: 方法中初始化 SensorsAnalyticsExceptionHandler 类的单例对象:
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];
}
注册信号处理函数:
注意:由于 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 事件:
在进行这样的处理之后,当应用程序发生异常时,我们不仅可以采集 $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...
【如何找到程序崩溃的 “凶手” ()】文章来源:公众号神策技术社区
推荐阅读
- 考研英语阅读终极解决方案——阅读理解如何巧拿高分
- 如何寻找情感问答App的分析切入点
- 基于微信小程序带后端ssm接口小区物业管理平台设计
- mybatisplus如何在xml的连表查询中使用queryWrapper
- MybatisPlus使用queryWrapper如何实现复杂查询
- 事件处理程序
- 如何在Mac中的文件选择框中打开系统隐藏文件夹
- 漫画初学者如何学习漫画背景的透视画法(这篇教程请收藏好了!)
- java中如何实现重建二叉树
- Linux下面如何查看tomcat已经使用多少线程