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 <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收集框架

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,547评论 6 477
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,399评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,428评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,599评论 1 274
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,612评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,577评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,941评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,603评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,852评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,605评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,693评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,375评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,955评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,936评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,172评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 43,970评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,414评论 2 342

推荐阅读更多精彩内容