AndroidToast問題深度剖析(一)

歡迎你們前往雲+社區,獲取更多騰訊海量技術實踐乾貨哦~java

做者:QQ音樂技術團隊

題記

Toast 做爲 Android 系統中最經常使用的類之一,因爲其方便的api設計和簡潔的交互體驗,被咱們所普遍採用。可是,伴隨着咱們開發的深刻,Toast 的問題也逐漸暴露出來。本文章就將解釋 Toast 這些問題產生的具體緣由。 本系列文章將分紅兩篇:android

  • 第一篇,咱們將分析 Toast 所帶來的問題
  • 第二篇,將提供解決 Toast 問題的解決方案

(注:本文源碼基於Android 7.0)api

1. 異常和偶爾不顯示的問題

當你在程序中調用了 ToastAPI,你可能會在後臺看到相似這樣的 Toast 執行異常:bash

android.view.WindowManager$BadTokenException
    Unable to add window -- token android.os.BinderProxy@7f652b2 is not valid; is your activity running?
    android.view.ViewRootImpl.setView(ViewRootImpl.java:826)
    android.view.WindowManagerGlobal.addView(WindowManagerGlobal.java:369)
    android.view.WindowManagerImpl.addView(WindowManagerImpl.java:94)
    android.widget.Toast$TN.handleShow(Toast.java:459)複製代碼

另外,在某些系統上,你沒有看到什麼異常,卻會出現 Toast 沒法正常展現的問題。爲了解釋上面這些問題產生的緣由,咱們須要先讀一遍 Toast 的源碼。網絡

2. Toast 的顯示和隱藏

首先,全部 Android 進程的視圖顯示都須要依賴於一個窗口。而這個窗口對象,被記錄在了咱們的 WindowManagerService(後面簡稱 WMS) 核心服務中。WMS 是專門用來管理應用窗口的核心服務。當 Android 進程須要構建一個窗口的時候,必須指定這個窗口的類型。 Toast 的顯示也一樣要依賴於一個窗口, 而它被指定的類型是:機器學習

public static final int TYPE_TOAST = FIRST_SYSTEM_WINDOW+5;//系統窗口複製代碼

能夠看出, Toast 是一個系統窗口,這就保證了 Toast 能夠在 Activity 所在的窗口之上顯示,並能夠在其餘的應用上層顯示。那麼,這就有一個疑問:ide

「若是是系統窗口,那麼,普通的應用進程爲何會有權限去生成這麼一個窗口呢?」函數

實際上,Android 系統在這裏使了一次 「偷天換日」 小計謀。咱們先來看下 Toast 從顯示到隱藏的整個流程:源碼分析

複製代碼
// code Toast.java
public void show() {
        if (mNextView == null) {
            throw new RuntimeException("setView must have been called");
        }

        INotificationManager service = getService();//調用系統的notification服務
        String pkg = mContext.getOpPackageName();
        TN tn = mTN;//本地binder
        tn.mNextView = mNextView;
        try {
            service.enqueueToast(pkg, tn, mDuration);
        } catch (RemoteException e) {
            // Empty
        }
    }複製代碼
複製代碼

咱們經過代碼能夠看出,當 Toastshow 的時候,將這個請求放在 NotificationManager 所管理的隊列中,而且爲了保證 NotificationManager 能跟進程交互, 會傳遞一個 TN 類型的 Binder 對象給 NotificationManager 系統服務。而在 NotificationManager 系統服務中:post

複製代碼
//code NotificationManagerService
public void enqueueToast(...) {
    ....
    synchronized (mToastQueue) {
                    ...
                    {
                        // Limit the number of toasts that any given package except the android
                        // package can enqueue.  Prevents DOS attacks and deals with leaks.
                        if (!isSystemToast) {
                            int count = 0;
                            final int N = mToastQueue.size();
                            for (int i=0; i<N; i++) {
                                 final ToastRecord r = mToastQueue.get(i);
                                 if (r.pkg.equals(pkg)) {
                                     count++;
                                     if (count >= MAX_PACKAGE_NOTIFICATIONS) {
                                         //上限判斷
                                         return;
                                     }
                                 }
                            }
                        }

                        Binder token = new Binder();
                        mWindowManagerInternal.addWindowToken(token,
                                WindowManager.LayoutParams.TYPE_TOAST);//生成一個Toast窗口
                        record = new ToastRecord(callingPid, pkg, callback, duration, token);
                        mToastQueue.add(record);
                        index = mToastQueue.size() - 1;
                        keepProcessAliveIfNeededLocked(callingPid);
                    }
                    ....
                     if (index == 0) {
                        showNextToastLocked();//若是當前沒有toast,顯示當前toast
                    }
                } finally {
                    Binder.restoreCallingIdentity(callingId);
                }
            }
}複製代碼
複製代碼

