能否讓APP永不崩潰—小光與我的對決

jimuzz發表於2020-12-10

前言

關於攔截異常,想必大家都知道可以通過Thread.setDefaultUncaughtExceptionHandler來攔截App中發生的異常,然後再進行處理。

於是,我有了一個不成熟的想法。。。

讓我的APP永不崩潰

既然我們可以攔截崩潰,那我們直接把APP中所有的異常攔截了,不殺死程式。這樣一個不會崩潰的APP使用者體驗不是槓槓的?

  • 有人聽了搖搖頭表示不贊同,這不小光跑來問我了:

“老鐵,出現崩潰是要你解決它不是掩蓋它!!”

  • 我拿把扇子扇了幾下,有點冷但是故作鎮定的說:

“這位老哥,你可以把異常上傳到自己的伺服器處理啊,你能拿到你的崩潰原因,使用者也不會因為異常導致APP崩潰,這不挺好?”

  • 小光有點生氣的說:

“這樣肯定有問題,聽著就不靠譜,哼,我去試試看”

小光的實驗

於是小光按照網上一個小博主—積木的文章,寫出了以下捕獲異常的程式碼:

//定義CrashHandler
class CrashHandler private constructor(): Thread.UncaughtExceptionHandler {
    private var context: Context? = null
    fun init(context: Context?) {
        this.context = context
        Thread.setDefaultUncaughtExceptionHandler(this)
    }

    override fun uncaughtException(t: Thread, e: Throwable) {}

    companion object {
        val instance: CrashHandler by lazy(mode = LazyThreadSafetyMode.SYNCHRONIZED) {
            CrashHandler() }
    }
}

//Application中初始化
class MyApplication : Application(){
    override fun onCreate() {
        super.onCreate()
        CrashHandler.instance.init(this)
    }
}

//Activity中觸發異常
class ExceptionActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_exception)
        
        btn.setOnClickListener {
            throw RuntimeException("主執行緒異常")
        }
        btn2.setOnClickListener {
            thread {
                throw RuntimeException("子執行緒異常")
            }
        }
    }
}

小光一頓操作,寫下了整套程式碼,為了驗證它的猜想,寫了兩種觸發異常的情況:子執行緒崩潰和主執行緒崩潰

  • 執行,點選按鈕2,觸發子執行緒異常崩潰:

“咦,還真沒啥影響,程式能繼續正常執行”

  • 然後點選按鈕1,觸發主執行緒異常崩潰:

“嘿嘿,卡住了,再點幾下,直接ANR了”

主執行緒崩潰

“果然有問題,但是為啥主執行緒會出問題呢?我得先搞懂再去找老鐵對峙。”

小光的思考(異常原始碼分析)

首先科普下java中的異常,包括執行時異常非執行時異常

  • 執行時異常。是RuntimeException類及其子類的異常,是非受檢異常,比如系統異常或者是程式邏輯異常,我們常遇到的有NullPointerException、IndexOutOfBoundsException等。遇到這種異常,Java Runtime會停止執行緒,列印異常,並且會停止程式執行,也就是我們常說的程式崩潰。

  • 非執行時異常。是屬於Exception類及其子類,是受檢異常,RuntimeException以外的異常。這類異常在程式中必須進行處理,如果不處理程式都無法正常編譯,比如NoSuchFieldException,IllegalAccessException這種。

ok,也就是說我們丟擲一個RuntimeException異常之後,所在的執行緒會被停止。如果主執行緒中丟擲這個異常,那麼主執行緒就會被停止,所以APP就會卡住無法正常操作,時間久了就會ANR。而子執行緒崩潰了並不會影響主執行緒也就是UI執行緒的操作,所以使用者還能正常使用。

這樣好像就說的通了。

等等,那為什麼遇到setDefaultUncaughtExceptionHandler就不會崩潰了呢?

我們還得從異常的原始碼開始說起:

一般情況下,一個應用中所使用的執行緒都是在同一個執行緒組,而在這個執行緒組裡只要有一個執行緒出現未被捕獲異常的時候,JAVA 虛擬機器就會呼叫當前執行緒所線上程組中的 uncaughtException()方法。

