Android开发之打造永不崩溃的APP——Crash防护

1 什么是Crash

Crash,即闪退,多指在移动设备(如iOS、Android设备)中,在打开应用程序时出现的突然退出中断的情况(类似于Windows的应用程序崩溃)。

2 Crash的成本

假设公司安卓端的日活是20万(对于很多公司来说,要远远超过这个数),Crash率为业界比较优秀的0.3%,再假设3次crash导致一个用户流失,那么

每天导致的用户流失数量是:
200 000 * 0.003 / 3 = 200

每个用户值多少钱呢?这个每个公司都不一样, 每个用户20块应该算比较平均的估计了。

那么,每天因为crash导致的资产流失:
200 * 20 = 4000

也就是说,每年公司因为crash损失
4000* 12 *30 = 144万

3 为什么会Crash

简单来说,因为有异常未被try-catch,应用程序进程被杀。

  • 在Thread ApI中提供了 UncaughtExceptionHandler ,它能检测出某个线程由于未捕获的异常而终结的情况,然后开发者可以对未捕获异常进行善后处理,例如回收一些系统资源,或者没有关闭当前的连接等等。
    Thread.UncaughtExceptionHandler 是一个接口,它提供如下的方法,让我们自定义异常处理程序。
  •     public static interface UncaughtExceptionHandler {
            void uncaughtException(Thread thread, Throwable ex);
    

    在Android平台中,应用进程fork出来后会为虚拟机设置一个UncaughtExceptionHandler

    //RuntimeInit.java中的zygoteInit函数
    public static final void zygoteInit(int targetSdkVersion, String[] argv, ClassLoader classLoader)
            throws ZygoteInit.MethodAndArgsCaller {
        ............
        //跟进commonInit
        commonInit();
        ............
    private static final void commonInit() {
        ...........
        /* set default handler; this applies to all threads in the VM */
        //到达目的地!
        Thread.setDefaultUncaughtExceptionHandler(new UncaughtHandler());
        ...........
    

    这个UncaughtHandler就是系统的实现,当线程(包括子线程和主线程)因未捕获的异常而即将终止时,就会杀死应用进程,并弹出一个应用崩溃的对话框。如下:

    //com.android.internal.os.RuntimeInit.UncaughtHandler
         * Use this to log a message when a thread exits due to an uncaught
         * exception.  The framework catches these for the main threads, so
         * this should only matter for threads created by applications.
        private static class UncaughtHandler implements Thread.UncaughtExceptionHandler {
            public void uncaughtException(Thread t, Throwable e) {
                try {
                   ......
                        Clog_e(TAG, message.toString(), e);//1. logcat打印出异常栈信息
                  .......
                    // Bring up crash dialog, wait for it to be dismissed
                    ActivityManagerNative.getDefault().handleApplicationCrash(
                            mApplicationObject, new ApplicationErrorReport.CrashInfo(e));
                                      //2. AMS处理crash的一系列行为,其中包括创建并提示crash对话框
                } catch (Throwable t2) {
                   .....
                } finally {
                    // Try everything to make sure this process goes away.
                    Process.killProcess(Process.myPid());//3. 杀死应用进程
                    System.exit(10);
    
  • 通过以下方法,我们可以给应用设置我们自定义的UncaughtExceptionHandler:
  •       Thread.setDefaultUncaughtExceptionHandler(new Thread.UncaughtExceptionHandler() {
                @Override
                public void uncaughtException(Thread t, Throwable e) {
                    Log.e(TAG,e);
    

    这个时候系统默认的杀死应用进程的UncaughtExceptionHandler不会再生效。子线程发生了未捕获异常不会导致Crash(子线程被终止了,主线程还在运行),主线程发生了未捕获异常会导致ANR(主线程已经被终止了)。

    4 android应用程序源码启动基本流程

    Android应用程序进程启动过程的源代码分析

    每一个进程的主线程的执行都在一个ActivityThread实例里,其中也包含了四大组件的启动和销毁及相关生命周期方法在主线程的执行逻辑。

    Android应用程序进程的入口函数是ActivityThread.main()(即java程序的入口main函数)。即进程创建完成之后,Android应用程序框架层就会在这个进程中将ActivityThread类加载进来,然后执行它的main函数,这个main函数就是进程执行消息循环的地方了:

    //ActivityThread .java
    //主线程的入口方法
    public static void main(String[] args) {
         ......
         //创建主线程的Looper和MessageQueue
         Looper.prepareMainLooper();
        //创建一个ActivityThread实例,然后调用它的attach函数,
        //ActivityManagerService通过Binder进程间通信机制通知ActivityThread,启动应用首页
         ActivityThread thread = new ActivityThread();
         thread.attach(false);
         if (sMainThreadHandler == null) {
             sMainThreadHandler = thread.getHandler();
        .......
       //开启主线程的消息循环。
         Looper.loop();
         throw new RuntimeException("Main thread loop unexpectedly exited");
    

    这个函数在进程中创建一个ActivityThread实例,然后调用它的attach函数,接着就进入消息循环了,直到最后进程退出。
    下面简单说说Android的消息循环机制。

    4.1 Android应用程序的消息机制

  • MessageQueue
    MessageQueue叫做消息队列,但是实际上它内部的存储结构是单链表的方式。
  • Looper
    Message只是一个消息的存储单元,它不能去处理消息,这个时候Looper就弥补了这个功能,Looper会以无限循环的形式从MessageQueue中查看是否有新消息,如果有新消息就会立即处理,否则就一直阻塞在那里。
  • Handler
    Handler把消息添加到了MessageQueue,Looper.loop会拿到该消息,按照handler的实现来处理响应的消息。
  • 4.2 Looper的工作机制

    上面所说的Andoird消息机制,主要体现在loop()方法里:
    Looper.loop()方法会无限循环调用MessageQueue的next()方法来获取新消息,而next是是一个阻塞操作,但没有信息时,next方法会一直阻塞在那里,这也导致loop方法一直阻塞在那里。如果MessageQueue的next方法返回了新消息,Looper就会处理这条消息。

        public static void loop() {
            ......
            for (;;) {
                Message msg = queue.next(); // might block
                ......
                msg.target.dispatchMessage(msg);//里面调用了handler.handleMessage()
    

    Android的view绘制,事件分发,Activity启动,Activity的生命周期回调等等都是一个个的Message,系统会把这些Message插入到主线程中唯一的queue中,所有的消息都排队等待Looper将其取出,并在主线程执行。

    比如点击一个按钮最终都是产生一个消息放到MessageQueue,等待Looper取出消息处理。
    Android中MotionEvent的来源和ViewRootImpl这篇文章追踪MotionEvent的来源,发现在ViewRootImpl中的dispatchInputEvent有一个方法:

    //ViewRootImpl.java
    public void dispatchInputEvent(InputEvent event, InputEventReceiver receiver) {
            SomeArgs args = SomeArgs.obtain();
            args.arg1 = event;
            args.arg2 = receiver;
            Message msg = mHandler.obtainMessage(MSG_DISPATCH_INPUT_EVENT, args);
            msg.setAsynchronous(true);
            mHandler.sendMessage(msg);
    

    5 crash防护的思路

    综上所述,主进程运行的所有代码都跑在Looper.loop();。前面也提到,crash的发生是由于 主线程有未捕获的异常。那么我Looper.loop();用try-catch块包起来,应用程序就永不崩溃了!
    所以在github上看到一个很精妙的思路android-notes/Cockroach,主要代码如下:

     new Handler(Looper.getMainLooper()).post(new Runnable() {
                @Override
                public void run() {
                   //主线程异常拦截
                    while (true) {
                        try {
                            Looper.loop();//主线程的异常会从这里抛出
                        } catch (Throwable e) {
            sUncaughtExceptionHandler = Thread.getDefaultUncaughtExceptionHandler();
             //所有线程异常拦截,由于主线程的异常都被我们catch住了,所以下面的代码拦截到的都是子线程的异常
            Thread.setDefaultUncaughtExceptionHandler(new Thread.UncaughtExceptionHandler() {
                @Override
                public void uncaughtException(Thread t, Throwable e) {
    

    原理很简单:

  • 通过Handler往主线程的queue中添加一个Runnable,当主线程执行到该Runnable时,会进入我们的while死循环,如果while内部是空的就会导致代码卡在这里,最终导致ANR。
  • 我们在while死循环中又调用了Looper.loop(),这就导致主线程又开始不断的读取queue中的Message并执行,也就是主线程并不会被阻塞。同时又可以保证以后主线程的所有异常都会从我们手动调用的Looper.loop()处抛出,一旦抛出就会被try-catch捕获,这样主线程就不会crash了。
  • 通过while(true)让主线程抛出异常后迫使主线程重新进入我们try-catch中的消息循环。 如果没有这个while的话那么主线程在第二次抛出异常时我们就又捕获不到了,这样APP就又crash了。
  • 总而言之,Android应用程序的主线程是阻塞在main()中的Looper.loop()的for(;;)循环里,后来for循环取到我们的runnable之后,程序的流程就阻塞在了我们的runnable里面了。

    强调一下,上面所做的并不能帮你解决程序中的逻辑错误,它只是当你出现异常的时候让你的app进程不会崩,可以减少crash的次数,提高用户体验和留存率而已

    6 框架优点

    利用java原生的try-catch机制捕获所有运行时异常,简单、稳定、无兼容性问题。你甚至可以通过后端来配置一个开关,在应用启动时决定要不要装载这个框架。

    虽然强行捕获所有运行时异常(往往是因为开发者遗留下的BUG),会导致各种UI上的奇葩问题发生,但可以最大程度的保证APP正常运行,很多时候我们希望主线程即使抛出异常也不影响app的正常使用,比如我们 给某个view设置背景色时,由于view是null就会导致app crash,像这种问题我们更希望即使view没法设置颜色也不要crash,这时直接try-catch的做法是非常合适的。

    7 try-catch机制及其性能损耗

    Java的异常处理可以让程序具有更好的容错性,程序更加健壮。当程序运行出现意外情形时,系统会自动生成一个Exception对象来通知程序。
    上面的做法相当于把Android应用程序整个主线程的运行都try-catch起来了,大家肯定会考虑到性能损耗问题。
    说到其性能损耗,一般人都可能会比较感性武断地说try-catch有一定的性能损耗,毕竟做了“额外”的事情。作为开发者当然不能像产品经理那样拍脑袋思考问题。这里我从两种方式去探究一下:

  • 写两个一样逻辑的函数,只不过一个包含try-catch代码块,一个不包含,分别循环调用百万次,通过System.nanoTime()来比较两个函数百万次调用的耗时。我本机跑了一下基本上没什么区别。
  • 可以看看.java文件经过编译生成的JVM可以执行的.class文件里的字节码指令。
  •  javap -verbose ReturnValueTest  xx.class 命令可以查看字节码
    

    《深入Java虚拟机》作者Bill Venners于1997年所写的文章How the Java virtual machine handles exceptions比较详尽地分析了一番。文章从反编译出的指令发现加了try-catch块的代码跟没有加的代码运行时的指令是完全一致的(你也可以按照上面命令自行进行对比)。 ** 如果程序运行过程中不产生异常的话try catch 几乎是不会对运行产生任何影响的**。只是在产生异常的时候jvm会追溯异常栈。这部分耗时就相对较高了。

    8 其他方案

    不对未捕获异常进行try-catch的话,那就只能让程序按照系统默认的处理杀掉进程。然后重启进程 恢复crash之前的Activity栈,达到比直接退出应用程序稍微好点的体验。

    Sunzxyong/Recovery这个框架在启动每个Activity都记录起Activity的class对象以及所需要的Intent对象,应用崩溃后重启进程再通过这些缓存起来的Intent对象一次性把所有的Activity都启动起来。

    但如果是启动过程中必现的BUG,这种方式会导致无限循环重启进程、恢复Activity。所以框架又做了一个处理,在一分钟内闪退两次就会杀掉进程不再重启。

    这种方式实际上应用还是发生了崩溃,只不过去帮重启后的应用恢复到原来的页面,实际使用时屏幕还是会有一个白屏闪烁,用户还是能够感知到APP崩溃了。

    所以我觉得有点鸡肋。

    android-notes/Cockroach