一、引言
在 iOS 应用程序开发过程中,我们难免会碰到因各种异常而导致应用程序崩溃的情况。
对于开发过程中遇到的崩溃,我们可以根据本地崩溃信息快速定位问题。但对于线上版本发生的一些崩溃情况,我们只能通过收集崩溃信息来分析具体的原因。虽然 Apple 提供了崩溃信息上报的功能,但是并非所有的用户都开启了该功能。因此,对于数据采集 SDK 来说,采集崩溃信息并上报是一项必不可少的功能。
下面针对神策分析 iOS SDK 崩溃采集模块进行解析,希望能够给大家提供一些参考。
二、崩溃类型
采集应用程序的崩溃信息,主要分为以下两种场景:
- NSException 异常;
- Unix 信号异常。
设计崩溃采集方案之前,我们不妨先认识一下 NSException 和 Unix 信号。
2.1 NSException
NSException[1] 是 Foundation 框架提供的一个类。用于封装一些异常信息,在需要的时候向外抛出。封装的异常信息包括异常名称、异常原因、调用堆栈。
@interface NSException : NSObject <NSCopying, NSSecureCoding>
@property (readonly, copy) NSExceptionName name;
@property (nullable, readonly, copy) NSString *reason;
@property (readonly, copy) NSArray<NSString *> *callStackSymbols;
@end
复制代码
在 iOS 应用程序中,最常见的就是通过 @throw 抛出的异常,如图 2-1 所示:
图 2-1 异常处理流程(图片来源于 Apple 开发者文档[2] )
比如常见的数组越界访问异常:
@throw [NSException exceptionWithName:@"NSRangeException" reason:@"index 2 beyond boun
复制代码
运行程序会出现如下异常信息:
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 0x0000000001000010
VM Region Info: 0x1000010is not in any region. Bytes before following region: 4283498480
REGION TYPE START - END [ VSIZE] PRT/MAX SHRMOD REGION DETAIL UNUSED SPACE AT START TEXT 0000000100510000-0000000100514000 [16K] r-x/r-x SM=COW
.app/Ekuaibao
Termination Signal: Segmentation fault: 11
Termination 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 异常是最底层的内核级异常[3]。Mach 异常会被转换成相应的 Unix 信号,并传递给出错的线程。上述 Exception Type 中的 EXC_BAD_ACCESS 是 Mach 层的异常,被转换成了 Unix 信号 SIGSEGV,然后传递给出错的线程。之所以会将 Mach 异常转换成 Unix 信号,是为了兼容 POSIX 标准(SUS 规范)[4],这样一来,开发者即使不了解 Mach 内核也可以通过 Unix 信号的方式进行兼容开发。
Unix 信号的种类有很多,在 iOS 应用程序中,常见的 Unix 信号[5]有如下几种:
- SIGILL:程序非法指令信号,通常是因为可执行文件本身出现错误,或者试图执行数据段。堆栈溢出时也有可能产生该信号;
- SIGABRT:程序中止命令中止信号,调用 abort 函数时产生该信号;
- SIGBUS:程序内存字节地址未对齐中止信号,比如访问一个 4 字节长的整数,但其地址不是 4 的倍数;
- SIGFPE:程序浮点异常信号,通常在浮点运算错误、溢出及除数为 0 等算术错误时都会产生该信号;
- SIGKILL:程序结束接收中止信号,用来立即结束程序运行,不能被处理、阻塞和忽略;
- SIGSEGV:程序无效内存中止信号,即试图访问未分配的内存,或向没有写权限的内存地址写数据;
- SIGPIPE:程序管道破裂信号,通常是在进程间通信时产生该信号;
- SIGSTOP:程序进程中止信号,与 SIGKILL 一样不能被处理、阻塞和忽略。
神策分析 iOS SDK 针对 NSException 异常和 Unix 信号异常设计并实现了一套适用于数据分析的崩溃采集方案。
三、NSException异常采集
3.1 方案简介
NSException 类中定义的 NSSetUncaughtExceptionHandler 可以设置全局异常处理函数。因此,我们可以先通过 NSSetUncaughtExceptionHandler 设置的函数来处理异常,然后收集异常堆栈信息并触发相应的事件($AppCrashed),来实现 NSException 异常的埋点。
NSSetUncaughtExceptionHandler 函数接收一个 C 语言函数的指针,函数定义如下:
typedef void NSUncaughtExceptionHandler(NSException *exception);
FOUNDATION_EXPORT void NSSetUncaughtExceptionHandler(NSUncaughtExceptionHandl
复制代码
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);
}
}
复制代码
通过上面的处理,即可把所有的异常处理函数形成链条,确保之前设置的异常处理函数也能采集到异常信息。
四、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 函数时做了判断以处理该逻辑。
五、补发退出事件
一旦程序发生异常,我们就采集不到 App 退出事件(AppStart)和 App 退出事件(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, ^{});
}
复制代码
在进行这样的处理之后,当应用程序发生异常时,我们不仅可以采集 AppEnd 事件。
六、总结
本文主要介绍了神策分析 iOS SDK 崩溃采集模块的具体实现。SDK 崩溃采集涵盖了 NSException 异常和 Unix 信号异常,详细的实现可以参考 iOS SDK 源码[6]。
最后,希望通过这篇文章,大家能够对神策分析 iOS SDK 的崩溃模块有一个系统的了解。
参考文献:
[1]developer.apple.com/documentati…
[2]developer.apple.com/library/arc…
[3]mp.weixin.qq.com/s/hOOzVzJ-n…
[4]zh.wikipedia.org/wiki/%E5%96…