目前我们知晓的Android客户端上会出现的三种导致APP无法使用的原因有Java崩溃,Native崩溃以及ANR。以下内容从三种错误展开,建立在自行调研以及实践的基础上。
Java崩溃
Java崩溃就是在Java/kotlin代码中,出现了未捕获异常,导致程序异常退出。通常是由我们自己的业务代码导致,例如空指针,索引越界等常见的崩溃。Java的崩溃日志相对于Native和ANR的堆栈日志,阅读和定位难度为最低。一般在配合mapping文件反混淆之后都可以直接定位错误。
java崩溃捕获
class JavaCrashHandler implements UncaughtExceptionHandler{
//在初始化的时候
void initialize(){
......
Thread.setDefaultUncaughtExceptionHandler(this);
}
@Override
public void uncaughtException(Thread thread, Throwable throwable) {
//处理异常 读取信息 上传或者其他处理
handleException(thread, throwable);
}
}
这部分异常捕捉由于有现成的接口提供所以很容易。只需要大家处理好读取部分的逻辑就没什么大问题,上报的时候采集一些附带信息即可。
java崩溃日志解析
解析java日志,首先需要的是mapping.txt文件。Grade 3.4版本之前,使用Proguard工具,之后Android 在新版中启用了 R8 编译器,没有使用 Proguard 工具,虽然兼容 Proguard 的配置和字典等,但是编译出来的 Mapping 文件格式还是有一点不同。如果开启混淆功能,则会产生Mapping 文件,用来逆向推出原始的堆栈信息,更快更方便的定位问题。位置路径:build/output/mapping/release/mapping.txt
或者
build/output/mapping/${flavorDir}/release/mapping.txt
大家可以自行查看mapping文件的内容,可以发现就是一张映射表,利用不同的字母组合代表指定的类或者方法等。如下图展示:
用来解析崩溃日志的工具是sdk中自带的retrace。路径为:sdk/tools/proguard/bin/retrace.sh。解析命令为:
retrace (mapping文件路径) (java crash文件路径)
其中java日志的格式如下,大家获取日志的时候可以自行搜索retrace解析日志格式,随着版本升级应该会有变动。
*** *** *** *** *** *** *** *** *** *** *** *** *** *** *** ***
其他字段信息....
....
java stacktrace:
....
其他信息...
+++ +++ +++ +++ +++ +++ +++ +++ +++ +++ +++ +++ +++ +++ +++ +++
举例如:
*** *** *** *** *** *** *** *** *** *** *** *** *** *** *** ***
Build fingerprint: 'samsung/dreamqltezc/dreamqltechn:9/PPR1.180610.011/G9500ZCU4DSH2:user/release-keys'
ABI: 'arm64'
java stacktrace:
java.lang.IllegalStateException: Could not execute method for android:onClick
at d.b.c.t$a.onClick(:2)
at android.view.View.performClick(View.java:7352)
at android.widget.TextView.performClick(TextView.java:14177)
at com.google.android.material.button.MaterialButton.performClick(Unknown Source:3)
at android.view.View.performClickInternal(View.java:7318)
at android.view.View.access$3200(View.java:846)
at android.view.View$PerformClick.run(View.java:27800)
at android.os.Handler.handleCallback(Handler.java:873)
at android.os.Handler.dispatchMessage(Handler.java:99)
at android.os.Looper.loop(Looper.java:214)
at android.app.ActivityThread.main(ActivityThread.java:7050)
at java.lang.reflect.Method.invoke(Native Method)
at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:493)
at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:965)
Caused by: java.lang.reflect.InvocationTargetException
at java.lang.reflect.Method.invoke(Native Method)
... 14 more
Caused by: java.lang.RuntimeException: test java exception
at j.n.b(Unknown Source:20)
at com.chinapnr.postbev2.SecondActivity.testJavaCrashInMainThread_onClick(Unknown Source:1)
... 15 more
+++ +++ +++ +++ +++ +++ +++ +++ +++ +++ +++ +++ +++ +++ +++ +++
通过使用上面的命令行解析之后的结果即替换掉了混淆的部分,比如上面的日志,就是替换了
at d.b.c.t$a.onClick(:2)
这一行为未混淆时候的代码。结果如下:
至此java崩溃的捕捉到解析就完成了。
Native崩溃
Native崩溃一般都 是因为在Native代码中访问非法地址,也可能是地址对⻬出现了问题,或者发生了程序主动abort,这些都会产生相应的 signal信号,导致程序异常退出。通常我们常见的几种信号大致如下:
#define SIGHUP 1 // 终端连接结束时发出(不管正常或非正常)
#define SIGINT 2 // 程序终止(例如Ctrl-C)
#define SIGQUIT 3 // 程序退出(Ctrl-\)
#define SIGILL 4 // 执行了非法指令,或者试图执行数据段,堆栈溢出
#define SIGTRAP 5 // 断点时产生,由debugger使用
#define SIGABRT 6 // 调用abort函数生成的信号,表示程序异常
#define SIGIOT 6 // 同上,更全,IO异常也会发出
#define SIGBUS 7 // 非法地址,包括内存地址对齐出错,比如访问一个4字节的整数, 但其地址不是4的倍数
#define SIGFPE 8 // 计算错误,比如除0、溢出
#define SIGKILL 9 // 强制结束程序,具有最高优先级,本信号不能被阻塞、处理和忽略
#define SIGUSR1 10 // 未使用,保留
#define SIGSEGV 11 // 非法内存操作,与SIGBUS不同,他是对合法地址的非法访问,比如访问没有读权限的内存,向没有写权限的地址写数据
#define SIGUSR2 12 // 未使用,保留
#define SIGPIPE 13 // 管道破裂,通常在进程间通信产生
#define SIGALRM 14 // 定时信号,
#define SIGTERM 15 // 结束程序,类似温和的SIGKILL,可被阻塞和处理。通常程序如果终止不了,才会尝试SIGKILL
#define SIGSTKFLT 16 // 协处理器堆栈错误
#define SIGCHLD 17 // 子进程结束时, 父进程会收到这个信号。
#define SIGCONT 18 // 让一个停止的进程继续执行
#define SIGSTOP 19 // 停止进程,本信号不能被阻塞,处理或忽略
#define SIGTSTP 20 // 停止进程,但该信号可以被处理和忽略
#define SIGTTIN 21 // 当后台作业要从用户终端读数据时, 该作业中的所有进程会收到SIGTTIN信号
#define SIGTTOU 22 // 类似于SIGTTIN, 但在写终端时收到
#define SIGURG 23 // 有紧急数据或out-of-band数据到达socket时产生
#define SIGXCPU 24 // 超过CPU时间资源限制时发出
#define SIGXFSZ 25 // 当进程企图扩大文件以至于超过文件大小资源限制
#define SIGVTALRM 26 // 虚拟时钟信号. 类似于SIGALRM, 但是计算的是该进程占用的CPU时间.
#define SIGPROF 27 // 类似于SIGALRM/SIGVTALRM, 但包括该进程用的CPU时间以及系统调用的时间
#define SIGWINCH 28 // 窗口大小改变时发出
#define SIGIO 29 // 文件描述符准备就绪, 可以开始进行输入/输出操作
#define SIGPOLL SIGIO // 同上,别称
#define SIGPWR 30 // 电源异常
#define SIGSYS 31 // 非法的系统调用
Native崩溃捕获
当一个动态库(native 程序)开始执行时,系统会注册一些连接到 debuggerd 的 signal handlers,当系统 crash 的时候,会保存一个 tombstone 文件到/data/tombstones目录下(Logcat中也会有相应的信息),文件就像墓碑一样记录了死亡了的进程的基本信息(例如进程的进程号,线程号),死亡的地址(在哪个地址上发生了 Crash),死亡时的现场是什么样的(记录了一系列的堆栈调用信息)等。
大致的流程步骤如下:
- 当Native进程发生了异常,比如NULL指针
- 操作系统会去异常向量表的地址去处理异常,然后发送信号
- 在debuggred_init注册的信号处理函数就会收到处理
- 创建伪线程去启动crash_dump进程,crash_dump则会获取当前进程中各个线程的crash信息
- tombstoned进程是开机就启动的,开机时注册好了socket等待监听
- 当在crash_dump中去连接tombstoned进程的时候,根据传递的dump_type类型会返回一个/data/tombstones/下文件描述符
- crash_dump进程后续通过engrave_tombstone函数将所有的线程的详细信息写入到tombstone文件中
- 在/data/tombstones下生成了此次对应的tombstone_XX文件
Native崩溃的捕捉重点就在于在C层替换信号处理函数,安装信号,进行信号处理,然后通过ptrace技术来获取线程的regs,backtrace等信息。以下的处理方案来源于Xcrash开源库的分析,大家可以去看源码。
1、java层
加载libscrash.so, nativeInit调用进行native层的初始化。
2、native层
nativeInit() 所映射的 jni 实现是 xc_jni_init()。xc_jni_init分3小步初始化:
1)xc_common_init:初始化公共参数,初始化两个文件fd(非负整数,索引值,指向内核为每一个进程所维护的该进程打开文件的记录)。
2)xc_crash_init:xc_crash_init_callback初始化 jni call back。初始化Ntaive线程通过eventfd(进程或者线程间的通信(如通知/等待机制的实现))阻塞等待native发生crash向上层java发出通知。
Native崩溃解析
同java崩溃一样,Native崩溃也需要一个映射文件和工具。Native的映射文件为带有调试符号信息的so包。
一个完整的 so 由C代码加一些 debug 信息组成,这些debug信息会记录 so 中所有方法的对照表,就是方法名和其偏移地址的对应表,也叫做符号表,这种 so 也是未 strip 的,通常体积会比较大。
IDE如果使用Android Sutdio+NDK,即项目中存在cpp项目,则在每次编译之后会生成对应的debug so文件(需保持最新最后一次编译产物),会按照对应的CPU指令架构集分类,较低版本的gradle插件使用ndk build的话,可能存在于如下路径(由于版本等,可能存在于其他路径,开发自行查找):
如果gradle4.0以上和使用Cmake打包so文件的生成路径,开发者需要上传的就是下图路径下对应环境下的obj/下的内容:
解析native崩溃信息。多了一步前提,需要一个匹配条件:cpu 架构指令集类型。即崩溃日志中统计到的abi的类型。
//日志中信息
ABI: 'arm64'
//abi对应debug so所在文件夹名称
arm64===》amr64-v8a
armeabi===》armeabi
armeabi-v7a===》armeabi-v7a
x86===》x86
x86_64===》x86_64
确认好native对应崩溃的abi平台,之后可以进行对应的debug so包调用和解析,具体使用是使用sdk下ndk包中的工具,命令结构如下:
工具:/sdk/ndk/21.1.6352462/ndk-stack
ndk-stack: ndk-stack -sym (对应abi下面的符号表文件路径) -dump (日志文件)
工具:/sdk/ndk/21.1.6352462/toolchains/aarch64-linux-android-4.9/prebuilt/darwin-x86_64/bin/aarch64-linux-android-addr2line
addr2line: addr2line -C -f -e (对应abi下面的符号表so包文件路径)(日志中显示的地址)
同样,Native的崩溃日志格式也有要求:
*** *** *** *** *** *** *** *** *** *** *** *** *** *** *** ***
Build fingerprint: 'samsung/dreamqltezc/dreamqltechn:9/PPR1.180610.011/G9500ZCU4DSH2:user/release-keys'
ABI: 'arm64'
.....报错信息等
backtrace:
内容
使用上面的ndk-stack工具跑命令行解析输出结果如下:
使用addr2line工具命令行解析单个地址输出结果如下:
至此,Native的崩溃日志解析完成啦~
ANR
遇上ANR,一般都是个耗时的事儿。需要先学会如何看ANR和定位。Traces.txt系统自动生成的记录anr等异常的文件,只记录java代码产生的异常。我们通过使用adb工具USB连接手机在终端是可以导出系统生成的文件:
adb bugreport
导出的文件目录如下:
这是我们在程序之外的操作,下面大致讲述一下如果在线上APP中捕获ANR。
ANR的捕获
Android 7.0(可能是6.0)之前,可以通过监听 /data/anr 目录的变化。获取系统生成好的ANR日志。
fileObserver = new FileObserver("/data/anr/", CLOSE_WRITE) {
public void onEvent(int event, String path) {
try {
if (path != null) {
String filepath = "/data/anr/" + path;
if (filepath.contains("trace")) {
handleAnr(filepath);
}
}
} catch (Exception e) {
XCrash.getLogger().e(Util.TAG, "AnrHandler fileObserver onEvent failed", e);
}
}
};
try {
fileObserver.startWatching();
} catch (Exception e) {
fileObserver = null;
XCrash.getLogger().e(Util.TAG, "AnrHandler fileObserver startWatching failed", e);
}
高版本的 Android 系统中,应用已经访问不到 /data/anr 了。C层面的方案就是捕获了 SIGQUIT 信号,这个是 Android App 发生 ANR 时由 ActivityMangerService 向 App 发送的信号。和处理Native Crash是一样的原理。
ANR部分主要还是Trace文件的详解,这个大家可以去自行搜索相关文档,很多大佬已经将如何一步一步定位ANR总结出来了。我这里就不做啰嗦啦,如何设计到解析,原理也同Native的崩溃日志解析一致。