AndroidToast問題深度剖析(一)

騰訊雲加社群發表於2018-02-06

歡迎大家前往雲+社群,獲取更多騰訊海量技術實踐乾貨哦~

作者:QQ音樂技術團隊

題記

Toast 作為 Android 系統中最常用的類之一,由於其方便的api設計和簡潔的互動體驗,被我們所廣泛採用。但是,伴隨著我們開發的深入,Toast 的問題也逐漸暴露出來。本文章就將解釋 Toast 這些問題產生的具體原因。 本系列文章將分成兩篇:

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

(注:本文原始碼基於Android 7.0)

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

當你在程式中呼叫了 ToastAPI,你可能會在後臺看到類似這樣的 Toast 執行異常:

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 所在的視窗之上顯示,並可以在其他的應用上層顯示。那麼,這就有一個疑問:

“如果是系統視窗,那麼,普通的應用程式為什麼會有許可權去生成這麼一個視窗呢?”

實際上,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 系統服務中:

複製程式碼
//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 時間減少,導致程式內的指令並不能被及時執行,這樣一樣會導致程式看起來”卡頓”的現象


相關文章