(不去深究其餘代碼的細節,有興趣能夠自行研究,挑出咱們所關心的Toast顯示相關的部分)

咱們會獲得如下的流程(在 NotificationManager系統服務所在的進程中):

  • 判斷當前的進程所彈出的 Toast 數量是否已經超過上限 MAX_PACKAGE_NOTIFICATIONS ,若是超過,直接返回
  • 生成一個 TOAST 類型的系統窗口,而且添加到 WMS 管理
  • 將該 Toast 請求記錄成爲一個 ToastRecord 對象

代碼到這裏,咱們已經看出 Toast 是如何偷天換日的。實際上,這個所須要的這個系統窗口 token ,是由咱們的 NotificationManager 系統服務所生成,因爲系統服務具備高權限,固然不會有權限問題。不過,咱們又會有第二個問題:

既然已經生成了這個窗口的 Token 對象,又是如何傳遞給 Android進程並通知進程顯示界面的呢?

咱們知道, Toast 不只有窗口,也有時序。有了時序,咱們就可讓 Toast 按照咱們調用的次序顯示出來。而這個時序的控制,天然而然也是落在咱們的NotificationManager 服務身上。咱們經過上面的代碼能夠看出,當系統並無 Toast 的時候,將經過調用 showNextToastLocked(); 函數來顯示下一個Toast

複製代碼
void showNextToastLocked() {
        ToastRecord record = mToastQueue.get(0);
        while (record != null) {
            ...
            try {
                record.callback.show(record.token);//通知進程顯示
                scheduleTimeoutLocked(record);//超時監聽消息
                return;
            } catch (RemoteException e) {
                ...
            }
        }
    }複製代碼
複製代碼

這裏,showNextToastLocked 函數將調用 ToastRecordcallback 成員的 show 方法通知進程顯示,那麼 callback 是什麼呢?

final ITransientNotification callback;//TN的Binder代理對象複製代碼

咱們看到 callback 的聲明,能夠知道它是一個 ITransientNotification 類型的對象,而這個對象實際上就是咱們剛纔所說的 TN 類型對象的代理對象:

private static class TN extends ITransientNotification.Stub {
    ...
}複製代碼

那麼 callback對象的show方法中須要傳遞的參數 record.token呢?實際上就是咱們剛纔所說的NotificationManager服務所生成的窗口的 token。 相信你們已經對 AndroidBinder 機制已經熟門熟路了,當咱們調用 TN 代理對象的 show 方法的時候,至關於 RPC 調用了 TNshow 方法。來看下 TN 的代碼:

複製代碼
// code TN.java
final Handler mHandler = new Handler() {
            @Override
            public void handleMessage(Message msg) {
                IBinder token = (IBinder) msg.obj;
                handleShow(token);//處理界面顯示
            }
        };
@Override
        public void show(IBinder windowToken) {
            if (localLOGV) Log.v(TAG, "SHOW: " + this);
            mHandler.obtainMessage(0, windowToken).sendToTarget();
        }複製代碼
複製代碼

這時候 TN 收到了 show 方法通知,將經過 mHandler 對象去 post 出一條命令爲 0 的消息。實際上,就是一條顯示窗口的消息。最終,將會調用handleShow(Binder) 方法:

複製代碼
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);
                ...
            }
        }複製代碼
複製代碼

而這個顯示窗口的方法很是簡單,就是將所傳遞過來的窗口 token 賦值給窗口屬性對象 mParams, 而後經過調用 WindowManager.addView 方法,將 Toast 中的mView 對象歸入 WMS 的管理。

