[关闭]
@Jazka 2015-12-21T16:36:14.000000Z 字数 7488 阅读 3209

Android平台的崩溃捕获机制及实现


由于Android系统是开源的,大家都可以根据自己的需求进行系统定制,由系统碎片化带来的Android应用程序的崩溃也就比较严重,在模拟器上运行良好的程序安装到某款手机上说不定就出现崩溃的现象。几乎没有一款App能够做到在所有Android手机上不出现crash,而且往往都是程序发布之后,在用户手机上出现了崩溃现象,所以如何及时捕获并收集Android平台的崩溃就变得非常重要了。目前市面上已经有第三方SDK可以帮助完成这一功能,本文将跟大家分享一下这些崩溃分析SDK的实现原理。

常见的Android崩溃有两类,一类是Java Exception异常,一类是Native Signal异常。我们将围绕这两类异常进行。对于很多基于Unity、Cocos平台的游戏,还会有C#,JS、Lua等的异常,这里不做讨论。

Java代码的崩溃机制及实现

Android应用程序的开发是基于Java语言的,所以首先来分析第一类Android崩溃Java Exception。

Exception的分类及捕获

Java的异常可以分为两类:checked Exception和unchecked Exception。所有RuntimeException类及其子类的实例被称为Runtime异常,即unchecked Exception,不是RuntimeException类及其子类的异常实例则被称为Checked Exception。

checked异常又称为编译时异常,即在编译阶段被处理的异常,编译器会强制程序处理所有的checked异常,也就是用try...catch显式的捕获并处理,因为Java认为这类异常都是可以被处理(修复)的。在Java API文档中,方法说明时,都会添加是否throw某个exception,这个exception就是checked异常。如果没有try...catch这个异常,则编译出错,错误提示类似于"Unhandled exception type xxxxx"。该类异常捕获的流程是:1)执行try块中的代码出现异常,系统会自动生成一个异常对象,并将该异常对象提交给java运行环境,这个就是异常抛出(throw)阶段;2) 当java运行环境收到异常对象时,会寻找最近的能够处理该异常对象的catch块,找到之后把该异常对象交给catch块处理,这个就是异常捕获(catch)阶段。

checked异常一般是不引起Android App Crash的,注意是一般,这里之所以介绍有两个原因:1)形成系统的了解,更好的对比理解unchecked Exception;2)对于一些checked Exception,虽然我们在程序里面已经捕获并处理了,但是如果能同时将该异常收集并发送到后台,将有助于提升App的健壮性,比如修改代码逻辑回避该异常,或者捕获后采用更好的方法去处理该异常。至于应该收集哪些checked Exception,则取决于App的业务逻辑和开发者的经验了。

unchecked异常又称为运行时异常,即RuntimeException,最常见的莫过于NullPointerException。unchecked异常发生时,由于没有相应的try...catch处理该异常对象,所以java运行环境将会终止,程序将退出,也就是我们说的crash。当然,你可能会说,那我们把这些异常也try...catch住不就行了。确实理论上是可以的,但是有两点导致这种方案不可行,1)无法将所有的代码都加上try...catch,这样对代码的效率和可读性将是毁灭性的;2)unchecked异常通常都是较为严重的异常,或者说已经破坏了运行环境的,比如内存地址,即使我们try...catch住了,也不能明确知道如何处理该异常,才能保证程序接下来的运行是正确的。

没有try...catch住的异常,即uncaught的异常,都会导致应用程序崩溃。那么面对崩溃,我们是否可以做些什么呢?比如程序退出前,弹出一个个性化对话框,而不是默认的强制关闭对话框,或者弹出一个提示框安慰一下用户,甚至重启应用程序等。其实Java提供了一个接口给我们,可以完成这些,这就是UncaughtExceptionHandler,该接口含有一个纯虚函数public abstract void uncaughtException (Thread thread, Throwable ex)。uncaught异常发生时会终止线程,此时,系统便会通知UncaughtExceptionHandler,告诉它被终止的线程以及对应的异常,然后便会调用uncaughtException函数。如果该handler没有被显式设置,则会调用对应线程组的默认handler。如果我们要捕获该异常,必须实现我们自己的handler,并通过函数public static void setDefaultUncaughtExceptionHandler (Thread.UncaughtExceptionHandler handler)进行设置。实现自定义的handler,只需要继承UncaughtExceptionHandler该接口,并实现uncaughtException方法即可。

