ToastUtil:修復Android 7.x裝置Toast顯示時丟擲的WindowManager$BadTokenException Token失效異常
最近在專案新版本測試中,當在Android 7.x(SDK=24/25)裝置上跑Monkey測試APP時,經常報Token失效異常:“android.view.WindowManager$BadTokenException: Unable to add window – token [email protected] is not valid; is your activity running?”,導致APP出現Crash,直接終止執行:
android.view.WindowManager$BadTokenException: Unable to add window -- token android. os.BinderProxy@ba9eb53 is not valid; is your activity running?
at android.view.ViewRootImpl.setView(ViewRootImpl.java:679)
at android.view.WindowManagerGlobal.addView(WindowManagerGlobal.java:342)
at android.view.WindowManagerImpl.addView(WindowManagerImpl.java:94)
at android.widget.Toast$TN.handleShow (Toast.java:459)
at android.widget.Toast$TN$2.handleMessage(Toast.java:342)
at android.os.Handler.dispatchMessage(Handler.java:102)
at android.os.Looper.loop(Looper.java:158)
at android.app.ActivityThread.main(ActivityThread.java:6175)
at java.lang.reflect.Method.invoke(Native Method)
at com.android. internal.os.ZygoteInit$MethodAndArgsCaller.run(ZygoteInit.java:893)
at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:783)
一、BadTokenException異常產生原因
從上面異常堆疊資訊可以看出,異常發生在當系統Toast內部類物件TN內部的Handler在收到顯示訊息Message,進行處理並呼叫 Toast$TN.handleShow()方法時,Toast$TN.handleShow()方法在不同的Android版本中實現也不一樣: 在Android 7.x版本,handleShow()方法實現如下:
public void handleShow(IBinder windowToken) {
if (localLOGV) Log.v(TAG, "HANDLE SHOW: " + this + " mView=" + mView
+ " mNextView=" + mNextView);
if (mView != mNextView) {
...
mWM = (WindowManager)context.getSystemService(Context.WINDOW_SERVICE);
....
mParams.token = windowToken;
...
mWM.addView(mView, mParams);
...
}
}
在Android 8.0版本,handleShow()方法實現如下:
public void handleShow(IBinder windowToken) {
if (localLOGV) Log.v(TAG, "HANDLE SHOW: " + this + " mView=" + mView
+ " mNextView=" + mNextView);
if (mView != mNextView) {
...
mWM = (WindowManager)context.getSystemService(Context.WINDOW_SERVICE);
....
mParams.token = windowToken;
...
try {
mWM.addView(mView, mParams);
trySendAccessibilityEvent();
} catch (WindowManager.BadTokenException e) {
/* ignore */
}
...
}
}
在上面可以看出,Google已經在Android 8.0原始碼中在呼叫WindowManagerImpl.addView()前通過使用try-catch捕獲WindowManager.BadTokenException異常,修復了該bug(Toast也不會顯示了),避免APP發生Crash,,這也是為什麼該異常在Android 7.x裝置上頻繁出現,而在Android 8.0裝置上幾乎沒有發生的原因。 在Android 7.x裝置上,通常情況下,按照正常的流程,是不會出現這種異常。但是由於在某些情況下, 尤其是在跑Monkey測試的時候,Android 程序某個 UI 執行緒的某個訊息阻塞,導致 TN 的 show 方法 post 出來 0 (顯示) 訊息位於該訊息之後,遲遲沒有執行,導致超時引起NotificationManager超時檢測機制刪除WMS 服務中的 Token 記錄,很容易導致該異常發生,具體Toast顯示流程原始碼分析以及異常產生原因可以閱讀下面QQ音樂技術團隊的分析文章[Android] Toast問題深度剖析(一)
二、如何修復該異常
正如上面分析,異常發生在當系統Toast內部類物件TN內部的Handler在收到顯示訊息Message,進行處理並呼叫 Toast$TN.handleShow()方法時,handleShow()方法是Toast內部類TN的方法,我們無法通過直接繼承Toast重寫handleShow()方法來捕獲該異常,不過通過異常堆疊資訊可知,在呼叫Toast$TN.handleShow()前,會先呼叫Toast$TN$Hanlder.handleMessage(),而呼叫Toast$TN$Hanlder.handleMessage()前,一定會先呼叫Handler.dispatchMessage() 方法,我們可以建立一個安全的Handler裝飾器,通過重寫Handler.dispatchMessage() 方法捕獲丟擲的異常即可,裝飾器Handler實現程式碼如下:
/**
* Safe outside Handler class which just warps the system origin handler object in the Toast.class
*/
private static class SafelyHandlerWarpper extends Handler {
private Handler originHandler;
public SafelyHandlerWarpper(Handler originHandler) {
this.originHandler = originHandler;
}
@Override
public void dispatchMessage(Message msg) {
// 在此處使用try-catch捕獲BadTokenException,當內部Hanlder發生異常,外部SafelyHandlerWarpper可以捕獲,
// 防止應用Crash
try {
super.dispatchMessage(msg);
} catch (Exception e) {
Log.e(TAG, "Catch system toast exception:" + e);
}
}
@Override
public void handleMessage(Message msg) {
// 需要委託給原Handler執行
if (originHandler != null) {
originHandler.handleMessage(msg);
}
}
}
然後,我們需要使用定義的SafeHandlerWarpper物件去包裝 Toast$TN$Hanlder, 然後通過反射去替換 Toast$TN$Hanlder物件,具體請見如下hookToast()方法:
private static final String FIELD_NAME_TN = "mTN";
private static final String FIELD_NAME_HANDLER = "mHandler";
/**
* Hook Toast,修復在7.x手機上跑monkey的時候,Toast低概率出現BadTokenException的異常
*
* @param toast
*/
private static void hookToast(Toast toast) {
if (!isNeedHook()) {
return;
}
try {
if (!sIsHookFieldInit) {
sField_TN = Toast.class.getDeclaredField(FIELD_NAME_TN);
sField_TN.setAccessible(true);
sField_TN_Handler = sField_TN.getType().getDeclaredField(FIELD_NAME_HANDLER);
sField_TN_Handler.setAccessible(true);
sIsHookFieldInit = true;
}
Object tn = sField_TN.get(toast);
Handler originHandler = (Handler) sField_TN_Handler.get(tn);
sField_TN_Handler.set(tn, new SafelyHandlerWarpper(originHandler));
} catch (Exception e) {
Log.e(TAG, "Hook toast exception=" + e);
}
}
我們僅需要選擇在Android 7.x裝置(SDK版本為24或25)上使用自定義Handler裝飾器SafelyHandlerWarpper去hook系統的Toast$TN$Hanlder物件,如果專案在其他Android版本上也出現該異常,可以根據自己專案需要去新增即可,具體請見如下isNeedHook()方法:
/**
* Check if Toast need hook,only hook the device 7.x(api = 24/25)
*
* @return true for need hook to fit system bug,false for don't need hook
*/
private static boolean isNeedHook() {
return Build.VERSION.SDK_INT == Build.VERSION_CODES.N_MR1 ||
Build.VERSION.SDK_INT == Build.VERSION_CODES.N;
}
最後,在我們呼叫Toast.show()準備顯示Toast前,呼叫hookToast()方法即可:
if (mToast == null) {
mToast = Toast.makeText(context, text, duration);
} else {
mToast.setText(text);
mToast.setDuration(duration);
}
hookToast(mToast);
mToast.show();
}
三、如何復現以及檢測是否修復
正如上面所說,當UI執行緒阻塞時,很容易導致該問題產生,我們可以通過在呼叫Toast.show()方法後,在主執行緒中呼叫Thread.sleep()阻塞主執行緒,導致WMS Token超時失效,就可以在Android 7.x裝置上覆現該Exception,如下程式碼所示
Toast.makeText(MainActivity.this,"I am origin Toast without fix",Toast.LENGTH_SHORT).show();
try {
Thread.sleep(10000);
} catch (InterruptedException e){
e.printStackTrace();
}
下面通過Demo來複現以及驗證,本例子中,Demo UI設計如下: 當點選第一個Button時,我們直接使用系統的Toast來顯示Toast:
btnUnfixed.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
Toast.makeText(MainActivity.this,"I am origin Toast without fix",Toast.LENGTH_SHORT).show();
try {
// just sleep and block the main thread which will reappear the BadTokenException
Thread.sleep(10000);
} catch (InterruptedException e){
e.printStackTrace();
}
}
});
當我們點選第一個Button十秒後,Demo APP出現了Crash,APP直接崩掉: 異常堆疊資訊如下:
android.view.WindowManager$BadTokenException: Unable to add window -- token android.os.BinderProxy@ba9eb53 is not valid; is your activity running?
at android.view.ViewRootImpl.setView(ViewRootImpl.java:679)
at android.view.WindowManagerGlobal.addView(WindowManagerGlobal.java:342)
at android.view.WindowManagerImpl.addView(WindowManagerImpl.java:94)
at android.widget.Toast$TN.handleShow(Toast.java:459)
at android.widget.Toast$TN$2.handleMessage(Toast.java:342)
at android.os.Handler.dispatchMessage(Handler.java:102)
at android.os.Looper.loop(Looper.java:158)
at android.app.ActivityThread.main(ActivityThread.java:6175)
at java.lang.reflect.Method.invoke(Native Method)
at com.android.internal.os.ZygoteInit$MethodAndArgsCaller.run(ZygoteInit.java:893)
at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:783)
當我們點選第二個Button顯示Toast時,此時是通過使用ToastUtil來顯示Toast,ToastUtil是對Toast管理的工具類,內部已經根據第二節分析的解決方法進行了一層封裝:
btnFixed.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
ToastUtil.showToast(MainActivity.this,"I am fixed Toast");
try {
Thread.sleep(10000);
} catch (InterruptedException e){
e.printStackTrace();
}
}
});
當我們點選第二個Button十秒後,Demo APP正常執行,而且捕獲住了異常:
四、ToastUtil
本文所提交的解決方法已封裝到ToastUtil中並提交至GitHub上,具體ToastUtil的實現以及Demo可以參見GitHubhttps://github.com/oukanggui/ToastUtil
五、感謝
感謝QQ音樂技術團隊系列文章的分析,對我有了很大的幫助,對Toast處理有興趣的同學可以閱讀如下兩篇QQ音樂技術團隊對Toast分析的文章 [Android] Toast問題深度剖析(一) [Android] Toast問題深度剖析(二)