1. 程式人生 > >ToastUtil:修復Android 7.x裝置Toast顯示時丟擲的WindowManager$BadTokenException Token失效異常

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問題深度剖析(二)