Toast的一些坑

Toast是Android系统中最常用的提示,但是在开发中碰到过几次棘手的问题。

1. 莫名其妙的闪退

1
2
android.view.WindowManager$BadTokenException
is your activity running?

在Android 7版本都有这个问题,当我看到这个问题的时候,我想好解决,是不是Activity销毁了导致的,所以在Toast show的时候判断Activity是否销毁,发现线上还会有闪退,too young!! 然后继续想办法,将Application当做Context 传入,想着应该解决了吧,然而发现问题继续存在。

只能查看源码了,为什么在Android 7版本上有问题,对比了不同版本的源码发现Android 8版本上加上了try catch判断,那怎么解决这个问题呢?

handleShow25vs26

方案一:

hook context getWindowManager

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
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
final class SafeToastContext extends ContextWrapper {

private @NonNull Toast toast;

SafeToastContext(@NonNull Context base, @NonNull Toast toast) {
super(base);
this.toast = toast;
}


@Override
public Context getApplicationContext() {
return new ApplicationContextWrapper(getBaseContext().getApplicationContext());
}

private final class ApplicationContextWrapper extends ContextWrapper {

private ApplicationContextWrapper(@NonNull Context base) {
super(base);
}


@Override
public Object getSystemService(@NonNull String name) {
if (Context.WINDOW_SERVICE.equals(name)) {
// noinspection ConstantConditions
return new WindowManagerWrapper((WindowManager) getBaseContext().getSystemService(name));
}
return super.getSystemService(name);
}
}


private final class WindowManagerWrapper implements WindowManager {

private static final String TAG = "WindowManagerWrapper";
private final @NonNull WindowManager base;


private WindowManagerWrapper(@NonNull WindowManager base) {
this.base = base;
}

@Override
public void addView(View view, ViewGroup.LayoutParams params) {
try {
Log.d(TAG, "WindowManager's addView(view, params) has been hooked.");
base.addView(view, params);
} catch (BadTokenException e) {
Log.i(TAG, e.getMessage());
} catch (Throwable throwable) {
Log.e(TAG, "[addView]", throwable);
}
}
}
}

改完后发布线上,发现完美,没有闪退。可以开心睡觉了! 高兴的太早,线上用户反馈部分机型Toast无法弹出,最终发现在一些机型上的确无法弹出,部分Rom修改了一些逻辑,加入了Context的一些判断逻辑。

继续战斗,将只有Android 7上才Hook Context,减少影响范围,线上用户并没有反馈Android 7 Toast弹不出来的问题,问题终于解决。但作为一个完美的程序员,考虑虽然减少范围只hook Android 7 ,万一部分Android 7机型弹不出Toast怎么办。

方案二:

继续阅读源码,找找可以解决问题的方法,发现可以hook toast Handler handlerShow方法,这样就不会影响Context,最终测试,发现完美,可以加个鸡腿了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class SafelyHandlerWrapper extends Handler {
private Handler impl;

public SafelyHandlerWrapper(Handler impl) {
this.impl = impl;
}

public void dispatchMessage(Message msg) {
try {
this.impl.dispatchMessage(msg);
} catch (Exception var3) {
;
}
}

public void handleMessage(Message msg) {
try {
this.impl.handleMessage(msg);
} catch (Exception var3) {
;
}
}
}

虽然解决了这个问题,但是具体什么导致的一直没找到原因,也一直未能复现,后来参考一些资料解释:

这个问题由于targetSDKVersion升到26之后,在7.1.1机型上概率性出现。稳定复现的步骤是,在Toast.show()之后,UI线程做了耗时的操作阻塞了Handler message的处理,如使用Thread.sleep(5000),然后这个崩溃就出现了。原因是7.1.1系统对TYPE_TOAST的Window类型做了超时限制,绑定了Window Token,最长超时时间是3.5s,如果UI在这段时间内没有执行完,Toast.show()内部的handler message得不到执行,NotificationManageService那端会把这个Toast取消掉,同时把Toast对于的window token置为无效。等App端真正需要显示Toast时,因为window token已经失效,ViewRootImpl就抛出了上面的异常。