// ThreadGroup.java
  private final ThreadGroup parent;

    public void uncaughtException(Thread t, Throwable e) {
        if (parent != null) {
            parent.uncaughtException(t, e);
        } else {
            Thread.UncaughtExceptionHandler ueh =
                Thread.getDefaultUncaughtExceptionHandler();
            if (ueh != null) {
                ueh.uncaughtException(t, e);
            } else if (!(e instanceof ThreadDeath)) {
                System.err.print("Exception in thread \""
                                 + t.getName() + "\" ");
                e.printStackTrace(System.err);
            }
        }
    }

parent表示當前執行緒組的父級執行緒組,所以最後還是會呼叫到這個方法中。接著看後面的程式碼,通過getDefaultUncaughtExceptionHandler獲取到了系統預設的異常處理器,然後呼叫了uncaughtException方法。那麼我們就去找找本來系統中的這個異常處理器——UncaughtExceptionHandler

這就要從APP的啟動流程說起了,之前也說過,所有的Android程式都是由zygote程式fork而來的,在一個新程式被啟動的時候就會呼叫zygoteInit方法,這個方法裡會進行一些應用的初始化工作:

    public static final Runnable zygoteInit(int targetSdkVersion, String[] argv, ClassLoader classLoader) {
        if (RuntimeInit.DEBUG) {
            Slog.d(RuntimeInit.TAG, "RuntimeInit: Starting application from zygote");
        }

        Trace.traceBegin(Trace.TRACE_TAG_ACTIVITY_MANAGER, "ZygoteInit");
        //日誌重定向
        RuntimeInit.redirectLogStreams();
        //通用的配置初始化  
        RuntimeInit.commonInit();
        // zygote初始化
        ZygoteInit.nativeZygoteInit();
        //應用相關初始化
        return RuntimeInit.applicationInit(targetSdkVersion, argv, classLoader);
    }

而關於異常處理器,就在這個通用的配置初始化方法當中:

    protected static final void commonInit() {
        if (DEBUG) Slog.d(TAG, "Entered RuntimeInit!");

       //設定異常處理器
        LoggingHandler loggingHandler = new LoggingHandler();
        Thread.setUncaughtExceptionPreHandler(loggingHandler);
        Thread.setDefaultUncaughtExceptionHandler(new KillApplicationHandler(loggingHandler));

        //設定時區
        TimezoneGetter.setInstance(new TimezoneGetter() {
            @Override
            public String getId() {
                return SystemProperties.get("persist.sys.timezone");
            }
        });
        TimeZone.setDefault(null);

        //log配置
        LogManager.getLogManager().reset();
        //***    

        initialized = true;
    }

找到了吧,這裡就設定了應用預設的異常處理器——KillApplicationHandler

private static class KillApplicationHandler implements Thread.UncaughtExceptionHandler {
        private final LoggingHandler mLoggingHandler;

        
        public KillApplicationHandler(LoggingHandler loggingHandler) {
            this.mLoggingHandler = Objects.requireNonNull(loggingHandler);
        }

        @Override
        public void uncaughtException(Thread t, Throwable e) {
            try {
                ensureLogging(t, e);
                //...    
                // Bring up crash dialog, wait for it to be dismissed
                ActivityManager.getService().handleApplicationCrash(
                        mApplicationObject, new ApplicationErrorReport.ParcelableCrashInfo(e));
            } catch (Throwable t2) {
                if (t2 instanceof DeadObjectException) {
                    // System process is dead; ignore
                } else {
                    try {
                        Clog_e(TAG, "Error reporting crash", t2);
                    } catch (Throwable t3) {
                        // Even Clog_e() fails!  Oh well.
                    }
                }
            } finally {
                // Try everything to make sure this process goes away.
                Process.killProcess(Process.myPid());
                System.exit(10);
            }
        }