static class MyCrashHandler implements UncaughtExceptionHandler{  
    @Override  
    public void uncaughtException(Thread thread, final Throwable throwable) {  
        // Deal this exception
    }
}

在任何线程中,都可以通过setDefaultUncaughtExceptionHandler来设置handler,但在Android应用程序中,全局的Application和Activity、Service都同属于UI主线程,线程名称默认为“main”。所以,在Application中应该为UI主线程添加UncaughtExceptionHandler,这样整个程序中的Activity、Service中出现的UncaughtException事件都可以被处理。

如果多次调用setDefaultUncaughtExceptionHandler设置handler,以最后一次为准。这也就是为什么多个抓崩溃的sdk同时使用,总会有一些sdk工作不正常的。某些情况下,用户会即想利用第三方的sdk收集崩溃,又想根据崩溃类型做出业务相关的处理。此时有两种方案:

获取Exception崩溃堆栈

捕获Exception之后,我们还需要知道崩溃堆栈的信息,这样有助于我们分析崩溃的原因,查找代码的bug。异常对象的printStackTrace方法用于打印异常的堆栈信息,根据printStackTrace方法的输出结果,我们可以找到异常的源头,并跟踪到异常一路触发的过程。

public static String getStackTraceInfo(final Throwable throwable) {
    String trace = "";
    try {
        Writer writer = new StringWriter();
        PrintWriter pw = new PrintWriter(writer);
        throwable.printStackTrace(pw);
        trace = writer.toString();
        pw.close();
    } catch (Exception e) {
        return "";
    }
    return trace;
}

Native代码的崩溃机制及实现

Android平台除了使用Java语言开发以外,还提供了对C/C++的支持。对于一些高cpu消耗的应用程序,Java语言很难满足对性能的要求,这时就需要使用C/C++进行开发,比如游戏引擎、信号处理等。但是Native代码只能开发动态链接库(so),然后Java通过JNI来调用so库。

Native崩溃分析与捕获

C++也可以通过try...catch去处理一些异常,但如果出现了uncaught异常,so库就会引起崩溃,此时肯定无法通过Java的UncaughtExceptionHandler来处理,那么我们应该如何捕获Native代码的崩溃呢?熟悉Linux开发的人都知道,so库一般通过gcc/g++编译,崩溃时会产生信号异常。Android底层是Linux系统,所以so库崩溃时也会产生信号异常。那么如果我们能够捕获信号异常,就相当于我们捕获了Android Native崩溃。

信号其实是一种软件层面的中断机制,当程序出现错误,比如除零、非法内存访问时,便会产生信号事件。那么进程如何获知并响应该事件呢?Linux的进程是由内核管理的,内核会接收信号,并将其放入到相应的进程的信号队列里面。当进程由于系统调用、中断或异常而进入内核态以后,从内核态回到用户态之前会检测信号队列,并查找到相应的信号处理函数。内核会为进程分配默认的信号处理函数,如果你想要对某个信号进行特殊处理,则需要注册相应的信号处理函数。

信号检测与处理流程

进程对信号异常的响应可以归结为以下几类:
1. 忽略信号:对信号不做任何处理,除了SIGKILL及SIGSTOP以外(超级用户杀掉进程时产生),其他都可以忽略
2. 捕获信号:注册信号处理函数,当信号发生时,执行相应的处理函数
3. 默认处理:执行内核分配的默认信号处理函数,大多数我们遇到的信号异常,默认处理是终止程序并生成core文件

对Native代码的崩溃,可以通过调用sigaction()注册信号处理函数来完成。

#include <signal.h> 
int sigaction(int signum,const struct sigaction *act,struct sigaction *oldact));

sigaction函数用于改变进程接收到特定信号后的行为。如果把第二、第三个参数都设为NULL,那么该函数可用于检查信号的有效性。

结构体sigaction包含了对特定信号的处理、信号所传递的信息、信号处理函数执行过程中应屏蔽掉哪些函数等等。