2. 当通知栏权限被禁掉Toast无法弹出

发现在Android 6版本及以上,通知栏权限被禁止了Toast无法弹出,这个逻辑我真不值得google是怎么想的,那我们App中的提示该怎么办呢?

可参考Toast源码,自定义一个提示,通过window manger addview显示, 首先Params.type=Toast类型,发现通知权限被禁掉以后,使用Toast类型会出异常。发现Params.type不设置的时候,可以弹出自定义View。 后面有部分用户反馈Toast还是无法弹出,检查发现部分机型Context不是Activity的时候,不设置Params.type是无法显示的。

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
40
41
42
43
44
45
46
47
48
49
50
51
52
53
    private void addWindowToast(Context context, Toast toast) {
// We can resolve the Gravity here by using the Locale for getting
// the layout direction
WindowManager.LayoutParams mParams = new WindowManager.LayoutParams();

mParams.height = WindowManager.LayoutParams.WRAP_CONTENT;
mParams.width = WindowManager.LayoutParams.WRAP_CONTENT;
mParams.format = PixelFormat.TRANSLUCENT;
mParams.windowAnimations = R.style.ToastAnimation;

// 为什么不能加 TYPE_TOAST,因为通知权限在关闭后设置显示的类型为Toast会报错
// android.view.WindowManager$BadTokenException: Unable to add window -- token null is not valid; is your activity running?
// mParams.type = WindowManager.LayoutParams.TYPE_TOAST;

// 判断是否为 Android 6.0 及以上系统并且有悬浮窗权限
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && Settings.canDrawOverlays(context)) {
mParams.type = WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY;
} else if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && Settings.canDrawOverlays(context)){
mParams.type = WindowManager.LayoutParams.TYPE_PHONE;
}

mParams.setTitle("Toast");
mParams.flags = WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON
| WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE
| WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE;


final int gravity = toast.getGravity();
mParams.gravity = gravity;
if ((gravity & Gravity.HORIZONTAL_GRAVITY_MASK) == Gravity.FILL_HORIZONTAL) {
mParams.horizontalWeight = 1.0f;
}
if ((gravity & Gravity.VERTICAL_GRAVITY_MASK) == Gravity.FILL_VERTICAL) {
mParams.verticalWeight = 1.0f;
}
mParams.x = toast.getXOffset();
mParams.y = toast.getYOffset();
mParams.verticalMargin = toast.getVerticalMargin();
mParams.horizontalMargin = toast.getHorizontalMargin();
mParams.packageName = context.getPackageName();
try {
mWM.addView(toast.getView(), mParams);
} catch (WindowManager.BadTokenException e) {
/* ignore */
Log.d(TAG, "addWindowToast: " + e.getMessage());
} catch (Exception e1) {
//ignore
Log.d(TAG, "addWindowToast: " + e1.getMessage());
} catch (Error error) {
//ignore
}
mHandler.postDelayed(this, toast.getDuration() == Toast.LENGTH_LONG ? 4000 : 2000);
}

继续思考解决方法,主动初始化调用application.registerActivityLifecycleCallbacks,缓存Activity, 使用顶部Activity弹出自定义Toast,在Activity销毁移除Toast,通过这个方案可以完美解决上述问题。

解决这个问题后还有问题,当App没有Activity的时候,在Service中还是存在无法弹出问题,所以可以添加悬浮窗权限,在通知权限禁掉的时候主动申请悬浮窗权限,使用Params.type = WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY来解决这个问题, 但是用户有的不一定同意悬浮窗权限,所以还是存在可能Toast无法弹出的问题。

最终也只做到这一步,只是尽量做的更好,没有完美的解决这个问题。

Github