题记
Toast
作为 Android
系统中最常用的类之一,由于其方便的api设计和简洁的交互体验,被我们所广泛采用。但是,伴随着我们开发的深入,Toast
的问题也逐渐暴露出来。本文章就将解释 Toast
这些问题产生的具体原因。 本系列文章将分成两篇:
- 第一篇,我们将分析
Toast
所带来的问题 - 第二篇,将提供解决
Toast
问题的解决方案
(注:本文源码基于Android 7.0)
1. 异常和偶尔不显示的问题
当你在程序中调用了 Toast
的 API
,你可能会在后台看到类似这样的 Toast
执行异常:
1 2 3 4 5 6 |
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
的显示也同样要依赖于一个窗口, 而它被指定的类型是:
1 |
public static final int TYPE_TOAST = FIRST_SYSTEM_WINDOW+5;//系统窗口 |
可以看出, Toast
是一个系统窗口,这就保证了 Toast
可以在 Activity
所在的窗口之上显示,并可以在其他的应用上层显示。那么,这就有一个疑问:
“如果是系统窗口,那么,普通的应用进程为什么会有权限去生成这么一个窗口呢?”
实际上,Android
系统在这里使了一次 “偷天换日” 小计谋。我们先来看下 Toast
从显示到隐藏的整个流程:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
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
系统服务中:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 |
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
。
1 2 3 4 5 6 7 8 9 10 11 12 13 |
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
是什么呢?
1 |
final ITransientNotification callback;//TN的Binder代理对象 |
我们看到 callback
的声明,可以知道它是一个 ITransientNotification
类型的对象,而这个对象实际上就是我们刚才所说的 TN
类型对象的代理对象:
1 2 3 |
private static class TN extends ITransientNotification.Stub { ... } |
那么 callback
对象的show
方法中需要传递的参数 record.token
呢?实际上就是我们刚才所说的NotificationManager
服务所生成的窗口的 token
。 相信大家已经对 Android
的 Binder
机制已经熟门熟路了,当我们调用 TN
代理对象的 show
方法的时候,相当于 RPC
调用了 TN
的 show
方法。来看下 TN
的代码:
1 2 3 4 5 6 7 8 9 10 11 12 |
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)
方法:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
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
方法:
1 2 |
record.callback.show(record.token);//通知进程显示 scheduleTimeoutLocked(record);//超时监听消息 |
而这个方法就是用于管理 Toast
时序:
1 2 3 4 5 6 7 |
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
的内部类:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
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
的显示:
1 2 3 4 5 6 7 8 9 10 |
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
的显示和隐藏的源码流程,那么为什么会出现显示异常呢?我们先来看下这个异常是什么呢?
1 2 3 |
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
方法之前。这就导致了我们上面的异常。我们来写一段代码测试一下:
1 2 3 4 5 6 7 8 |
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秒。当进程异常退出后我们截取他们的日志可以得到:
1 2 3 4 5 6 |
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
发生了变化:
1 2 3 4 5 6 |
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
时间减少,导致进程内的指令并不能被及时执行,这样一样会导致进程看起来”卡顿”的现象