struct sigaction {
    void     (*sa_handler)(int);
    void     (*sa_sigaction)(int, siginfo_t *, void *);
    sigset_t   sa_mask;
    int        sa_flags;
    void     (*sa_restorer)(void);
}

在一些体系上,sa_handler和sa_sigaction共用一个联合体(union),所以不要同时指定两个字段的值。

各参数的详细使用说明,请参考相关资料。
基于上面的分析,下面给出Native代码崩溃(即信号异常)捕获的代码片段,让大家有一个更直观的认识。

const int handledSignals[] = {
    SIGSEGV, SIGABRT, SIGFPE, SIGILL, SIGBUS
};
const int handledSignalsNum = sizeof(handledSignals) / sizeof(handledSignals[0]);
struct sigaction old_handlers[handledSignalsNum];

int nativeCrashHandler_onLoad(JNIEnv *env) {
    struct sigaction handler;
    memset(&handler, 0, sizeof(sigaction));
    handler.sa_sigaction = my_sigaction;
    handler.sa_flags = SA_RESETHAND;

    for (int i = 0; i < handledSignalsNum; ++i) {
        sigaction(handledSignals[i], &handler, &old_handlers[i]);
    }

    return 1;
}

当Android应用程序加载so库的时候,调用nativeCrashHandler_onLoad会为SIGSEGV、SIGABRT、SIGFPE、SIGILL、SIGBUS通过sigaction注册信号处理函数my_sigaction。当发生Native崩溃并且发生前面几个信号异常时,就会调用my_sigaction完成信号处理。

notifyNativeCrash  = (*env)->GetMethodID(env, cls,  "notifyNativeCrash", "()V");

void my_sigaction(int signal, siginfo_t *info, void *reserved) {
    // Here catch the native crash
}

获取Native崩溃堆栈

Android没有提供像throwable.printStackTrace一样的接口去获取Native崩溃后堆栈信息,所以我们需要自己想办法实现。这里有两种思路可以考虑。

在本地调试代码时,我们经常通过查看logcat日志来分析解决问题。对于发布的应用,在代码中执行命令"logcat -d -v threadtime"也能达到同样的效果,只不过是获取到了用户手机的logcat。当Native崩溃时,Android系统同样会输出崩溃堆栈到logcat,那么拿到了logcat信息也就拿到了Native的崩溃堆栈。

 Process process = Runtime.getRuntime().exec(new String[]{"logcat","-d","-v","threadtime"});
 String logTxt = getSysLogInfo(process.getInputStream());

在my_sigaction捕获到异常信号后,通知Java层代码,在Java层启动新的进程,并在新的进程中完成上面的操作。这里注意一定要在新的进程中完成,因为原有的进程马上就会结束。

网络上有一些对应这种思路的代码,但是在很多手机上都无法获得Native的崩溃堆栈。原因是对崩溃堆栈产生了破坏,使得相关信息并没有输出到logcat中。研究一下Android backtrace的底层实现以及google breakpad的源码,会帮助你解决这个问题。

Linux提供了Core Dump机制,即操作系统会把程序崩溃时的内存内容dump出来,写入一个叫做core的文件里面。Google breakpad作为跨平台的崩溃转储和分析模块(支持windows, mac, linux, iOS和android等),便是通过类似的minidump机制来获取崩溃堆栈的。

通过Google breakpad捕获信号异常,并将堆栈信息写入你指定的本地minidump文件中。下次启动应用程序的时候,便可以读取该minidump文件进行相应的操作,比如上传到后台服务器。当然,也可以修改Google breakpad的源码,不写minidump文件,而是通过dumpCallback直接获得堆栈信息,并将相关信息通知到Java层代码,做相应的处理。

Google Breakpad是权威的捕获Native崩溃的方法,相关的使用方法可以查看官网文档。由于它的跨平台,代码体量较大,所以建议大家裁剪源码,只保留Android相关的功能,保持自己APK的小巧。

参考资料

1. Android官方文档
2. Linux内核源代码情景分析
3. Google breakpad官方文档及源码
添加新批注
在作者公开此批注前,只有你和作者可见。
回复批注