iOS 崩溃信息收集实践

iOS 崩溃信息收集

最近项目要求收集应用使用过程中的崩溃信息,在网上搜索了一番后,了解目前崩溃信息收集有如下几种途径:iTunes Connect导出手机上传日志、拿到用户手机使用 Xcode 导出、使用第三方崩溃收集服务(如 Bugly、友盟等)。从及时性和可定制角度来看上面几种都不符合项目的需求,基于上述需求背景要求必须学习手动收集崩溃信息。

导致崩溃的问题

导致应用崩溃的问题主要有两种:

  1. C++语言层面的错误,比如野指针、除零、内存非法访问等;
  2. 未捕获异常(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 系统中一种用于异步通知的机制。信号传递给进程后,在没有设置处理函数的情况下,程序可以指定三种行为:

  1. 忽略信号,但 SIGKILL 和 SIGSTOP 信号不可忽略;
  2. 使用默认的处理函数 SIG_DFL,大多数信号的默认动作是终止进程;
  3. 捕获信号,执行用户定义的函数。

这里有两个特殊的常量:

  • 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. 注册信号处理回调函数;
  2. 在回调函数中收集调用堆栈信息;
  3. 恢复信号默认处理函数;

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 

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 非常全媲美苹果的收集的日志,简单看了下源码原理和上述思路一致,但一直没找到它如何解决其他线程的堆栈收集问题,有时间继续研读下。

参考文章:

iOS崩溃信息收集
iOS异常捕获
漫谈iOS Crash收集框架

你可能感兴趣的:(iOS 崩溃信息收集实践)