[Android] Toast問題深度剖析(一)

2021-03-02 騰訊音樂技術團隊
題記

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

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

當你在程序中調用了 Toast 的 API,你可能會在後臺看到類似這樣的 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.javapublic 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        }    }

我們通過代碼可以看出,當 Toast 在 show 的時候,將這個請求放在 NotificationManager 所管理的隊列中,並且為了保證 NotificationManager 能跟進程交互, 會傳遞一個 TN 類型的 Binder 對象給 NotificationManager 系統服務。而在 NotificationManager 系統服務中:

//code NotificationManagerServicepublic 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 函數將調用 ToastRecord的 callback 成員的 show 方法通知進程顯示,那麼 callback 是什麼呢?

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

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

private static class TN extends ITransientNotification.Stub {    ...}

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

// code TN.javafinal 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 的時序控制。因此,我們需要穿梭一下時空,回到 NotificationManager 的 showNextToastLocked() 方法。大家可以看到:在調用 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 內部通過調用 Handler 的 sendMessageDelayed 函數來實現定時調用,而這個 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 函數將做以下兩件事:

遠程調用 ITransientNotification.hide 方法,通知客戶端隱藏窗口

將給 Toast 生成的窗口 Token 從 WMS 服務中刪除

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

Toast 調用 show 方法的時候 ,實際上是將自己納入到 NotificationManager 的 Toast 管理中去,期間傳遞了一個本地的 TN 類型或者是 ITransientNotification.Stub 的 Binder 對象

NotificationManager 收到 Toast 的顯示請求後,將生成一個  Binder 對象,將它作為一個窗口的 token 添加到 WMS 對象,並且類型是 TOAST

NotificationManager 將這個窗口 token 通過 ITransientNotification 的 show 方法傳遞給遠程的 TN 對象,並且拋出一個超時監聽消息 scheduleTimeoutLocked

TN 對象收到消息以後將往 Handler 對象中 post 顯示消息,然後調用顯示處理函數將 Toast 中的 View 添加到了 WMS 管理中, Toast 窗口顯示

NotificationManager 的 WorkerHandler 收到 MESSAGE_TIMEOUT 消息, NotificationManager 遠程調用進程隱藏  Toast 窗口,然後將窗口 token 從 WMS 中刪除

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 線程的某個消息阻塞。導致 TN 的 show 方法 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

有哪些原因引起的這個問題?

引起這個問題的也不一定是卡頓,當你的 TN 拋出消息的時候,前面有大量的 UI 線程消息等待執行,而每個 UI 線程消息雖然並不卡頓,但是總和如果超過了 NotificationManager 的超時時間,還是會出現問題

UI 線程執行了一條非常耗時的操作,比如加載圖片,大量浮點運算等等,比如我們上面用 sleep 模擬的就是這種情況

在某些情況下,進程退後臺或者息屏了,系統為了減少電量或者某種原因,分配給進程的 cpu 時間減少,導致進程內的指令並不能被及時執行,這樣一樣會導致進程看起來」卡頓」的現象