        private void ensureLogging(Thread t, Throwable e) {
            if (!mLoggingHandler.mTriggered) {
                try {
                    mLoggingHandler.uncaughtException(t, e);
                } catch (Throwable loggingThrowable) {
                    // Ignored.
                }
            }
        }

看到這裡,小光欣慰一笑,被我逮到了吧。在uncaughtException回撥方法中,會執行一個handleApplicationCrash方法進行異常處理,並且最後都會走到finally中進行程式銷燬,Try everything to make sure this process goes away。所以程式就崩潰了。

關於我們平時在手機上看到的崩潰提示彈窗,就是在這個handleApplicationCrash方法中彈出來的。不僅僅是java崩潰,還有我們平時遇到的native_crash、ANR等異常都會最後走到handleApplicationCrash方法中進行崩潰處理。

另外有的朋友可能發現了構造方法中,傳入了一個LoggingHandler,並且在uncaughtException回撥方法中還呼叫了這個LoggingHandleruncaughtException方法,難道這個LoggingHandler就是我們平時遇到崩潰問題,所看到的崩潰日誌?進去瞅瞅:

private static class LoggingHandler implements Thread.UncaughtExceptionHandler {
        public volatile boolean mTriggered = false;

        @Override
        public void uncaughtException(Thread t, Throwable e) {
            mTriggered = true;
            if (mCrashing) return;

            if (mApplicationObject == null && (Process.SYSTEM_UID == Process.myUid())) {
                Clog_e(TAG, "*** FATAL EXCEPTION IN SYSTEM PROCESS: " + t.getName(), e);
            } else {
                StringBuilder message = new StringBuilder();
                message.append("FATAL EXCEPTION: ").append(t.getName()).append("\n");
                final String processName = ActivityThread.currentProcessName();
                if (processName != null) {
                    message.append("Process: ").append(processName).append(", ");
                }
                message.append("PID: ").append(Process.myPid());
                Clog_e(TAG, message.toString(), e);
            }
        }
    }

    private static int Clog_e(String tag, String msg, Throwable tr) {
        return Log.printlns(Log.LOG_ID_CRASH, Log.ERROR, tag, msg, tr);
    }

這可不就是嗎?將崩潰的一些資訊——比如執行緒,程式,程式id,崩潰原因等等通過Log列印出來了。來張崩潰日誌圖給大家對對看:

崩潰日誌圖

好了,回到正軌,所以我們通過setDefaultUncaughtExceptionHandler方法設定了我們自己的崩潰處理器,就把之前應用設定的這個崩潰處理器給頂掉了,然後我們又沒有做任何處理,自然程式就不會崩潰了,來張總結圖。

崩潰呼叫圖

小光又來找我對峙了

  • 搞清楚這一切的小光又來找我了:

“老鐵,你瞅瞅,這是我寫的Demo和總結的資料,你那套根本行不通,主執行緒崩潰就GG了,我就說有問題吧”

  • 我繼續故作鎮定

“老哥,我上次忘記說了,只加這個UncaughtExceptionHandler可不行,還得加一段程式碼,發給你,回去試試吧”

    Handler(Looper.getMainLooper()).post {
        while (true) {
            try {
                Looper.loop()
            } catch (e: Throwable) {
            }
        }
    }

“這,,能行嗎”

小光再次的實驗

小光把上述程式碼加到了程式裡面(Application—onCreate),再次執行:

我去,真的沒問題了,點選主執行緒崩潰後,還是可以正常操作app,這又是什麼原理呢?

小光的再次思考(攔截主執行緒崩潰的方案思想)

我們都知道,在主執行緒中維護著Handler的一套機制,在應用啟動時就做好了Looper的建立和初始化,並且呼叫了loop方法開始了訊息的迴圈處理。應用在使用過程中,主執行緒的所有操作比如事件點選,列表滑動等等都是在這個迴圈中完成處理的,其本質就是將訊息加入MessageQueue佇列,然後迴圈從這個佇列中取出訊息並處理,如果沒有訊息處理的時候,就會依靠epoll機制掛起等待喚醒。貼一下我濃縮的loop程式碼:

    public static void loop() {
        final Looper me = myLooper();
        final MessageQueue queue = me.mQueue;
        for (;;) {
            Message msg = queue.next(); 
            msg.target.dispatchMessage(msg);
        }
    }

一個死迴圈,不斷取訊息處理訊息。再回頭看看剛才加的程式碼:

