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應用程序源碼啟動基本流程
每一個進程的主線程的執行都在一個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崩潰了。
所以我覺得有點雞肋。