相關焦點

  • Android 用戶界面---廣播通知(Toast Notifications)
    一文讀懂Flink是如何管理好內存的,想知道嗎?>>> <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"               android:
  • Android開發之:Toast和Notification
    package com.a3gs.toast;import android.app.Activity;import android.os.Bundle;import android.view.View;import android.widget.Button;import android.widget.EditText
  • iOS和Android規範解析:提示框(Toast)對比
    然而現在很多應用中,存在對於toast過度使用的情況,並且常常出現Android樣式的toast出現在iOS應用中(反之亦然)的情形。在研究了iOS和Android的規範之後,筆者驚人地發現iOS中其實是沒有toast這種部件的。到底我們在設計的時候應該如何處理這種部件呢?且看下面的分解。
  • android 傳統藍牙開發 (附示例源碼)
    傳統藍牙,自己整理了一下思路,然後寫了一個BluetoothStudy小測試程序,來運用所理解的android傳統藍牙開發技術,寫下一些心得和思路,供其他android熱愛者參考,也方便日後回顧。一、整體思路和對應相關方法1、獲得BluetoothAdapter:BluetoothAdapter.getDefaultAdapter();2、打開藍牙:詢問用戶打開(推薦)或直接bluetoothAdapter.enable
  • Toast
    和外國朋友一起出席聚會時,總會聽到「toast」這個詞,它的意思是「乾杯,敬酒」。我們知道toast有「烤麵包片,吐司麵包」的意思,這和敬酒有什麼關係嗎? 據說從12世紀起,英國人飲酒時喜歡將香噴噴的麵包片(toast)浸到酒碗中,以增加酒的味道。
  • 【補充版】Android開發中,有哪些讓你覺得相見恨晚的方法、類或接口?(一)
    】view.isShown ()的用法)6、Arrays類中的一系列關於數組操作的工具方法:binarySearch(),asList(),equals(),sort(),toString(),copyOfRange()等;Collections類中的一系列關於集合操作的工具方法:sort(),reverse
  • 自定義Toast以及玩轉SnackBar
    前言今天我們來學習下有關Toast以及SnackBar用法,通過細節,讓我們的app更加人性化~本文目標閱讀完本文,你會get如下技能:        下面開始我們第一小節,了解Toast那些不為人知的秘密一、
  • 深度剖析中小學生的粗心問題
    一 中小學生的粗心現象 1,審題過程中,漏讀條件,錯讀字詞,讀偏條件,審錯題意。 2,答題過程中,代錯數據,列錯方程,算錯結果,步驟不全。 3,檢查試卷時,忘寫名字,寫錯考號,塗錯選項,答在問卷。二 粗心的根源深度剖析 1,性格因素使然,平時做事就馬虎大意
  • Toast of the town?
    We know round the dining table that if you propose a toast you mean to offer to have a drink in honor of a person.
  • Android 11來了,快!扶我起來
    添加的方法如下(https://developer.android.google.cn/reference/android/provider/MediaStore):(android.content.ContentResolver, java.util.Collection)用戶向應用授予對指定媒體文件組的寫入訪問權限的請求。
  • android 狀態欄梳理
    "ImageTranslucentTheme" parent="AppTheme">    <item name="android:windowTranslucentStatus">false</item>    <item name="android:windowTranslucentNavigation">true</item>
  • Android新手入門-Android中文SDK
    (Writing your first Android Application, the ever popular Hello World, Android style)剖析Android程序 (Anatomy of an Android Application )結構和Android程序構架說明介紹,通過嚮導將會幫助你理解編寫Android程序。
  • 很少有人會告訴你的 Android 開發基本常識 移動開發
    軟體開發流程一個完整的軟體開發流程離不開策劃、交互、視覺、軟體、測試、維護和運營這七個環節,這七個環節並不是孤立的,它們是開發一款成功產品的前提,但每一項也都可以形成一個學科,是一個獨立的崗位,隨著敏捷開發的流行,以及來到了體驗為王的時代,現代軟體開發更多的是注重效率和敏捷,而不是循規蹈矩的遵循這些開發流程,比如軟體開發的崗位不再僅僅是個技術崗位,它需要去參與前期的設計和評審
  • 每日一詞 | make a toast跟麵包沒有關係?
    toast 作為名詞有吐司,麵包的意思。在這個詞組中,make a toast 不是做麵包的意思。 製作麵包的翻譯是:make bread/toast(沒有冠詞a)Toast作為動詞,表示敬酒,祝福,感謝的意思,用法是:toast sb/sth with sth,但更常見的用法是在短語中make a toast 表示敬酒,發表祝酒詞
  • How to Give a Toast in English.
    You will often find that when you are with a group of people, giving a toast is a very common occurrence.Giving a toast is taking your English to another level.
  • 國家公務員申論題型深度剖析
    【導讀】華圖國家公務員考試網同步寧夏華圖發布:國家公務員申論題型深度剖析,詳細信息請閱讀下文!寧夏公務員考試網(http://nx.huatu.com/)對此做出深度剖析。   第一種分類方法:   看答題是否有嚴格的、固定的標準,申論問題可以分為客觀題、主觀題、客觀與主觀交匯題三種類型。如果有嚴格的、固定的標準就屬於客觀題,如果沒有則屬於主觀題。
  • 【Android基礎學習一】Android 常用 adb 命令總結
    #base=/systemexport CLASSPATH=$base/framework/am.jarexec app_process $base/bin com.android.commands.am.Am "$@"還有 SDK sources/android-20/com/android/commands 目錄下:[xuxu:...oid-20/com/android
  • 使用Kotlin來開發Android,愛上它的優雅
    Kotlin相比Java有很多優勢,首先一點就是你可以用更少的代碼來做更多的事,其次Kotlin居然可以幫我們來避免空指針的問題,這個問題可是很多程序猿在寫代碼時時刻警惕著卻又不能很好避免的問題呢!而且使用集合的時候也很方便,而在安卓開發中,視圖綁定是必須要做的,雖然有黃油刀的輔助可能會省了不少力,但是現在Kotlin已經可以幫你很好的解決這個問題了!
  • android 不同大小的屏幕專題及常見問題 - CSDN
    例如:<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" android:orientation="vertical" android:layout_width="match_parent" android:layout_height="match_parent"&