如何监控iOS的崩溃问题

这是我参与8月更文挑战的第13天,活动详情查看: 8月更文挑战

App 上线后,我们最怕出现的情况就是应用崩溃了。但是,我们线下测试好好的 App,为什么上线后就发生崩溃了呢?这些崩溃日志信息是怎么采集的?能够采集的全吗?采集后又要怎么分析、解决呢?

崩溃发生的原因

App 上线后,是很脆弱的,导致其崩溃的问题,不仅包括编写代码时的各种小马虎,还包括那些被系统强杀的疑难杂症。

下面,我们就先看看几个常见的编写代码时的小马虎,是如何让应用崩溃的。

  • 数组越界:在取数据索引时越界,App 会发生崩溃。还有一种情况,就是给数组添加了 nil 会崩溃。
  • 多线程问题:在子线程中进行 UI 更新可能会发生崩溃。多个线程进行数据的读取操作,因为处理时机不一致,比如有一个线程在置空数据的同时另一个线程在读取这个数据,可能会出现崩溃情况。
  • 主线程无响应:如果主线程超过系统规定的时间无响应,就会被 Watchdog 杀掉。这时,崩溃问题对应的异常编码是 0x8badf00d。关于这个异常编码,我还会在后文和你说明。
  • 野指针:指针指向一个已删除的对象访问内存区域时,会出现野指针崩溃。野指针问题是需要我们重点关注的,因为它是导致 App 崩溃的最常见,也是最难定位的一种情况。

程序崩溃了,我们的 App就不可用了,对用户的伤害也是最大的。因此,每家公司都会非常重视自家产品的崩溃率,并且会将崩溃率(也就是一段时间内崩溃次数与启动次数之比)作为优先级最高的技术指标,比如千分位是生死线,万分位是达标线等,去衡量一个 App 的高可用性。

而崩溃率等技术指标,一般都是由崩溃监控系统来搜集。同时,崩溃监控系统收集到的堆栈信息,也为解决崩溃问题提供了最重要的信息。

但是,崩溃信息的收集却并没有那么简单。因为,有些崩溃日志是可以通过信号捕获到的,而很多崩溃日志却是通过信号捕获不到的。

常见的崩溃情况:

KVO问题,NSNotification线程问题,数组越界,野指针等崩溃信息是可以通过信号捕获的。但是诸如后台任务超时、内存爆炸、主线程卡顿超过阈值这些问题,我们是无法通过信号捕捉的。

但是,只有所有的崩溃,我们都监控到,才能实现崩溃的全面监控。那么怎么做才能捕获这两种崩溃信息呢?

信号可捕获的崩溃日志收集

崩溃日志最简单的收集方法,就是打开Xcode,菜单选择Product->Archive。然后在提交时选上Upload your app’s symbols to receive symbolicated reports from Apple,以后你就可以直接在 Xcode 的 Archive 里看到符号化后的崩溃日志了。

但是这种查看日志的方式,每次都是纯手工的操作,而且时效性较差。所以,目前很多公司的崩溃日志监控系统,都是通过PLCrashReporter 这样的第三方开源库捕获崩溃日志,然后上传到自己服务器上进行整体监控的。

而没有服务端开发能力,或者对数据不敏感的公司,则会直接使用Bugly来监控崩溃。

那么PLCrashReporterBugly这类工具,是怎么知道 App 什么时候崩溃的呢?

在崩溃日志中,我们经常看到这样的信息

Exception Type:        EXC_BAD_ACCESS (SIGSEGV)
复制代码

他表示的是EXC_BAD_ACCESS这个异常会通过SIGSEGV信号发现有问题的线程。

虽然信号的种类有很多,但是都可以通过注册signalHandler来捕获到。代码如下:


void registerSignalHandler(void) {
    signal(SIGSEGV, handleSignalException);
    signal(SIGFPE, handleSignalException);
    signal(SIGBUS, handleSignalException);
    signal(SIGPIPE, handleSignalException);
    signal(SIGHUP, handleSignalException);
    signal(SIGINT, handleSignalException);
    signal(SIGQUIT, handleSignalException);
    signal(SIGABRT, handleSignalException);
    signal(SIGILL, handleSignalException);
}

void handleSignalException(int signal) {
    NSMutableString *crashString = [[NSMutableString alloc]init];
    void* callstack[128];
    int i, frames = backtrace(callstack, 128);
    char** traceChar = backtrace_symbols(callstack, frames);
    for (i = 0; i <frames; ++i) {
        [crashString appendFormat:@"%s\n", traceChar[i]];
    }
    NSLog(crashString);
}
复制代码

上边代码通过对各信号进行注册,捕获到异常信号后,在处理方法handleSignalException里通过backtrace_symbols方法就能获取到当前的堆栈信息。堆栈信息可以先保存在本地,下次启动的时候再上传到崩溃监控的服务器即可;

为什么要先存在本地呢?因为在保存完这些堆栈信息之后,App就崩溃了,崩溃后内存里的数据也就没有了,因此要将这些数据保存在本地磁盘中,在下次启动的时候能够方便的读取这些信息;

信号不可捕获的崩溃日志收集

我们经常遇到这种情况,App退到后台之后,即使代码逻辑没有问题,也很容易出现崩溃。而且这些崩溃往往是因为系统强制杀掉了某些进程导致的,而这种信号由于系统限制无法被捕获。

一般,在退后台时你都会把关键业务数据保存在内存中,如果保存过程中出现了崩溃就会丢失或损坏关键数据,进而数据损坏又会导致应用不可用。这种关键数据的损坏会给用户带来巨大的损失。

那么,后台容易崩溃的原因是什么呢?如何避免后台崩溃?怎么去收集后台信号捕获不到的那些崩溃信息呢?还有哪些信号捕获不到的崩溃情况?怎样监控其他无法通过信号捕获的崩溃信息?

我们带着这几个问题来分析一下

后台容易崩溃的原因是什么?

iOS后台保活有5种方式:

  • Background Mode:这种方式App Store在审核时会提高对App的要求。通常情况下,只有那些图、音乐播放VoIP 类的 App 才能通过审核;
  • Background Fetch:这种方式的唤醒时间不稳定,而且用户可以在系统里设置关闭这种方式,导致它的使用场景很少;
  • Silent Push:是推送的一种,会在后台唤起App 30秒。它的优先级很低,会调用 application:didReceiveRemoteNotifiacation:fetchCompletionHandler: 这个 delegate,和普通的 remote push notification 推送调用的 delegate 是一样的;
  • PushKit:后台唤醒 App 后能够保活 30 秒。它主要用于提升 VoIP 应用的体验;
  • Background Task:这个方式是使用最多的。App退后台后,默认都会使用这种方式;

既然Background Task是使用最多的,那么它可以解决哪些问题呢?

在你的程序退到后台以后,只有几秒钟的时间可以执行代码,接下来就会被系统挂起。进程挂起后所有线程都会暂停,不管这个线程是文件读写还是内存读写都会被暂停。但是,数据读写过程无法暂停只能被中断,中断时数据读写异常而且容易损坏文件,所以系统会选择主动杀掉 App 进程。

Background Task 这种方式,就是系统提供了 beginBackgroundTaskWithExpirationHandler 方法来延长后台执行时间,可以解决你退后台后还需要一些时间去处理一些任务的诉求。

其使用方法如下:


- (void)applicationDidEnterBackground:(UIApplication *)application {
    self.backgroundTaskIdentifier = [application beginBackgroundTaskWithExpirationHandler:^( void) {
        [self yourTask];
    }];
}
复制代码

在这段代码中,yourTask 任务最多执行3分钟,3分钟内 yourTask运行完成,你的 App 就会挂起。如果yourTask3分钟之内没有执行完的话,系统会强制杀掉进程,从而造成崩溃,这就是为什么 App 退后台容易出现崩溃的原因。

后台崩溃造成的影响是未知的。持久化存储的数据出现了问题,就会造成你的 App 无法正常使用。

如何避免后台崩溃呢?

我们知道了,App退后台后,如果执行时间过长就会导致被系统杀掉。那么,如果我们要想避免这种崩溃发生的话,就需要严格控制后台数据的读写操作。比如,你可以先判断需要处理的数据的大小,如果数据过大,也就是在后台限制时间内或延长后台执行时间后也处理不完的话,可以考虑在程序下次启动或后台唤醒时再进行处理。

同时,App退后台后,这种由于在规定时间内没有处理完而被系统强制杀掉的崩溃,是无法通过信号被捕获到的。这也说明了,随着团队规模扩大,要想保证 App 高可用的话,后台崩溃的监控就尤为重要了。

