iOS 崩溃信息收集实践
iOS 崩溃信息收集
最近项目要求收集应用使用过程中的崩溃信息,在网上搜索了一番后,了解目前崩溃信息收集有如下几种途径:iTunes Connect导出手机上传日志、拿到用户手机使用 Xcode 导出、使用第三方崩溃收集服务(如 Bugly、友盟等)。从及时性和可定制角度来看上面几种都不符合项目的需求,基于上述需求背景要求必须学习手动收集崩溃信息。
导致崩溃的问题
导致应用崩溃的问题主要有两种:
- C++语言层面的错误,比如野指针、除零、内存非法访问等;
- 未捕获异常(Uncaught Exception),在 iOS 中最常见的就是通过 @throw 抛出的 NSException(常见的错误,比如数组访问越界)
对于第一种问题,由于 iOS 和 Android 底层系统都是 Unix 或者类 Unix 系统,可以采用信号机制来捕获 signal 或 sigaction,通过设置的回调函数来收集信号的上下文信息。
第二种问题可以通过 NSSetUncaughtExceptionHandler 设置异常处理回调函数来收集异常的调用堆栈。
收集崩溃的上下文信息
使用 NSUncaughtExceptionHandler 捕获 NSException
通过 void NSSetUncaughtExceptionHandler(NSUncaughtExceptionHandler *)
函数设置异常发生时对应的事件处理函数,NSUncaughtExceptionHandler
是一个函数指针 typedef void NSUncaughtExceptionHandler(NSException *exception)
,该函数指针的入参是 NSException,包含该异常的调用堆栈:
void InstallUncaughtExceptionHandler(void) {
// Backup original handler
g_previousUncaughtExceptionHandler = NSGetUncaughtExceptionHandler();
NSSetUncaughtExceptionHandler(&HandleException);
}
void MyUncaughtExceptionHandler(NSException *exception) {
// 异常的堆栈信息
NSArray *stackArray = [exception callStackSymbols];
// 出现异常的原因
NSString *reason = [exception reason];
// 异常名称
NSString *name = [exception name];
NSString *exceptionInfo = [NSString stringWithFormat:@"Exception reason:%@\nException name:%@\nException stack:%@",name, reason, stackArray];
NSLog(@"%@", exceptionInfo);
[UncaughtExceptionHandler saveCreash:exceptionInfo];
if (g_previousUncaughtExceptionHandler != NULL) {
g_previousUncaughtExceptionHandler(exception);
}
}
上面是捕获异常的简单示例。
捕获 Signal 信号
Signal 信号是 Unix 系统中一种用于异步通知的机制。信号传递给进程后,在没有设置处理函数的情况下,程序可以指定三种行为:
- 忽略信号,但 SIGKILL 和 SIGSTOP 信号不可忽略;
- 使用默认的处理函数 SIG_DFL,大多数信号的默认动作是终止进程;
- 捕获信号,执行用户定义的函数。
这里有两个特殊的常量:
- SIG_IGN:向内核表示忽略此信号。对于不能忽略的两个信号SIGKILL和SIGSTOP,调用时会报错;
- SIG_DFL:执行该信号的系统默认动作.
常用函数:
-
int kill(pid_t pid, int signo)
发送信号到指定的进程 -
int raise(int signo)
发送信号给自己
Unix 系统中常见信号有如下几种:
SIGABRT--程序中止命令中止信号
SIGALRM--程序超时信号
SIGFPE--程序浮点异常信号
SIGILL--程序非法指令信号
SIGHUP--程序终端中止信号
SIGINT--程序键盘中断信号
SIGKILL--程序结束接收中止信号
SIGTERM--程序kill中止信号
SIGSTOP--程序键盘中止信号
SIGSEGV--程序无效内存中止信号
SIGBUS--程序内存字节未对齐中止信号
SIGPIPE--程序Socket发送失败中止信号
会导致程序被杀掉的有下面几种,我们只需收集这几种信号的上下文信息,就能找到崩溃发生原因。
SIGABRT,
SIGBUS,
SIGFPE,
SIGILL,
SIGSEGV,
SIGTRAP,
SIGTERM,
SIGKILL,
信号处理流程分三步:
- 注册信号处理回调函数;
- 在回调函数中收集调用堆栈信息;
- 恢复信号默认处理函数;
1.注册信号处理回调函数
static int Beacon_errorSignals[] = {
SIGABRT,
SIGBUS,
SIGFPE,
SIGILL,
SIGSEGV,
SIGTRAP,
SIGTERM,
SIGKILL,
};
for (int i = 0; i < Beacon_errorSignalsNum; i++) {
signal(Beacon_errorSignals[i], &SignalExceptionHandler);
}
2.回调函数中收集调用堆栈信息
void SignalExceptionHandler(int sig) {
NSMutableString *mstr = [[NSMutableString alloc] init];
[mstr appendString:@"Stack:\n"];
void *callstack[128];
int i, frames = backtrace(callstack, 128);
char **strs = backtrace_symbols(callstack, frames);
for (i = 0; i <frames; ++i) {
[mstr appendFormat:@"%s\n", strs[i]];
}
[SignalHandler saveCreash:mstr];
free(strs);
}
3.恢复信号默认处理函数
但这里会将信号不断的发向该处理函数,导致应用无法正常崩溃,因为一般的消息处理会向进程终结,但是这里没有,所以还会有同样地信号不断的发过来并被处理.所以处理函数后要终结该处理函数的处理,并将其由系统默认处理,即:
signal(sig, SIG_DFL);
测试
完成异常和信号处理函数的设置后,我们需要测试设置是否生效,能否正常捕获到崩溃的堆栈信息。测试需要注意:信号时不能在 debug 环境下进行,系统的 debug 会优先拦截信号。正确的测试姿势,安装应用后关闭 debug,直接在模拟器中点击应用制造信号。Exception 测试可以在 debug 环境下进行。
- (IBAction)buttonClick:(UIButton *)sender {
//1.信号量
Test *pTest = {1,2};
free(pTest); //导致SIGABRT的错误,因为内存中根本就没有这个空间,哪来的free,就在栈中的对象而已
pTest->a = 5;
}
- (IBAction)buttonOCException:(UIButton *)sender {
//2.ios崩溃
NSArray *array= @[@"tom",@"xxx",@"ooo"];
[array objectAtIndex:5];
}
收集后的清理
传递 UncaughtExceptionHandler
如果多方通过 NSSetUncaughtExceptionHandler 注册异常处理程序,后注册的异常处理程序会覆盖前一个注册的 handler,导致之前注册的日志收集服务收不到相应的 NSException,丢失崩溃堆栈信息。(iOS 系统自带的 Crash Reporter 不受影响)。
崩溃后友好退出
而对于有些时候,在iOS中,在应用崩溃后,保持运行状态而不退出:
CFRunLoopRef runLoop = CFRunLoopGetCurrent();
CFArrayRef allModes = CFRunLoopCopyAllModes(runLoop);
while (!dismissed) {
for (NSString *mode in (__bridge NSArray *)allModes) {
CFRunLoopRunInMode((__bridge CFStringRef)mode, 0.001, false);
}
}
CFRelease(allModes);
应用以上代码,可以做到崩溃时弹框提示应用,以让用户还是可以正常操作,让响应更加友好.
存在的问题
使用上述方式收集到的堆栈信息只包含错误线程,其他线程的调用堆栈无法获取。而在一些 Signal 的出错信息仅靠崩溃线程的堆栈无法找到原因,需同时根据其他线程调用堆栈来寻找崩溃原因。
目前成熟的开源崩溃日志收集服务有很多,如 KSCrash,PLCrashReporter,CrashKit 等,使用一番后觉得 PLCrashReporter 更符合项目要求。PL 收集崩溃日志信息和苹果官方日志兼容,扩展性较好,与已有服务衔接较为简单。
集成 PLCrashReporter
去官网下载最新的 release 包,将iOS Framework/CrashReporter.framework 拖进工程。在 application:didFinishLaunchingWithOptions
方法中调用 initCrashMgr
完成 PLCrashReporter 的初始化。
- (void)initCrashMgr {
PLCrashReporter *crashReporter = [PLCrashReporter sharedReporter];
NSError *error;
// Check if we previously crashed
if ([crashReporter hasPendingCrashReport]) {
[self handleCrashReport];
}
// Enable the Crash Reporter
if (![crashReporter enableCrashReporterAndReturnError: &error]) {
ABLog(@"Warning: Could not enable crash reporter: %@", error);
}
}
- (void)handleCrashReport {
PLCrashReporter *crashReporter = [PLCrashReporter sharedReporter];
NSData *crashData;
NSError *error;
// Try loading the crash report
crashData = [crashReporter loadPendingCrashReportDataAndReturnError:&error];
if (crashData == nil) {
ABLog(@"Could not load crash report: %@", error);
[crashReporter purgePendingCrashReport];
return;
}
// We could send the report from here, but we'll just print out some debugging info instead
PLCrashReport *report = [[PLCrashReport alloc] initWithData:crashData error:&error];
if (report == nil) {
ABLog(@"Could not parse crash report");
[crashReporter purgePendingCrashReport];
return;
}
//TODO:send the report
ABLog(@"Crashed on %@", report.systemInfo.timestamp);
ABLog(@"Crashed with signal %@ (code %@, address=0x%" PRIx64 ")", report.signalInfo.name, report.signalInfo.code, report.signalInfo.address);
NSString *humanReadText = [PLCrashReportTextFormatter stringValueForCrashReport:report withTextFormat:PLCrashReportTextFormatiOS];
// 处理收集到的 crash 信息
[self sendCrashReport:humanReadText];
[crashReporter purgePendingCrashReport];
return;
}
PLCrashReporter 收集的 crash 非常全媲美苹果的收集的日志,简单看了下源码原理和上述思路一致,但一直没找到它如何解决其他线程的堆栈收集问题,有时间继续研读下。
参考文章: