在Toast系列(二):Toast基本工作原理(android 7.1变化)中我们说到,Android7.1系统引入了Toast的一个bug——BadTokenException。本篇我们剖析下原因并给出解决方案。
Android7.1开始,系统服务在将Toast请求加入队列时,为其创建一个Token。
@Override
public void enqueueToast(String pkg, ITransientNotification callback, int duration){
...
Binder token = new Binder();
//为该Toast窗口添加Token,笔者注释
mWindowManagerInternal.addWindowToken(token,WindowManager.LayoutParams.TYPE_TOAST);
record = new ToastRecord(callingPid, pkg, callback, duration, token);
//将该Toast显示请求加入队列,笔者注释
mToastQueue.add(record);
...
}
当Toast超时(duration耗尽)或者主动cancel掉时,系统服务先隐藏掉该Toast窗口,然后将请求从队列移除,并将Token设为失效。
void cancelToastLocked(int index) {
//从队列中把本次请求取出,笔者注释
ToastRecord record = mToastQueue.get(index);
...
//调用callback的hide方法隐藏掉窗口,这个callback实际上就是Toast的Tn,笔者注释
record.callback.hide();
...
//将该Toast请求从队列移除,笔者注释
ToastRecord lastToast = mToastQueue.remove(index);
//将该Toast窗口的Token设为无效,笔者注释
mWindowManagerInternal.removeWindowToken(lastToast.token, true);
...
//如果队列不为空,则说明还有Toast要显示,则继续显示下一个Toast,笔者注释
if (mToastQueue.size() > 0) {
showNextToastLocked();
}
}
Toast的工作流程是一个基于Binder的IPC(进程)过程,应用程序作为客户端仅仅发起Toast请求和被动接受回调。系统服务负责管理请求队列、Token等,并通过Toast的内部类Tn来实现与应用程序的交互,即通过回调Tn的show/hide方法来显示/隐藏Toast窗口。
应用程序的主线程是一个死循环,不断地从消息队列里取出消息执行。系统服务回调Tn的show方法,实际执行逻辑是发送一个SHOW消息给Tn的Handler,并最终在这个Handler的handleShow方法里执行显示逻辑。
Tn的show方法:
@Override
public void show(IBinder windowToken) {
...
mHandler.obtainMessage(0, windowToken).sendToTarget();
}
Handler的handleShow方法:
public void handleShow(IBinder windowToken) {
...
//将系统服务创建的Token传递进来,笔者注释
mParams.token = windowToken;
...
//添加Toast窗口,此时Token已然失效,引发BadTokenExceptin,笔者注释
mWM.addView(mView, mParams);
...
}
如果系统服务已然调用了Tn的show方法,而恰在此时,应用程序主线程因为某个消息阻塞或者其他原因,迟迟没能执行handleShow方法。直到Toast超时,系统服务将该Toast请求从队列移除,并将Token设为失效。在这之后,应用程序才执行到handleShow方法,而Token已然失效,添加Toast窗口必然发生BadTokenException。
Google在Android8.0修复了这个bug。
首先,直接在产生BadTokenException的地方捕获该异常。
public void handleShow(IBinder windowToken) {
...
//捕获该异常,笔者注释
try {
mWM.addView(mView, mParams);
...
} catch (WindowManager.BadTokenException e) {
/* ignore */
}
}
}
另外,在Tn的handler处理SHOW消息之前,判断其消息队列里是否存在HIDE或者CANCEL消息,有则表示Token已然失效,直接返回,什么都不需要做。
public void handleShow(IBinder windowToken) {
...
//判断是否有超时隐藏或者主动cancel的消息,有则表示系统服务已经将其从
//显示队列移除,并将Token设为失效,笔者注释
if (mHandler.hasMessages(CANCEL) || mHandler.hasMessages(HIDE)) {
return;
}
...
try {
mWM.addView(mView, mParams);
...
} catch (WindowManager.BadTokenException e) {
/* ignore */
}
}
但是,对于已经发布了的Android7.1,我们如何解决这个问题呢?我们可以仿照8.0的补救方法,直接添加try-catch块,捕获它。
注意,不能想当然的像下面这样捕获。
try {
toast.show();
}catch (WindowManager.BadTokenException e){
}
Toast的show方法仅仅是创建Tn提交给系统服务,请求显示Toast而已。前面说过,BadTokenException发生在handleShow方法内,而handleShow方法是在Tn的Handler的handleMessage方法内被调用的。我们可以新建一个Handler,命名为SafeHandler吧,作为Tn原有Handler的外壳。SafeHandler的handleMessage方法直接转交给原有Handler处理,只是在外层套上try-catch块。最后将SafeHandler注入Tn,取代原有Handler。
SafeHandler源码:
class SafeHandler extends Handler {
//用来保存Tn原有handler
private Handler mNestedHandler;
public SafeHandler(Handler nestedHandler) {
//构造方法里将Tn原有Handler传入
mNestedHandler = nestedHandler;
}
/**
* handleMessage是在dispatchMessage里被调用的,所以在这里捕获异常就可以
* @param msg
*/
@Override
public void dispatchMessage(Message msg) {
try {
super.dispatchMessage(msg);
} catch (WindowManager.BadTokenException e) {
}
}
@Override
public void handleMessage(Message msg) {
//交由原有Handler处理
mNestedHandler.handleMessage(msg);
}
通过反射拿到Tn,将SafeHandler注入。
Field tnField = Toast.class.getDeclaredField("mTN");
tnField.setAccessible(true);
mTn = tnField.get(mToast);
if (isSdk25()) {
Field handlerField = mTn.getClass().getDeclaredField("mHandler");
handlerField.setAccessible(true);
Handler handlerOfTn = (Handler) handlerField.get(mTn);
handlerField.set(mTn, new SafeHandler(handlerOfTn));
}
如此,我们完美解决了Android7.1系统关于Toast的BadTokenException。如果你不想自己处理这个问题,我给大家推荐一下我的开源库SmartShow,其中的SmartToast自动帮你解决BadTokenException以及其他常见的Toast性能问题,具体功能请参看github文档:https://github.com/the-pig-of-jungle/SmartShow。