怎么去收集后台信号捕获不到的那些崩溃信息呢?

采用Background Task方式时,我们可以根据 beginBackgroundTaskWithExpirationHandler会让后台保活3分钟这个阈值,先设置一个计时器,在接近3分钟时判断后台程序是否还在执行。如果还在执行的话,我们就可以判断该程序即将后台崩溃,进行上报、记录,以达到监控的效果。

其它问题

还有哪些信号捕获不到的崩溃情况?怎样监控其他无法通过信号捕获的崩溃信息?

其他捕获不到的崩溃情况还有很多,主要就是内存打爆和主线程卡顿时间超过阈值被 watchdog杀掉这两种情况.

其实,监控这两类崩溃的思路和监控后台崩溃类似,我们都先要找到它们的阈值,然后在临近阈值时还在执行的后台程序,判断为将要崩溃,收集信息并上报。

关于如何获取阈值我们以后再介绍

对于内存爆炸信息的收集,你可以采用内存映射(mmap)的方式来保存现场。主线程卡顿时间超过阈值这种情况,你只要收集当前线程的堆栈信息就可以了。

如何解决崩溃问题

通过上边的内容,我们已经解决了崩溃信息采集的问题,现在,我们需要对这些信息进行分析,从而解决App的崩溃问题:

我们采集到的崩溃日志,主要包含的信息为:进程信息基本信息异常信息线程回溯

  • 线程信息:崩溃进程的相关信息,比如崩溃报告唯一标识符、唯一键值、设备标识;
  • 基本信息:崩溃发生的日期、iOS 版本;
  • 异常信息:异常类型、异常编码、异常的线程;
  • 线程回溯:崩溃时的方法调用栈。

通常情况下,我们分析崩溃日志时最先看的是异常信息,分析出问题的是哪个线程,在线程回溯里找到那个线程;然后,分析方法调用栈,符号化后的方法调用栈可以完整地看到方法调用的过程,从而知道问题发生在哪个方法的调用上;

比如如下图所示的方法调用栈:

方法调用栈顶,也就是最后导致崩溃的方法调用。完整的崩溃日志里,除了线程方法调用栈还有异常编码。异常编码在异常信息中可以找到。

一些被系统杀掉的情况,可以通过异常编码来分析。查看完整的异常编码

常见的三种异常编码:

  • 0x8badf00d:表示 App 在一定时间内无响应而被 watchdog 杀掉的情况。
  • 0xdeadfa11:表示 App 被用户强制退出。
  • 0xc00010ff:表示 App 因为运行造成设备温度太高而被杀掉。

0x8badf00d这种情况是出现最多的。当出现被watchdog杀掉的情况时,我们就可以把范围控制在主线程被卡的情况;

0xdeadfa11的情况,是用户的主动行为,我们不用太关注;

0xc00010ff这种情况,就要对每个线程 CPU 进行针对性的检查和优化,以减少电量消耗;

除了崩溃日志外,崩溃监控平台还需要对所有采集上来的日志进行统计。我以腾讯的 Bugly 平台为例,和你一起看一下崩溃监控平台一般都会记录哪些信息,来辅助开发者追溯崩溃问题。

上图展示的就是整体崩溃情况的趋势图,你可以选择App的不同版本查看不同时间段的趋势。这个相当于总控台,能够全局观察App的崩溃大盘。

除了崩溃率,你还可以在这个平台上能查看次数、用户数等趋势。下图展示的是某一个 App 的崩溃在不同iOS系统、不同iPhone设备、App版本的占比情况。这也是全局大盘观察,从不同维度来分析。

有了全局大盘信息,一旦出现大量崩溃,你就需要明白是哪些方法调用出现了问题,需要根据影响的用户数量按照从大到小的顺序排列出来,优先解决影响面大的问题

同时,每个崩溃也都有自己的崩溃趋势图、iOS系统分布图等信息,来辅助开发者跟踪崩溃修复效果。

有了崩溃的方法调用堆栈后,大部分问题都能够通过方法调用堆栈,来快速地定位到具体是哪个方法调用出现了问题。有些问题仅仅通过这些堆栈还无法分析出来,这时就需要借助崩溃前用户相关行为和系统环境状况的日志来进行进一步分析。

分类:
iOS
标签: