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

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

作者: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.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 } }

我們通過代碼可以看出,當 Toastshow 的時候,將這個請求放在 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 函數將調用 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.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 的時序控制。因此,我們需要穿梭一下時空,回到 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 時間減少,導致進程內的指令並不能被及時執行,這樣一樣會導致進程看起來」卡頓」的現象

相關閱讀

一種Android App在Native層動態載入so庫的方案

Android OpenGL開發實踐 - GLSurfaceView對攝像頭數據的再處理

通過JS庫Encog實現JavaScript機器學習和神經學網路


此文已由作者授權騰訊雲+技術社區發布,轉載請註明文章出處


推薦閱讀:

TAG:Android开发 | API | toast |