歡迎大家前往雲+社群,獲取更多騰訊海量技術實踐乾貨哦~
作者:QQ音樂技術團隊
題記
Toast
作為 Android
系統中最常用的類之一,由於其方便的api設計和簡潔的互動體驗,被我們所廣泛採用。但是,伴隨著我們開發的深入,Toast
的問題也逐漸暴露出來。本文章就將解釋 Toast
這些問題產生的具體原因。 本系列文章將分成兩篇:
- 第一篇,我們將分析
Toast
所帶來的問題 - 第二篇,將提供解決
Toast
問題的解決方案
(注:本文原始碼基於Android 7.0)
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.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
}
}複製程式碼
我們通過程式碼可以看出,當 Toast
在 show
的時候,將這個請求放在 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
函式將呼叫 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.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
的時序控制。因此,我們需要穿梭一下時空,回到 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 App在Native層動態載入so庫的方案
Android OpenGL開發實踐 - GLSurfaceView對攝像頭資料的再處理
通過JS庫Encog實現JavaScript機器學習和神經學網路
此文已由作者授權騰訊雲+技術社群釋出,轉載請註明文章出處