01. 崩溃捕获设计实践方案
01.崩溃捕获设计实践方案
目录介绍
01.整体介绍概述
1.1 项目背景介绍
1.2 遇到问题
1.3 基础概念介绍
1.4 设计目标
02.App 崩溃流程
2.1 为何崩溃推出 App
2.2 Java 崩溃流程
2.3 Native 崩溃流程
2.4 崩溃日志处理
2.5 最后推出 App
2.6 崩溃流程叙述
2.7 Binder 死亡通知
03.崩溃处理入口
3.1 Java 处理异常入口
3.2 异常处理常用 api
3.3 注意事项说明
3.4 JVM 处理异常入口
3.5 理解异常栈轨迹链
3.6 JVM 如何实现异常
04.崩溃捕获思路
4.1 实现崩溃监听
4.2 处理捕获异常
4.3 实现相同异常次数统计
4.4 崩溃日志收集
4.5 捕获指定线程异常
4.6 日志可视化查看
4.7 日志发送邮箱
4.8 崩溃重启实践
01.整体介绍概述
1.1 项目背景介绍
Android
的稳定性是Android
性能的一个重要指标,它也是 App 质量构建体系中最基本和最关键的一环。如果应用经常崩溃率,或者关键功能不可用,那显然会对我们的留存产生重大影响。
1.2 遇到问题
Crash
率多少算优秀呢?在明确了目标之后,我们才能正确认识我们的工作到底有什么作用。降低崩溃率到我们的指标……
崩溃率如何衡量
崩溃率 UV = 发生崩溃的 UV / 启动 UV
衡量标准:崩溃率小于 3/1000 为正常,3/10000 为优秀
1.3 基础概念介绍
崩溃现场是“第一案发现场”,它保留着很多有价值的线索。
接下来具体来看看在崩溃现场,确认重点,内存 &线程需特别注意,很多崩溃都是由于它们使用不当造成的。如何去分析日志
确认严重程度
如果一时半会解决不了,那么能否先止损,采用降级策略。延期修复,如果是非要解决,那么解决完后即通过灰度测试发版,及时跟进问题。
崩溃基本信息
Java 崩溃(比如
NullPPointerException
是空指针,OutOfMemoryError
是资源不足)Native 崩溃(比较常见的是有 SIGSEGV 和 SIGABRT)
ANR(先看看主线程的堆栈,是否是因为锁等待导致。接着看看 ANR 日志中 iowait、CPU、GC、system server 等信息,进一步确定是 I/O 问题,或是 CPU 竞争问题,还是由于大量 GC 导致卡死)
Logcat 日志
从
Logcat
中我们可以看到当时系统的一些行为跟手机的状态,当从一条崩溃日志中无法看出问题的原因,或者得不到有用信息时,不要放弃,建议查看相同崩溃点下的更多崩溃日志。查找共性(机型、系统、ROM、厂商、ABI)
机型、系统、ROM、厂商、ABI,这些采集到的系统信息都可以作为维度聚合,共性问题例如是不是因为安装了 Xposed,是不是只出现在 x86 的手机,是不是只有三星这款机型,是不是只在 Android 8.0 的系统上。
复现问题
尽量去找到复现问题的链路,方便排查问题。有些 bug 如果找不到,那么思考能否上传 info 日志,通过技术埋点去排查崩溃链路问题。
1.4 设计目标
能够准确将崩溃日志写到本地文件
能够捕获到崩溃日志,然后把它通过 io 流写入到 file 文件中。写入的崩溃信息,带有完整的异常堆栈链信息,还有一些基础的手机和 App 属性。
能够有效计算相同崩溃的次数
比如针对同一段代码的类型转化异常
java.lang.NumberFormatException: For input string: "12.3"
,如果出现多次,需要统计到具体的次数。能够可视化展示崩溃日志信息
这一块,主要是能够读到崩溃日志路径,拿到所有的文件。然后通过可视化界面展示出来,方便查看!
能够将崩溃信息文件转发分享
能够将崩溃 file 文件分享到微信,QQ 或者钉钉这类社交 App,方便测试童鞋转发给开发。MonitorFileLib
02.App 崩溃流程
2.1 为何崩溃推出 App
线程中抛出异常以后的处理逻辑
一旦线程出现抛出异常,并且在没有捕捉的情况下,
JVM
将调用Thread
中的dispatchUncaughtException
方法把异常传递给线程的未捕获异常处理器。找到 Android 源码中处理异常捕获入口
既然
Android
遇到异常会发生崩溃,然后找一些哪里用到设置setDefaultUncaughtExceptionHandler
,即可定位到RuntimeInit
类。即在这个里面设置异常捕获
KillApplicationHandler
,发生异常之后,会调用handleApplicationCrash
打印输出崩溃crash
信息,最后会杀死应用app
。
2.2 处理崩溃流程
2.2.1 崩溃的大概流程
然后看一下
RuntimeInit
类,由于是 java 代码,所以首先找main
方法入口。代码如下所示然后再来看一下
commonInit()
方法,看看里面做了什么操作?可以发现这里调用了
setDefaultUncaughtExceptionHandler
方法,设置了自定义的Handler
类接着看一下
KillApplicationHandler
类,可以发现该类实现了Thread.UncaughtExceptionHandler
接口这个就是杀死 app 逻辑具体的代码。可以看到当出现异常的时候,在 finally 中会退出进程操作。
得出结论如下所示
其实在
fork
出app
进程的时候,系统已经为app
设置了一个异常处理,并且最终崩溃后会直接导致执行该handler
的finally
方法最后杀死 app 直接退出 app。如何自己捕获 App 异常
如果你要自己处理,你可以自己实现
Thread.UncaughtExceptionHandler
。而调用setDefaultUncaughtExceptionHandler
多次,最后一次会覆盖之前的。
2.2.2 崩溃日志的记录
在
KillApplicationHandler
类中的uncaughtException
方法可以看到
ActivityManager.getService().handleApplicationCrash
被调用,那么这个是用来做什么的呢?ActivityManager.getService().handleApplicationCrash-->ActivityManagerService.handleApplicationCrash-->handleApplicationCrashInner 方法
从下面可以看出,若传入
app
为null
时,processName
就设置为system_server
然后接着看一下
handleApplicationCrashInner
方法做了什么。调用addErrorToDropBox
将应用crash
,进行封装输出崩溃日志封装流程如下所示
ActivityManagerService#handleApplicationCrash(),在这个方法里处理崩溃日志信息 ActivityManagerService#findAppProcess(),这个是根据 binder 去找对应的 crash 的 ProcessRecord 对象 ActivityManagerService#handleApplicationCrashInner(),这个方法很关键 ActivityManagerService#addErrorToDropBox(),这个就是将 crash,anr,装到盒子里。这个主要在下面会说到 ActivityManagerService#appendDropBoxProcessHeaders,这个方法是拼接 app 的进程,pid,package 包名等等
2.3 Native 崩溃流程
Native 崩溃监控入口流程
SystemServer#main(),在 fork 出 system_server 进程后执行 main 方法,然后创建该对象并且执行 run 方法做初始化各种服务逻辑 SystemServer#run(),在这个线程 run 方法中,调用 startOtherServices 开启各种服务逻辑 SystemServer#startOtherServices(),在这个方法里,是系统 system_server 进程开启众多服务,比如 IMS 输入事件服务,NMS 通知栏服务等 ActivityManagerService#startObservingNativeCrashes(),在这个类中创建 NativeCrashListener 去监控 native 崩溃
native_crash
,顾名思义,就是native
层发生的crash
。其实他是通过一个NativeCrashListener
线程去监控的。上报
native_crash
的线程-->NativeCrashReporter:native crash
跟到这里就结束了,后面的流程就是跟application crash
一样,都会走到addErrorToDropBox
中。
2.4 崩溃日志处理
为什么说
addErrorToDropBox
是殊途同归呢,因为无论是crash
、native_crash
、ANR
或是wtf
,最终都是来到这里,交由它去处理。
2.5 最后推出 App
推出 App 的方式常见的有哪些?思考一下,系统是采用那种方式推出 App,为什么?
第一种:在根页面,调用
finish
直接推出 App 的首页,Activity
会调用onDestroy
。这种情况进程其实是未杀死的情况,第二种:在根页面,调用
moveTaskToBack
推出 App,这种类似 home 键作用,Activity
是调用onStop
回到后台。第三种:finish 所有的
activity
推出 App,这种情况下,进程可能存活。第四种:直接调用
killProcess
杀死进程,然后在调用System.exit
推出程序。这种方式是彻底杀死进程,比较粗暴【系统就是这种】。App 常见友好的推出方式
杀死进程:先回退到桌面,然后
finish
掉所有activity
页面,然后在杀死进程和推出程序。可以避免闪一下……
2.6 崩溃流程叙述
App 崩溃流程图
崩溃流程叙述
1、首先发生 crash 所在进程,在
RuntimeInit
创建之初便准备好了 defaultUncaughtHandler,用来来处理 Uncaught Exception,并输出当前 crash 基本信息;2、调用当前进程中的
AMP.handleApplicationCrash
,经过 binder ipc 机制,传递到 system_server 进程;3、接下来,进入 system_server 进程,调用 binder 服务端执行
AMS.handleApplicationCrash
;4、从
AMS.findAppProcess
查找到目标进程的 ProcessRecord 对象;然后调用AMS.handleApplicationCrashInner
,并将进程 crash 信息输出到目录/data/system/dropbox;5、执行
ActivityManagerService#addErrorToDropBox()
,这个就是将 crash,anr,装到盒子里。这个主要在下面会说到;6、回到
RuntimeInit
处理崩溃 finally 中,执行杀死进程操作,当 crash 进程被杀,通过 binder 死亡通知,告知 system_server 进程来执行 appDiedLocked();
2.7 Binder 死亡通知
还需要了解下 binder 死亡通知的原理,其流程图如下所示:
binder 死亡通知原理
由于 Crash 进程中拥有一个 Binder 服务端 ApplicationThread,而应用进程在创建过程调用 attachApplicationLocked(),从而 attach 到 system_server 进程,在 system_server 进程内有一个 ApplicationThreadProxy,这是相对应的 Binder 客户端。
当 Binder 服务端 ApplicationThread 所在进程(即 Crash 进程)挂掉后,则 Binder 客户端能收到相应的死亡通知,从而进入 binderDied 流程。
03.崩溃处理入口
3.1 Java 处理异常入口
UncaughtExceptionHandler
接口,官方介绍为:Interface for handlers invoked when a Thread abruptly terminates due to an uncaught exception.
When a thread is about to terminate due to an uncaught exception the Java Virtual Machine will query the thread for its UncaughtExceptionHandler using getUncaughtExceptionHandler() and will invoke the handler's uncaughtException method, passing the thread and the exception as arguments. If a thread has not had its UncaughtExceptionHandler explicitly set, then its ThreadGroup object acts as its UncaughtExceptionHandler. If the ThreadGroup object has no special requirements for dealing with the exception, it can forward the invocation to the default uncaught exception handler.
翻译后大概的意思是
UncaughtExceptionHandler
接口用于处理因为一个未捕获的异常而导致一个线程突然终止问题。当一个线程因为一个未捕获的异常即将终止时,Java 虚拟机将通过调用
getUncaughtExceptionHandler()
函数去查询该线程的UncaughtExceptionHandler
并调用处理器的uncaughtException
方法将线程及异常信息通过参数的形式传递进去。如果一个线程没有明确设置一个 UncaughtExceptionHandler,那么 ThreadGroup 对象将会代替 UncaughtExceptionHandler 完成该行为。如果 ThreadGroup 没有明确指定处理该异常,ThreadGroup 将转发给默认的处理未捕获的异常的处理器。线程出现未捕获异常后,JVM 将调用
Thread
中的dispatchUncaughtException
方法把异常传递给线程的未捕获异常处理器。
3.2 异常处理常用 api
3.2.1 设置 uncaughtExceptionPreHandler
Thread
中存在两个UncaughtExceptionHandler
。一个是静态的
defaultUncaughtExceptionHandler
,另一个是非静态uncaughtExceptionHandler
。defaultUncaughtExceptionHandler
:设置一个静态的默认的UncaughtExceptionHandler
。来自所有线程中的
Exception
在抛出并且未捕获的情况下,都会从此路过。进程fork
的时候设置的就是这个静态的defaultUncaughtExceptionHandler
,管辖范围为整个进程。uncaughtExceptionHandler
:为单个线程设置一个属于线程自己的uncaughtExceptionHandler
,辖范围比较小。
3.2.2 没有设置 uncaughtExceptionPreHandler
没有设置
uncaughtExceptionHandler
怎么办?如果没有设置
uncaughtExceptionHandler
,将使用线程所在的线程组来处理这个未捕获异常。线程组
ThreadGroup
实现了UncaughtExceptionHandler
,所以可以用来处理未捕获异常。ThreadGroup 类定义:然后看一下
ThreadGroup
中实现uncaughtException(Thread t, Throwable e)
方法,代码如下默认情况下,线程组处理未捕获异常的逻辑是,首先将异常消息通知给父线程组,然后尝试利用一个默认的
defaultUncaughtExceptionHandler
来处理异常,如果没有默认的异常处理器则将错误信息输出到
System.err
。也就是 JVM 提供给我们设置每个线程的具体的未捕获异常处理器,也提供了设置默认异常处理器的方法。
3.3 注意事项说明
难道要为每一个线程创建
UncaughtExceptionHandler
吗?应用程序通常都会创建很多线程,如果为每一个线程都设置一次
UncaughtExceptionHandler
未免太过麻烦。既然出现未处理异常后 JVM 最终都会调 getDefaultUncaughtExceptionHandler(),那么我们可以在应用启动时设置一个默认的未捕获异常处理器。
即调用
Thread.setDefaultUncaughtExceptionHandler(handler)
就可以。setDefaultUncaughtExceptionHandler
被调用多次如何理解?Thread.setDefaultUncaughtExceptionHandler(handler)
方法如果被多次调用的话,会以最后一次传递的 handler 为准。所以如果用了第三方的统计模块,可能会出现失灵的情况。
对于这种情况,在设置默认
handler
之前,可以先通过getDefaultUncaughtExceptionHandler()
方法获取并保留旧的handler
,然后在默认handler
的uncaughtException
方法中调用其他handler
的uncaughtException
方法,保证都会收到异常信息。
3.4 JVM 处理异常入口
思考一下:JVM 拿到异常之后是如何将捕获的异常回调到
java
层的uncaughtException
方法。在
Hotspot
虚拟机源码的thread.cpp
中的 JavaThread::exit 方法发现了这样的一段代码,并且还给出了注释:在线程调用 exit 退出时
如果有未捕获的异常,则会调用
Thread.dispatchUncaughtException
方法。这个则是 java 层处理异常的入口!
3.5 理解异常栈轨迹链
来看一个简单的崩溃日志,如下所示:
那么这个崩溃日志,是怎么形成的崩溃异常链的?简单来说,在方法调用链路中,存在栈管理。
在这个崩溃日志,可以发现
ZygoteInit.main ----> RuntimeInit ----> ActivityThread.main ----> Handler.dispatchMessage ---> View.performClick ---> CrashTestActivity.onClick
观察可知,这个崩溃信息则是记录着 app 从启动到崩溃中的流程日志。
StackTraceElement
此类在java.lang
包下public final class StackTraceElement extends Object implements Serializable
堆栈跟踪元素,它由
Throwable.getStackTrace()
返回。每个元素表示单独的一个【堆栈帧】。所有的堆栈帧(堆栈顶部的那个堆栈帧除外)都表示一个【方法调用】。堆栈顶部的帧表示【生成堆栈跟踪的执行点】。通常,这是创建对应于堆栈跟踪的 throwable 的点。
3.6 JVM 如何实现异常
那么思考一下,
jvm
是如何构造Throwable
异常的呢?异常实例的构造十分昂贵
由于在构造异常实例时,JVM 需要生成该异常的栈轨迹,该操作逐一访问当前线程的 Java 栈桢,并且记录下各种调试信息,包括栈桢所指向方法的名字、方法所在的类名以及方法在源代码中的位置等信息。
JVM 捕获异常需要异常表
每个方法都有一个异常表,异常表中的每一个条目都代表一个异常处理器,并且由 from、to、target 指针及其异常类型所构成。form-to 其实就是 try 块,而 target 就是 catch 的起始位置。
当程序触发异常时,JVM 会检测触发异常的字节码的索引值落到哪个异常表的 from-to 范围内,然后再判断异常类型是否匹配,匹配就开始执行 target 处字节码处理该异常。
最后是 finally 代码块的编译
finally 代码块一定会运行的(除非虚拟机退出了)。那么它是如何实现的呢?其实是一个比较笨的办法,当前 JVM 的做法是,复制 finally 代码块的内容,分别放在所有可能的执行路径的出口中。
如何理解 Java 函数调用栈桢呢
操作系统给每个线程分配了一块独立的内存空间,这块内存被组织成“栈”这种结构, 用来存储函数调用时的临时变量。每进入一个函数,就会将临时变量作为一个栈帧入栈,当被调用函数执行完成,返回之后,将这个函数对应的栈帧出栈。
从代码中我们可以看出,main() 函数调用了 add() 函数,获取计算结果,并且与临时变量 a 相加,最后打印 res 的值。
为了让你清晰地看到这个过程对应的函数栈里出栈、入栈的操作,我画了一张图。图中显示的是,在执行到 add() 函数时,函数调用栈的情况。
04.崩溃监听思路
4.1 实现崩溃监听
ThreadHandler
这个类就是实现了UncaughtExceptionHandler
这个接口。handler
将会报告线程终止和不明原因异常这个情况。崩溃监听核心流程图
4.2 处理捕获异常
当出现异常的时候,最终会将异常分发到 uncaughtException 这个回调方法中。处理捕获异常相关操作,就是在这个方法中处理
4.3 实现相同异常次数统计
大概的思路如下所示
每一次发生崩溃时,拿到异常 Throwable,然后获取它的堆栈信息,转化为字符串后再 md5 一下得到一个 key。
每一次存储的时候,获取之前的【如果之前没有则是 0】次数加一
注意问题点:关键是怎么判断两个崩溃是同一个?
举一个例子:Integer.parseInt("12.3") 和 Integer.parseInt("12.4") 它们都是 NumberFormatException 异常,但却是不同的。获取堆栈再 md5 一下即可保证 key 唯一
4.4 崩溃日志收集
4.4.1 收集崩溃信息
从崩溃的基本信息,可以对崩溃有初步的判断。
进程名、线程名。崩溃的进程是前台进程还是后台进程,崩溃是不是发生在 UI 线程。
崩溃堆栈和类型。崩溃是属于 Java 崩溃、Native 崩溃,还是 ANR,对于不同类型的崩溃我们关注的点也不太一样。特别需要看崩溃堆栈的栈顶,看具体崩溃在系统的代码,还是我们自己的代码里面。
收集崩溃时的系统信息
机型、系统、厂商、CPU、ABI、Linux 版本等。(寻找共性)
Logcat
。(包括应用、系统的运行日志,其中会记录 App 运行的一些基本情况)设备状态:是否 root、是否是模拟器。一些问题是由 Xposed 或多开软件造成,对这部分问题我们要区别对待。
收集崩溃时的内存信息(OOM、ANR、虚拟内存耗尽等,很多崩溃都跟内存有直接关系)
系统剩余内存。(系统可用内存很小 – 低于 MemTotal 的 10%时,OOM、大量 GC、系统频繁自杀拉起等问题都非常容易出现)
虚拟内存(但是很多类似 OOM、tgkill 等问题都是虚拟内存不足导致的)
应用使用内存(得出应用本身内存的占用大小和分布)
资源信息
有的时候我们会发现应用堆内存和设备内存都非常充足,还是会出现内存分配失败的情况,这跟资源泄漏可能有比较大的关系。
文件句柄
fd
。一般单个进程允许打开的最大文件句柄个数为 1024。但是如果文件句柄超过 800 个就比较危险,需要将所有的 fd 以及对应的文件名输出到日志中,进一步排查是否出现了有文件或者线程的泄漏线程数。一个线程可能就占 2MB 的虚拟内存,过多的线程会对虚拟内存和文件句柄带来压力。根据我的经验来说,如果线程数超过 400 个就比较危险。需要将所有的线程 id 以及对应的线程名输出到日志中,进一步排查是否出现了线程相关的问题。
收集崩溃时的应用信息
崩溃场景(崩溃发生在哪个 Activity 或 Fragment,发生在哪个业务中)
关键操作路径(记录关键的用户操作路径,这对我们复现崩溃会有比较大的帮助)
其他自定义信息(不同应用关心的重点不一样。例如运行时间、是否加载了补丁、是否是全新安装或升级等)
4.4.2 收集日志详细说明
Logcat。这里包括应用、系统的运行日志。
由于系统权限问题,获取到的 Logcat 可能只包含与当前 App 相关的。其中系统的 event logcat 会记录 App 运行的一些基本情况,记录在文件 /system/etc/event-log-tags 中。
机型、系统、厂商、CPU、ABI、Linux 版本等。–> 寻找共性
设备状态:是否 root、是否是模拟器。一些问题是由 Xposed 或多开软件造成,对这部分问题我们要区别对待。
内存信息
OOM、ANR、虚拟内存耗尽等,很多崩溃都跟内存有直接关系。
系统剩余内存。关于系统内存状态,可以直接读取文件 /proc/meminfo。当系统可用内存很小(低于 MemTotal 的 10%)时,OOM、大量 GC、系统频繁自杀拉起等问题都非常容易出现。
应用使用内存。包括 Java 内存、RSS(Resident Set Size)、PSS(Proportional Set Size),我们可以得出应用本身内存的占用大小和分布。PSS 和 RSS 通过 /proc/self/smap 计算,可以进一步得到例如 apk、dex、so 等更加详细的分类统计。
虚拟内存。虚拟内存可以通过 /proc/self/status 得到,通过 /proc/self/maps 文件可以得到具体的分布情况。有时候我们一般不太重视虚拟内存,但是很多类似 OOM、tgkill 等问题都是虚拟内存不足导致的。
线程数。当前线程数大小可以通过上面的 status 文件得到,一个线程可能就占 2MB 的虚拟内存,过多的线程会对虚拟内存和文件句柄带来压力。根据我的经验来说,如果线程数超过 400 个就比较危险。需要将所有的线程 id 以及对应的线程名输出到日志中,进一步排查是否出现了线程相关的问题。
截图如下所示
4.6 日志可视化查看
可以通过该工具查看缓存文件
快速查看
data/data/包名
目录下的缓存文件。快速查看
/sdcard/Android/data/包名
下存储文件。一键接入该工具
FileExplorerActivity.startActivity(MainActivity.this);
可视化界面展示
4.7 日志发送邮箱
发送邮件分为两种:
调用系统的发邮件功能发送邮件
使用特定的邮箱密码发送邮件
发送优先必备操作
要使用 JavaMail 的三个 jar 包:activation.jar;additionnal.jar;mail.jar
发送流程如下所示
设置发送服务器;设置发送账户和密码;设置发送显示的名称,主题,内容和附件;设置接收者地址;发送邮件给接收者
4.8 崩溃重启实践
第一种方式,开启一个新的服务 KillSelfService,用来重启本 APP。
第二种方式,使用闹钟延时,然后重启 app
第三种方式,检索获取项目中 LauncherActivity,然后设置该 activity 的 flag 和 component 启动 app
关于崩溃重启 App,具体的 Demo 可以看:
https://github.com/yangchong211/YCAppTool/tree/master/CommonLib/AppRestartLIb
评论