1. 前言
在 iOS 应用程序开发过程中,我们难免会碰到因各种异常而导致应用程序崩溃的情况。
对于开发过程中遇到的崩溃,我们可以根据本地崩溃信息快速定位问题。但对于线上版本发生的一些崩溃情况,我们只能通过收集崩溃信息来分析具体的原因。虽然 Apple 提供了崩溃信息上报的功能,但是并非所有的用户都开启了该功能。因此,对于数据采集 SDK 来说,采集崩溃信息并上报是一项必不可少的功能。
下面针对神策分析 iOS SDK 崩溃采集模块进行解析,希望能够给大家提供一些参考。
2. 崩溃类型
采集应用程序的崩溃信息,主要分为以下两种场景:
- NSException 异常;
- Unix 信号异常。
设计崩溃采集方案之前,我们不妨先认识一下 NSException 和 Unix 信号。
2.1. NSException
NSException 是 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 开发者文档)
比如常见的数组越界访问异常:
@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 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 异常是最底层的内核级异常。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/documentation/foundation/nsexception
- https://developer.apple.com/library/archive/documentation/Cocoa/Conceptual/Exceptions/Tasks/HandlingExceptions.html#//apple_ref/doc/uid/20000059-SW1
- https://mp.weixin.qq.com/s/hOOzVzJ-nAtkQ8iD-8wVGg
- https://zh.wikipedia.org/wiki/%E5%96%AE%E4%B8%80UNIX%E8%A6%8F%E7%AF%84
- https://blog.51cto.com/arthurchen/736181
- https://github.com/sensorsdata/sa-sdk-ios