本文为稀土掘金技术社区首发签约文章,30天内禁止转载,30天后未获授权禁止转载,侵权必究!
Crash 指的是程序因发生严重错误导致而崩溃的情况。对于 Android 程序来说,Crash 崩溃率一般至少要小于万分之五,这样才能保障一个比较好的体验,那些稳定性优化做的非常好的程序,崩溃率甚至小于万分之一。
一,Java Crash
Crash 主要来自于 Java 层和 Native 层这两个方向。我们由易到难,先从 Java Crash 的基础知识开始学习。
1.常见异常
大部分 Java Crash 都是比较容易修复的,只要有 Crash 发生的日志,就可以根据日志信息中记录的异常类型来进行归因和定位。一些常见的 Java Crash 的类型如下。
- 空指针异常(NullPointerException):访问一个空对象的属性或方法导致的异常,在线程并发,时序调用异常,对象传递链路太长等场景下容易高频出现
- 非法状态异常(IllegalStateException):通常是因为在不合适的时机或者场景下执行了某些操作而引发的异常。比如在 Activity 的 onCreate 周期之后调用 findViewById,在 onPause 周期之后调用 startActivity;非 UI 线程上更新 UI 元素;在已经关闭的数据库连接上执行查询等等
- 索引越界异常(IndexOutOfBoundsException):在访问数组、列表或字符串等集合时,索引超出了有效范围而引发的异常。常见在一些并发场景中,比如一个线程已经移除了集合中的一个数据,但是另外一个线程还在按照原来的序列进行访问。
- 不合法的参数异常(IllegalArgumentException):当程序使用了错误的参数来调用函数,比如传递了一个负数的索引,或者一个为空的字符串等。
- 内存溢出错误(OutOfMemoryError):当程序使用的内存超过了系统中可使用的内存时会出现此异常。
在修复 Java Crash 时应该尽量寻找导致异常发生的本质原因,而不能简单的通过异常捕获等方式进行修复,这种简单的修复方式很容易会给程序带来更难排查的异常。比如最常见的空指针异常,就需要找到所有会置空数据的代码逻辑后,判断该空指针异常是因为时序调用问题,或是多线程同步问题,或是异常的使用等原因后,再根据原因去对置空的代码进行针对性的修复。
2.异常的传递和捕获
当程序的某个代码逻辑发生异常时,首先会抛出一个异常,如果我们的代码逻辑中有 try-catch 语句来捕获该异常,程序就会正常的执行后续代码,如果没有 try-catch 语句,或者 catch 块中没有匹配该异常的类型,那么异常会沿着调用栈一直向上抛,直到该异常被捕获。如果一直到调用栈最顶层的方法都没有捕获该异常,便会交给系统默认的异常处理器 (UncaughtExceptionHandler),该异常处理器会通过 System.err 来输出日志,然后将程序强制关闭。
在实际开发中,我们一般都会自定义异常处理器,在自定义的异常处理器中,往往会获取用于定位异常的日志信息并上传到服务器,对于出现在子线程且并不会影响程序表现的异常,也可以在这里进行捕获,从而避免程序的退出。配置自定义异常处理器的实现代码如下:
scala
public class MyApplication extends Application {
@Override
public void onCreate() {
super.onCreate();
// 设置全局的未捕获异常处理器
Thread.setDefaultUncaughtExceptionHandler(new MyCrashHandler());
}
}
我们需要尽量在程序启动的前期,上面的代码中是在 Application 的 onCreate 周期方法中,为主线程设置一个自定义的异常处理器,用于捕获并处理未捕获的异常,以及异常的上报。自定义的异常处理器需要继承自系统的 UncaughtExceptionHandler 类,代码实现如下:
arduino
//自定义的UncaughtExceptionHandler类
public class MyCrashHandler implements Thread.UncaughtExceptionHandler{
@Override
public void uncaughtException(Thread thread, Throwable ex) {
//保存异常文件
saveCrashInfo(ex);
//判断是否捕获异常;
if(catchError(thread,ex)){
return;
}
//无法捕获的异常则退出程序
android.os.Process.killProcess(android.os.Process.myPid());
System.exit(1);
}
}
在异常捕获的实现代码中,一般只会将异常保存在本地,等到程序下次启动时在往服务端进行上传,因为异常发生后的处理时间是比较短的,而往本地写数据的耗时要远比上传到服务端的耗时短,所以这样可以提高异常日志捕获率。
在 catchError 捕获异常的函数中,可以将所有的非主线程的异常都进行捕获,这样程序便不会被终止了,但是我们依然要将所有的异常都进行上报,否则会影响异常的发现和修复。虽然这种方式很可能会导致程序使用时出现异常表现,但是总比程序退出要强,不过这种方式也不能解决所有问题,因此还需要继续增加一些策略来确保程序受到最小程度的影响,笔者这里介绍一种策略,流程如下所示:
- 捕获所有异常,如果短时间内,异常不再发生,则不再继续处理,只做上报即可。
- 如果短时间内反复发生 Crash,则判断是否可以强制关闭 Activity
- 如果 Activity 无法强制关闭或者关闭后无法解决异常,可以接着清除本地缓存和数据库
- 如果清除本地缓存和数据库的情况下,依然反复发生 Crash,则弹出带有提醒用户升级或者更换程序版本的弹窗
该策略的流程图所示,读者可以根据实际的场景和业务特性,来设计合适的 Crash 兜底策略。
3.OOM
OOM 是一类特殊的 Java Crash,大部分的 Crash 通过崩溃发生的堆栈基本便能定位问题,但是 OOM 通过堆栈几乎很难定位问题,而是要通过内存快照才能定位出问题。并且大部分的 OOM 的治理都是需要通过内存优化治理来解决的,仅有一小部分 OOM 是因为异常的代码逻辑导致的,比如死循环的逻辑,异常数据加载逻辑,这部分异常需要通过内存快照文件,也就是 Hprof 文件来进行分析和定位。
要修复线上的 OOM 异常,就依赖于 Hprof 文件,因此我们通常在 OOM 发生时会抓取内存堆栈。代码实现如下,直接调用系统提供的 Debug.dumpHprofData 接口既能获取内存快照。
java
private class MyCrashHandler implements Thread.UncaughtExceptionHandler {
@Override
public void uncaughtException(Thread thread, Throwable ex) {
// 判断是否是 OOM 错误
if (ex instanceof OutOfMemoryError) {
// dump hprof 文件到应用的内部存储中
File hprofFile = new File(getFilesDir(), "dump.hprof");
//调用接口获取内存快照。
Debug.dumpHprofData(hprofFile.getAbsolutePath());
}
}
}
二,Native Crash
Native Crash 的治理相比于 Java Crash 会复杂很多,我们在这里先了解一些 Native Crash 的基础性知识,在后面实战章节中还会进行更深入的学习。
1.常见信号
所有的 Java Crash 都有明确的异常类型,这些类型可以有效的帮助我们聚焦且定位问题,对于 Native Crash 来说,也有同样的机制:信号,能帮助我们明确的定位出 Native Crash 的类型。
信号(signal)是操作系统用来通知进程发生了某些异常事件的一种机制。每个信号都代表了某一事件,Android 中一共有 31 个信号,下面介绍一下常用的信号。
信号 | 含义 | 解释 |
---|---|---|
4 (SIGILL) | 非法指令 | ILL_ILLOPC(错误代码 1):非法操作码,应用程序试图执行一个无效的操作码。 ILL_ILLOPN(错误代码 2):非法操作数,应用程序试图执行一个无效的操作数。 |
6 (SIGABRT) | 自愿终止 | ABRT_NOOP(错误代码 0):Native 代码中主动调用了 abort 函数ABRT_LOW_MEMORY(错误代码 1):内存不足,应用程序因为内存不足而自愿终止。 |
7 (SIGBUS) | 总线错误 | BUS_ADRALN(错误代码 1):地址对齐错误,应用程序试图访问非对齐的内存地址。BUS_ADRERR(错误代码 2):非法地址错误,应用程序试图访问无效的物理内存地址。 |
8 (SIGFPE) | 浮点异常 | FPE_INTDIV(错误代码 1):除以零,应用程序试图进行整数除法并除以零。FPE_INTOVF(错误代码 2):整数溢出,应用程序试图进行整数操作并导致结果溢出。 |
9 (SIGKILL) | Kill 信号 | 特殊的信号,它通常用于强制终止进程。与其他信号不同,SIGKILL 无法被捕获或忽略,它会立即终止目标进程,由于 SIGKILL 是一个固定的信号,它没有特定的错误代码 |
11 (SIGSEGV) | 段错误 | 1. SEGV_MAPERR:表示访问内存映射错误。应用程序试图访问未映射到其地址空间的内存区域。2. SEGV_ACCERR:表示访问权限错误。应用程序试图访问其不具备读写权限的内存区域。 |
13 (SIGPIPE) | 管道破裂 | 没有特定的错误代码。当进程接收到 SIGPIPE 信号时,通常意味着它正在尝试向一个已关闭的管道写入数据 |
16 (SIGSTKFLT) | 协处理器栈错误 | 表示浮点栈错误,通常由于使用了无效的浮点指令集引发 |
15 (SIGTERM) | 终止进程 | 请求进程正常终止的信号 |
19 (SIGSTOP) | 停止进程 | 请求进程停止运行的信号,类似于暂停进程的操作 |
2.信号的传递和捕获
信号是 Linux 系统的一种进程间通信方式,所以我们也可以通过代码来主动发送信号,信号发送函数比较多,常见的方式有这些:
- kill 函数:用于向进程或进程组发送信号
- sigqueue 函数:只能向一个进程发送信号,不能向进程组发送信号,但它还可以携带一个附加的整型值和一个附加的数据指针,以提供更多的信息给目标进程
- alarm 函数:用于设置一个定时器,当定时器超时时,会向当前的进程发送一个 SIGALRM 信号
- abort 函数:用于异常终止当前进程。它会向当前进程发送一个 SIGABRT 信号,导致进程立即终止
- raise 函数:用于向当前进程发送指定的信号。它可以用来触发特定信号的处理函数,或者用来模拟其他进程发送信号的情况
当 Native 层的代码逻辑异常时,处于内核态的操作系统便会检测到异常,并通过上述的函数来给异常的进程发送信号。我们可以在 Native 层通过 Linux 系统提供的 signal 函数或 sigaction 函数来捕获这些异常信号。这两个函数虽然都可以捕获信号,但是 sigaction 函数的灵活性更高,所以实际项目中也一般通过该函数来进行信号捕获并实现 Native Crash 的异常监控。sigaction 函数如下:
c
#include <signal.h>
int sigaction(int signum,const struct sigaction *act,struct sigaction *oldact));
struct sigaction {
void (*sa_handler)(int);
void (*sa_sigaction)(int, siginfo_t *, void *);
sigset_t sa_mask;
int sa_flags;
void (*sa_restorer)(void);
};
下面是对这个函数入参的解释:
-
signum:想要捕获的信号
-
act:指向 sigaction 结构体的一个实例的指针,结构体参数的解释如下
- sa_handler:函数指针,指向用于处理信号的函数。当接收到相应的信号时,系统会调用此函数进行处理
- sa_sigaction:函数指针,指定用于处理信号的函数,相比于 sa_handler,可以接收更多的参数,包括信号编号、信号附加信息和上下文信息等。当 sa_handler 非空时,sa_sigaction 将被忽略。
- sa_mask:指定在信号处理函数执行期间,哪些信号应该被阻塞
- sa_flags:用于指定信号处理的表示选项。常见的标志有 SA_RESTART,表示在信号处理函数返回时自动重启被信号中断的系统调用;SA_NOCLDSTOP,表示忽略子进程停止或终止的信号;以及 SA_SIGINFO,表示使用 sa_sigaction 而不是 sa_handler 作为信号处理函数。
- sa_restorer:已被废弃,无需设置。
-
oldact:指向 sigaction 类型的结构体,用于存储之前信号处理函数和选项的信息。sigaction 函数注册信号会覆盖原来的信号注册函数,如果需要保持原来的信号处理函数的完整性,可以使用 oldact 参数来保存原来的信号处理方式,并在适当的时候进行调用。
三,稳定性优化方法论
很多开发者在做稳定性的优化时,通常都是异常发生后,再当做 Bug 去进行修复,仅仅去修复一个 Bug 是稳定性优化中是最简单的一件事,这种方式并不能很好的提高应用的稳定性,因为问题实际上已经发生了。对于稳定性的优化,我们需要更全面和更体系的方案,即从监控,分析和治理,防劣化三个方向进行,才能确保应用的稳定性能始终维持在一个较好的水平。
1. 监控
想要保障应用的稳定性,就需要在用户使用程序的全过程中,能够及时发现程序的异常,因此在线上环境中对程序的异常进行监控,是稳定性优化中最重要的一个环节。
监控至少要包含这两个能力,一是能够及时的捕捉到异常,二是在异常发生时能够收集异常信息。不管是 Java Crash,Native Crash,或是 ANR,OOM 等异常,在设计监控方案时,都需要拥有两个功能,而这两个功能的难点在于要如何提高异常的捕获率,以及如何才能采集到足够的用于分析和定位异常的信息,这些难点在后面的实战篇章中都会进行的详细的讲解。
虽然目前市面上也有不少专门用于稳定性监控的组件,如腾讯的 Bugly 等,在实际项目场景中,为了提高效率,减少重复造轮子,我们可以直接使用这些三方提供的监控能力,但是我们依然要知道各项稳定性监控的原理和实现方案,这样才能对这些三方工具进行优化和改进,使之能更加适用于实际的业务场景。
2. 分析及治理
分析和治理这个环节,最重要的就是分析,这往往也是花费时间最多也最复杂的步骤。针对 ANR ,需要分析主线程逻辑,各个线程状态,性能情况等信息;针对 Java 或 Native 的 Crash 需要分析堆栈,关键日志,异常类型等信息;针对 OOM ,需要分析内存快照等信息。
在异常分析时,很多时候我们都可能因为监控捕捉的信息不足而没法有效的分析出异常,此时我们需要不断的补全监控和日志抓取能力,直到有充足的信息。也有很多时候,即使在信息充足的情况下,也可能因为系统或者程序中隐藏的原因导致无法有效的分析出异常。这个时候,也无需气馁,如果该异常影响不是特别严重,我们可以靠着猜测,然后通过多个版本的迭代去进行验证,直到定位和修复该异常,如果实在分析不出问题,也可以换一个思路,比如通过换一种实现方式来绕过该异常。
当分析出异常后,进行治理就是一件很简单的事情了,针对出现异常的代码直接进行修改即可,如果是我们无法直接修改源码的二方或三方库出现的异常,则可以通过 Hook 技术进行修改。
3. 防劣化
防劣化对稳定性优化也是至关重要的。当程序发版前,如果符合正规的流程,都会进行长时间的 Monkey 测试来检测程序的稳定性,测试人员也会手动进行大量的测试来保障程序的稳定性,这些都是线下的防劣化方式,除了这些常见的线下方案,我们还有多事情可以做的,比如代码规范层面的防劣化方案,包括建立完善的代码 Review 和合码机制,推动使用 Kotlin 替代 Java 以减少空指针异常等;线上的防裂化方案,比如完善的 Crash 兜底策略,慢函数检测等方案。
如果没有做好防劣化,即使我们花了大力气进行了稳定性的治理,也很容易在经历了几个版本的迭代后,稳定性指标又出现了大幅下降。因此做好防劣化的工作,可以帮助我们能更持久的巩固在稳定性优化上取得的成果,减少后续在稳定性优化上投入的时间。