目前我们知晓的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文件的内容,可以发现就是一张映射表,利用不同的字母组合代表指定的类或者方法等。如下图展示:

JAVA byte new 奔溃 java崩溃_java

用来解析崩溃日志的工具是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 byte new 奔溃 java崩溃_java_02

至此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的话,可能存在于如下路径(由于版本等,可能存在于其他路径,开发自行查找):

JAVA byte new 奔溃 java崩溃_JAVA byte new 奔溃_03

 如果gradle4.0以上和使用Cmake打包so文件的生成路径,开发者需要上传的就是下图路径下对应环境下的obj/下的内容:

JAVA byte new 奔溃 java崩溃_java_04

 解析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包调用和解析,具体使用是使用sdkndk包中的工具,命令结构如下:

工具:/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工具跑命令行解析输出结果如下:

JAVA byte new 奔溃 java崩溃_#define_05

使用addr2line工具命令行解析单个地址输出结果如下:

JAVA byte new 奔溃 java崩溃_android_06

 至此,Native的崩溃日志解析完成啦~

ANR

遇上ANR,一般都是个耗时的事儿。需要先学会如何看ANR和定位。Traces.txt系统自动生成的记录anr等异常的文件,只记录java代码产生的异常。我们通过使用adb工具USB连接手机在终端是可以导出系统生成的文件:

adb bugreport

导出的文件目录如下:

JAVA byte new 奔溃 java崩溃_android_07

这是我们在程序之外的操作,下面大致讲述一下如果在线上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的崩溃日志解析一致。