    Handler(Looper.getMainLooper()).post {
        while (true) {
            //主執行緒異常攔截
            try {
                Looper.loop()
            } catch (e: Throwable) {
            }
        }
    }

我們通過Handler往主執行緒傳送了一個runnable任務,然後在這個runnable中加了一個死迴圈,死迴圈中執行了Looper.loop()進行訊息迴圈讀取。這樣就會導致後續所有的主執行緒訊息都會走到我們這個loop方法中進行處理,也就是一旦發生了主執行緒崩潰,那麼這裡就可以進行異常捕獲。同時因為我們寫的是while死迴圈,那麼捕獲異常後,又會開始新的Looper.loop()方法執行。這樣主執行緒的Looper就可以一直正常讀取訊息,主執行緒就可以一直正常執行了。

文字說不清楚的圖片來幫我們:

同時之前CrashHandler的邏輯可以保證子執行緒也是不受崩潰影響,所以兩段程式碼都加上,齊活了。

但是小光還不服氣,他又想到了一種崩潰情況。。。

小光又又又一次實驗

class Test2Activity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_exception)

        throw RuntimeException("主執行緒異常")
    }
}

誒,我直接在onCreate裡面給你丟擲個異常,執行看看:

黑漆漆的一片~沒錯,黑屏了

最後的對話(Cockroach庫思想)

  • 看到這一幕,我主動找到了小光:

“這種情況確實比較麻煩了,如果直接在Activity生命週期內丟擲異常,會導致介面繪製無法完成,Activity無法被正確啟動,就會白屏或者黑屏了
這種嚴重影響到使用者體驗的情況還是建議直接殺死APP,因為很有可能會對其他的功能模組造成影響。或者如果某些Activity不是很重要,也可以只finish這個Activity。”

  • 小光思索地問:
    “那麼怎麼分辨出這種生命週期內發生崩潰的情況呢?”

“這就要通過反射了,借用Cockroach開源庫中的思想,由於Activity的生命週期都是通過主執行緒的Handler進行訊息處理,所以我們可以通過反射替換掉主執行緒的Handler中的Callback回撥,也就是ActivityThread.mH.mCallback,然後針對每個生命週期對應的訊息進行trycatch捕獲異常,然後就可以進行finishActivity或者殺死程式操作了。”

