iOS 崩溃信息收集实践

作者: rgcyc | 来源:发表于2016-12-15 10:53 被阅读1768次

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

    参考文章:

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

    相关文章

      网友评论

      • 小湾子:请问博主,捕获 Signal 信号时注册signal handler,处理完后如何传递出去啊?
      • GJCode:您好,请教下,关于 Crash的 Sighal 信号量捕获,会发现当App中既存在我自己写的通过signal(SIGHUP, SignalExceptionHandler) 来捕获的实现,又引用了友盟,会发现我自己的关于信号量的方法就不在执行了,如果做到两个都可以执行呢
        rgcyc:@GJCode 第三方可能会覆盖你注册的 handler,如果存在覆盖不传递 handler,自定义的函数实现就无法与第三方 SDK 并存
        GJCode:您可能理解错我的意思了,因为我们说Crash错误一般有两种嘛,一种是异常捕获,就是NSUncatchn那种,那个可以通过传递handler来实现自己捕获和第三方SDK捕获同时存在,然后还有一种是信号量捕获sighal,那个目前想请教下该怎么做到自己的函数实现跟第三方SDK的并存呢?
        rgcyc:出现你说的情况应该是友盟的 signal handler强行覆盖你注册的 handler,处理完Signal 之后没有传递给你。
        至于你问的能不能同时存在:按照你的描述应该是无法同时并存的,我用过 bugly,它比较规矩,自己的 handler 和它能并存,你可以试试看

      本文标题:iOS 崩溃信息收集实践

      本文链接:https://www.haomeiwen.com/subject/frnwmttx.html