上面咱們解釋了 NotificationManager 服務是如何將窗口 token 傳遞給 Android 進程,而且 Android 進程是如何顯示的。咱們剛纔也說到,NotificationManager 不只掌管着 Toast 的生成,也管理着 Toast 的時序控制。所以,咱們須要穿梭一下時空,回到 NotificationManagershowNextToastLocked() 方法。你們能夠看到:在調用 callback.show 方法以後又調用了個 scheduleTimeoutLocked 方法:

record.callback.show(record.token);//通知進程顯示
scheduleTimeoutLocked(record);//超時監聽消息複製代碼

而這個方法就是用於管理 Toast 時序:

複製代碼
private void scheduleTimeoutLocked(ToastRecord r)
    {
        mHandler.removeCallbacksAndMessages(r);
        Message m = Message.obtain(mHandler, MESSAGE_TIMEOUT, r);
        long delay = r.duration == Toast.LENGTH_LONG ? LONG_DELAY : SHORT_DELAY;
        mHandler.sendMessageDelayed(m, delay);
    }複製代碼
複製代碼

scheduleTimeoutLocked 內部經過調用 HandlersendMessageDelayed 函數來實現定時調用,而這個 mHandler 對象的實現類,是一個叫作 WorkerHandler 的內部類:

複製代碼
private final class WorkerHandler extends Handler
    {
        @Override
        public void handleMessage(Message msg)
        {
            switch (msg.what)
            {
                case MESSAGE_TIMEOUT:
                    handleTimeout((ToastRecord)msg.obj);
                    break;
                ....
            }
    } 
    private void handleTimeout(ToastRecord record)
    {
        synchronized (mToastQueue) {
            int index = indexOfToastLocked(record.pkg, record.callback);
            if (index >= 0) {
                cancelToastLocked(index);
            }
        }
    }複製代碼
複製代碼

WorkerHandler 處理 MESSAGE_TIMEOUT 消息會調用 handleTimeout(ToastRecord) 函數,而 handleTimeout(ToastRecord) 函數通過搜索後,將調用cancelToastLocked 函數取消掉 Toast 的顯示:

複製代碼
void cancelToastLocked(int index) {
        ToastRecord record = mToastQueue.get(index);
            ....
            record.callback.hide();//遠程調用hide,通知客戶端隱藏窗口
            ....

        ToastRecord lastToast = mToastQueue.remove(index);
        mWindowManagerInternal.removeWindowToken(lastToast.token, true);
        //將給 Toast 生成的窗口 Token 從 WMS 服務中刪除
        ...複製代碼
複製代碼

cancelToastLocked 函數將作如下兩件事:

  1. 遠程調用 ITransientNotification.hide 方法,通知客戶端隱藏窗口
  2. 將給 Toast 生成的窗口 TokenWMS 服務中刪除

上面咱們就從源碼的角度分析了一個Toast的顯示和隱藏,咱們不妨再來捋一下思路,Toast 的顯示和隱藏大體分紅如下核心步驟:

  1. Toast 調用 show 方法的時候 ,其實是將本身歸入到 NotificationManagerToast 管理中去,期間傳遞了一個本地的 TN 類型或者是ITransientNotification.StubBinder 對象
  2. NotificationManager 收到 Toast 的顯示請求後,將生成一個 Binder 對象,將它做爲一個窗口的 token 添加到 WMS 對象,而且類型是 TOAST
  3. NotificationManager 將這個窗口 token 經過 ITransientNotificationshow 方法傳遞給遠程的 TN 對象,而且拋出一個超時監聽消息scheduleTimeoutLocked
  4. TN 對象收到消息之後將往 Handler 對象中 post 顯示消息,而後調用顯示處理函數將 Toast 中的 View 添加到了 WMS 管理中, Toast 窗口顯示
  5. NotificationManagerWorkerHandler 收到 MESSAGE_TIMEOUT 消息, NotificationManager 遠程調用進程隱藏 Toast 窗口,而後將窗口 tokenWMS中刪除

3. 異常產生的緣由

上面咱們分析了 Toast 的顯示和隱藏的源碼流程,那麼爲何會出現顯示異常呢?咱們先來看下這個異常是什麼呢?

Unable to add window -- token android.os.BinderProxy@7f652b2 is not valid; is your activity running?
    android.view.ViewRootImpl.setView(ViewRootImpl.java:826)
    android.view.WindowManagerGlobal.addView(WindowManagerGlobal.java:369)複製代碼

首先,這個異常發生在 Toast 顯示的時候,緣由是由於 token 失效。那麼 token 爲何會失效呢?咱們來看下下面的圖:

一般狀況下,按照正常的流程,是不會出現這種異常。可是因爲在某些狀況下, Android 進程某個 UI 線程的某個消息阻塞。致使 TNshow 方法 post 出來 0 (顯示) 消息位於該消息以後,遲遲沒有執行。這時候,NotificationManager 的超時檢測結束,刪除了 WMS 服務中的 token 記錄。也就是如圖所示,刪除token 發生在 Android 進程 show 方法以前。這就致使了咱們上面的異常。咱們來寫一段代碼測試一下:

複製代碼
public void click(View view) {
        Toast.makeText(this,"test",Toast.LENGTH_SHORT).show();
        try {
            Thread.sleep(10000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
}複製代碼
複製代碼

咱們先調用 Toast.show 方法,而後在該 ui 線程消息中 sleep 10秒。當進程異常退出後咱們截取他們的日誌能夠獲得:

複製代碼
12-28 11:10:30.086 24599 24599 E AndroidRuntime: android.view.WindowManager$BadTokenException: Unable to add window -- token android.os.BinderProxy@2e5da2c is not valid; is your activity running?
12-28 11:10:30.086 24599 24599 E AndroidRuntime:     at android.view.ViewRootImpl.setView(ViewRootImpl.java:679)
12-28 11:10:30.086 24599 24599 E AndroidRuntime:     at android.view.WindowManagerGlobal.addView(WindowManagerGlobal.java:342)
12-28 11:10:30.086 24599 24599 E AndroidRuntime:     at android.view.WindowManagerImpl.addView(WindowManagerImpl.java:93)
12-28 11:10:30.086 24599 24599 E AndroidRuntime:     at android.widget.Toast$TN.handleShow(Toast.java:434)
12-28 11:10:30.086 24599 24599 E AndroidRuntime:     at android.widget.Toast$TN$2.handleMessage(Toast.java:345)複製代碼
複製代碼

果真如咱們所料,咱們復現了這個問題的堆棧。那麼或許你會有下面幾個疑問:

Toast.show 方法外增長 try-catch 有用麼?

固然沒用,按照咱們的源碼分析,異常是發生在咱們的下一個 UI 線程消息中,所以咱們在上一個 ui 線程消息中加入 try-catch 是沒有意義的

爲何有些系統中沒有這個異常,可是有時候 toast不顯示?

咱們上面分析的是7.0的代碼,而在8.0的代碼中,Toast 中的 handleShow發生了變化:

複製代碼
//code handleShow() android 8.0
                try {
                    mWM.addView(mView, mParams);
                    trySendAccessibilityEvent();
                } catch (WindowManager.BadTokenException e) {
                    /* ignore */
                }複製代碼
複製代碼

8.0 的代碼中,對 mWM.addView 進行了 try-catch 包裝,所以並不會拋出異常,但因爲執行失敗,所以不會顯示 Toast

有哪些緣由引發的這個問題?

  1. 引發這個問題的也不必定是卡頓,當你的 TN 拋出消息的時候,前面有大量的 UI 線程消息等待執行,而每一個 UI 線程消息雖然並不卡頓,可是總和若是超過了 NotificationManager 的超時時間,仍是會出現問題
  2. UI 線程執行了一條很是耗時的操做,好比加載圖片,大量浮點運算等等,好比咱們上面用 sleep 模擬的就是這種狀況
  3. 在某些狀況下,進程退後臺或者息屏了,系統爲了減小電量或者某種緣由,分配給進程的 cpu 時間減小,致使進程內的指令並不能被及時執行,這樣同樣會致使進程看起來」卡頓」的現象
相關文章
相關標籤/搜索