0%

Toast BadToken和权限问题

Toast作为Android的提示工具,在日常开发中大量使用。Android系统针对窗口权限一直在优化,在Android 7.1版本出现crash问题。

1. BadTokenException分析

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

1.1 初步分析

当看到这个问题的时候,首先想到的是Activity销毁后显示Toast导致的Crash,根据这个思路完成了第一次优化,在Toast显示的校验Activity是否销毁,修改完成上线没有解决问题。

1.2 系统源码分析

  • 通过上报平台可以发现,只有在Android 7.1版本才会出现这个问题。分析Toast源码发现Android 8.0加上了try catch。

handleShow25vs26

  • Toast Show调用系统NotificationManager服务。

    1
    2
    3
    4
    5
    6
    7
    8
     public 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
    8
     public 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
    5
    void 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
    7
     private 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
    15
    void 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
    5
    Toast.makeText(getApplicationContext(), "测试", Toast.LENGTH_SHORT).show();
    try {
    Thread.sleep(3000);
    } catch (InterruptedException e) {
    }

2 解决方案

方案一:

通过阅读源码,找到了问题的原因后,可以参照Android 8.0的源码修复这个问题。 发现可以Hook Toast$TN Handler 的执行方法,加上try catch修复。

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) {
;
}
}
}

实施

确定修复方案后,我们着手实施方案,在创建Toast的时候,通过反射将Toast$TN中的handler替换。

  1. 手动将项目里面所有的代码都更改一遍。
    • 无法更改aar里面的代码
    • 工作量巨大。
  2. 通过gradle插件编译阶段,自动更改。

方案二

通过源码发现WindowManager的获取是 application.getSystemService, 通过重写application的方法使用自定义WindowManager来处理这个问题。

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
57
58
if (Build.VERSION.SDK_INT == Build.VERSION_CODES.N_MR1 && obj instanceof WindowManager) {

boolean isToast = false;
StackTraceElement[] stackTraceElements = Thread.currentThread().getStackTrace();
for (StackTraceElement stackTraceElement: stackTraceElements) {
if ("android.widget.Toast$TN".equals(stackTraceElement.getClassName())) {
isToast = true;
LtLogUtils.Logd("TOAST", "isToast true");
}
}

if (!isToast) return obj;

if (mProxyWindowManager == null) {
WindowManager windowManager = (WindowManager) obj;
mProxyWindowManager = new WindowManager() {
@Override
public Display getDefaultDisplay() {
return windowManager.getDefaultDisplay();
}

@Override
public void removeViewImmediate(View view) {
windowManager.removeViewImmediate(view);
}

@Override
public void addView(View view, ViewGroup.LayoutParams params) {
if (params instanceof WindowManager.LayoutParams) {
WindowManager.LayoutParams wlp = (LayoutParams) params;
if (wlp.type == WindowManager.LayoutParams.TYPE_TOAST
&& wlp.getTitle() != null && "Toast".equals(wlp.getTitle().toString())) {
try {
windowManager.addView(view, params);
} catch (Exception e) {

}
} else {
windowManager.addView(view, params);
}
} else {
windowManager.addView(view, params);
}
}

@Override
public void updateViewLayout(View view, ViewGroup.LayoutParams params) {
windowManager.updateViewLayout(view, params);
}

@Override
public void removeView(View view) {
windowManager.removeView(view);
}
};
}
return mProxyWindowManager;
}

. 当通知栏权限被禁掉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