Toast作为Android的提示工具,在日常开发中大量使用。Android系统针对窗口权限一直在优化,在Android 7.1版本出现crash问题。
1. BadTokenException分析
1 | android.view.WindowManager$BadTokenException |
1.1 初步分析
当看到这个问题的时候,首先想到的是Activity销毁后显示Toast导致的Crash,根据这个思路完成了第一次优化,在Toast显示的校验Activity是否销毁,修改完成上线没有解决问题。
1.2 系统源码分析
- 通过上报平台可以发现,只有在Android 7.1版本才会出现这个问题。分析Toast源码发现Android 8.0加上了try catch。
Toast Show调用系统
NotificationManager
服务。1
2
3
4
5
6
7
8public void show() {
...
INotificationManager service = getService();
String pkg = mContext.getOpPackageName();
TN tn = mTN;
tn.mNextView = mNextView;
service.enqueueToast(pkg, tn, mDuration);
}然后最终执行到系统服务
NotificationManagerService
enqueueToast,这里会新建一个token,并将token存放到windowmanager中。1
2
3
4
5
6
7
8public void enqueueToast(String pkg, ITransientNotification callback, int duration){
...
Binder token = new Binder();
mWindowManagerInternal.addWindowToken(token, TYPE_TOAST, DEFAULT_DISPLAY);
record = new ToastRecord(callingPid, pkg, callback, duration, token);
mToastQueue.add(record);
showNextToastLocked();
}Toast加入到队列中后,然后将被回调到app内,将Toast添加到window上面
1
2
3
4
5void showNextToastLocked() {
ToastRecord record = mToastQueue.get(0);
record.callback.show(record.token);
scheduleDurationReachedLocked(record);
}最终回调到app内Toast$TN show,Toast$TN是一个binder。然后会触发
handleShow
方法(最上面的图里),将toast view添加到window上面最核心的地方在
scheduleDurationReachedLocked
方法,这里会延迟校验Toast是否显示超时,Toast.LENGTH_LONG的超时时间是3500ms,Toast.LENGTH_SHORT的超时时间是2000ms。1
2
3
4
5
6
7private void scheduleDurationReachedLocked(ToastRecord r)
{
mHandler.removeCallbacksAndMessages(r);
Message m = Message.obtain(mHandler, MESSAGE_DURATION_REACHED, r);
long delay = r.duration == Toast.LENGTH_LONG ? LONG_DELAY : SHORT_DELAY;
mHandler.sendMessageDelayed(m, delay);
}超时后就会将之前的token从windowmanager中删除
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15void cancelToastLocked(int index) {
ToastRecord record = mToastQueue.get(index);
ToastRecord lastToast = mToastQueue.remove(index);
mWindowManagerInternal.removeWindowToken(lastToast.token, false /* removeWindows */,
DEFAULT_DISPLAY);
// We passed 'false' for 'removeWindows' so that the client has time to stop
// rendering (as hide above is a one-way message), otherwise we could crash
// a client which was actively using a surface made from the token. However
// we need to schedule a timeout to make sure the token is eventually killed
// one way or another.
scheduleKillTokenTimeout(lastToast.token);
}如果toast token已经被remove,这个时候再将toast view添加到widow中就会出现这个crash。下面是可以复现的代码
1
2
3
4
5Toast.makeText(getApplicationContext(), "测试", Toast.LENGTH_SHORT).show();
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
}
2 解决方案
方案一:
通过阅读源码,找到了问题的原因后,可以参照Android 8.0的源码修复这个问题。 发现可以Hook Toast$TN Handler 的执行方法,加上try catch修复。
1 | class SafelyHandlerWrapper extends Handler { |
实施
确定修复方案后,我们着手实施方案,在创建Toast的时候,通过反射将Toast$TN中的handler替换。
- 手动将项目里面所有的代码都更改一遍。
- 无法更改aar里面的代码
- 工作量巨大。
- 通过gradle插件编译阶段,自动更改。
方案二
通过源码发现WindowManager的获取是 application.getSystemService, 通过重写application的方法使用自定义WindowManager来处理这个问题。
1 | if (Build.VERSION.SDK_INT == Build.VERSION_CODES.N_MR1 && obj instanceof WindowManager) { |
. 当通知栏权限被禁掉Toast无法弹出
发现在Android 6版本及以上,通知栏权限被禁止了Toast无法弹出,这个逻辑我真不值得google是怎么想的,那我们App中的提示该怎么办呢?
可参考Toast源码,自定义一个提示,通过window manger addview显示, 首先Params.type=Toast类型,发现通知权限被禁掉以后,使用Toast类型会出异常。发现Params.type不设置的时候,可以弹出自定义View。 后面有部分用户反馈Toast还是无法弹出,检查发现部分机型Context不是Activity的时候,不设置Params.type是无法显示的。
1 | private void addWindowToast(Context context, Toast toast) { |
继续思考解决方法,主动初始化调用application.registerActivityLifecycleCallbacks,缓存Activity, 使用顶部Activity弹出自定义Toast,在Activity销毁移除Toast,通过这个方案可以完美解决上述问题。
解决这个问题后还有问题,当App没有Activity的时候,在Service中还是存在无法弹出问题,所以可以添加悬浮窗权限,在通知权限禁掉的时候主动申请悬浮窗权限,使用Params.type = WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY来解决这个问题, 但是用户有的不一定同意悬浮窗权限,所以还是存在可能Toast无法弹出的问题。
最终也只做到这一步,只是尽量做的更好,没有完美的解决这个问题。