主要程式碼:

		Field mhField = activityThreadClass.getDeclaredField("mH");
        mhField.setAccessible(true);
        final Handler mhHandler = (Handler) mhField.get(activityThread);
        Field callbackField = Handler.class.getDeclaredField("mCallback");
        callbackField.setAccessible(true);
        callbackField.set(mhHandler, new Handler.Callback() {
            @Override
            public boolean handleMessage(Message msg) {
                if (Build.VERSION.SDK_INT >= 28) {
                //android 28之後的生命週期處理
                    final int EXECUTE_TRANSACTION = 159;
                    if (msg.what == EXECUTE_TRANSACTION) {
                        try {
                            mhHandler.handleMessage(msg);
                        } catch (Throwable throwable) {
                            //殺死程式或者殺死Activity
                        }
                        return true;
                    }
                    return false;
                }
                
                //android 28之前的生命週期處理
                switch (msg.what) {
                    case RESUME_ACTIVITY:
                    //onRestart onStart onResume回撥這裡
                        try {
                            mhHandler.handleMessage(msg);
                        } catch (Throwable throwable) {
                            sActivityKiller.finishResumeActivity(msg);
                            notifyException(throwable);
                        }
                        return true;

程式碼貼了一部分,但是原理大家應該都懂了吧,就是通過替換主執行緒HandlerCallback,進行宣告週期的異常捕獲。

接下來就是進行捕獲後的處理工作了,要不殺死程式,要麼殺死Activity。

  • 殺死程式,這個應該大家都熟悉
  Process.killProcess(Process.myPid())
  exitProcess(10)
  • finish掉Activity

這裡又要分析下Activity的finish流程了,簡單說下,以android29的原始碼為例。

    private void finish(int finishTask) {
        if (mParent == null) {
            
            if (false) Log.v(TAG, "Finishing self: token=" + mToken);
            try {
                if (resultData != null) {
                    resultData.prepareToLeaveProcess(this);
                }
                if (ActivityTaskManager.getService()
                        .finishActivity(mToken, resultCode, resultData, finishTask)) {
                    mFinished = true;
                }
            } 
        } 

    }
    
    
    @Override
    public final boolean finishActivity(IBinder token, int resultCode, Intent resultData,
            int finishTask) {
        return mActivityTaskManager.finishActivity(token, resultCode, resultData, finishTask);
    }    

從Activity的finish原始碼可以得知,最終是呼叫到ActivityTaskManagerServicefinishActivity方法,這個方法有四個引數,其中有個用來標識Activity的引數也就是最重要的引數——token。所以去原始碼裡面找找token~

由於我們捕獲的地方是在handleMessage回撥方法中,所以只有一個引數Message可以用,那我麼你就從這方面入手。回到剛才我們處理訊息的原始碼中,看看能不能找到什麼線索:

 class H extends Handler {
        public void handleMessage(Message msg) {
            switch (msg.what) {
                case EXECUTE_TRANSACTION: 
                    final ClientTransaction transaction = (ClientTransaction) msg.obj;
                    mTransactionExecutor.execute(transaction);
                    break;              
            }        
        }
    }
    
    public void execute(ClientTransaction transaction) {
        final IBinder token = transaction.getActivityToken();
        executeCallbacks(transaction);
        executeLifecycleState(transaction);
        mPendingActions.clear();
        log("End resolving transaction");
    }    

可以看到在原始碼中,Handler是怎麼處理EXECUTE_TRANSACTION訊息的,獲取到msg.obj物件,也就是ClientTransaction類例項,然後呼叫了execute方法。而在execute方法中。。。咦咦咦,這不就是token嗎?

(找到的過於快速了哈,主要是activity啟動銷燬這部分的原始碼解說並不是今天的重點,所以就一筆帶過了)

找到token,那我們就通過反射進行Activity的銷燬就行啦:

    private void finishMyCatchActivity(Message message) throws Throwable {
        ClientTransaction clientTransaction = (ClientTransaction) message.obj;
        IBinder binder = clientTransaction.getActivityToken();
       
       Method getServiceMethod = ActivityManager.class.getDeclaredMethod("getService");
        Object activityManager = getServiceMethod.invoke(null);

        Method finishActivityMethod = activityManager.getClass().getDeclaredMethod("finishActivity", IBinder.class, int.class, Intent.class, int.class);
        finishActivityMethod.setAccessible(true);
        finishActivityMethod.invoke(activityManager, binder, Activity.RESULT_CANCELED, null, 0);
    }

啊,終於搞定了,但是小光還是一臉疑惑的看著我:

“我還是去看Cockroach庫的原始碼吧~”

“我去,,”

總結

今天主要就說了一件事:如何捕獲程式中的異常不讓APP崩潰,從而給使用者帶來最好的體驗。主要有以下做法:

  • 通過在主執行緒裡面傳送一個訊息,捕獲主執行緒的異常,並在異常發生後繼續呼叫Looper.loop方法,使得主執行緒繼續處理訊息。
  • 對於子執行緒的異常,可以通過Thread.setDefaultUncaughtExceptionHandler來攔截,並且子執行緒的停止不會給使用者帶來感知。
  • 對於在生命週期內發生的異常,可以通過替換ActivityThread.mH.mCallback的方法來捕獲,並且通過token來結束Activity或者直接殺死程式。但是這種辦法要適配不同SDK版本的原始碼才行,所以慎用,需要的可以看文末Cockroach庫原始碼。

可能有的朋友會問,為什麼要讓程式不崩潰呢?會有哪些情況需要我們進行這樣操作呢?

其實還是有很多時候,有些異常我們無法預料或者給使用者帶來幾乎是無感知的異常,比如:

  • 系統的一些bug
  • 第三方庫的一些bug
  • 不同廠商的手機帶來的一些bug

等等這些情況,我們就可以通過這樣的操作來讓APP犧牲掉這部分的功能來維護系統的穩定性。

參考

Cockroach
一文讀懂 Handler 機制全家桶
zyogte程式(Java篇)
wanAndroid

拜拜

好了,到了說再見的時候了。

最後給大家推薦一個劇—棋魂,嘿嘿,小光就是裡面的主角。

這些優秀的開源庫又何嘗不是指引我們前行進步的光呢~

有一起學習的小夥伴可以關注下❤️我的公眾號——碼上積木,每天剖析一個知識點,我們一起積累知識。公眾號回覆111可獲得面試題《思考與解答》以往期刊。

相關文章