在Toast与Snackbar的那点事儿这篇文章中,受益良多,翻看了自己项目,发现项目也有重构一份SafeToastManager来统一管理应用Toast的弹出,并且解决方案和美团的这篇文章竟然出奇的一致!由于项目的最高TargetSdk版本还是23,所以暂时还没用到后续提到的SnackBar。
但是在翻看该篇文章的时候,发现作者有一处错误的地方,在作者解释Toast导致BadTokenException的时候,有说明到该奔溃主要集中在Android 5.0 -- Android 7.1.2的机型上,自己有翻看了源码并实践,发现这种说法是有问题的。
经过试验发现,在Sdk25(Android 7.1)版本以下的机型上,是不会产生BadTokenException问题的,只有在Sdk版本为 25 的机型上,才有可能产生异常崩溃,因为在Android O(26)中,Google修复了此问题。
原因分析
Toast导致BadTokenException的原因详见Toast与Snackbar的那点事儿,其根本原因就是主线程Looper阻塞导致的异常,NMS会在固定时间之后移除生成的Token标识,而此时主线程由于阻塞没能及时的执行WindowManager.addView(),导致出现BadTokenException异常。在Android 7.1.1上,WMS执行addWindow()判断代码如下:
public int addWindow(Session session, IWindow client, int seq,
WindowManager.LayoutParams attrs, int viewVisibility, int displayId,
Rect outContentInsets, Rect outStableInsets, Rect outOutsets,
InputChannel outInputChannel) {
..............
if (token == null) {
..............
if (type == TYPE_TOAST) {
// 注 : 此处判断的为 Target Sdk 版本是否大于 25
// Apps targeting SDK above N MR1 cannot arbitrary add toast windows.
if (doesAddToastWindowRequireToken(attrs.packageName, callingUid,
attachedWindow)) {
Slog.w(TAG_WM, "Attempted to add a toast window with unknown token "
+ attrs.token + ". Aborting.");
return WindowManagerGlobal.ADD_BAD_APP_TOKEN;
}
}
}
...............
}
从上述可看出,在TargetSdk为25以上,当需要弹出一个window type为toast类型的弹窗时,首先需要强制判断Token是否存在。那么当一个toast弹出的时候,这个token是什么时候产生的呢。
通过对Toast.show()的代码跟踪如下,最终会调用到NMS去管理Toast弹出,NMS维护一个toastRecord队列,依次弹出toast,代码如下:
public void enqueueToast(String pkg, ITransientNotification callback, int duration)
{
if (DBG) {
Slog.i(TAG, "enqueueToast pkg=" + pkg + " callback=" + callback
+ " duration=" + duration);
}
if (pkg == null || callback == null) {
Slog.e(TAG, "Not doing toast. pkg=" + pkg + " callback=" + callback);
return ;
}
final boolean isSystemToast = isCallerSystem() || ("android".equals(pkg));
final boolean isPackageSuspended =
isPackageSuspendedForUser(pkg, Binder.getCallingUid());
if (ENABLE_BLOCKED_TOASTS && (!noteNotificationOp(pkg, Binder.getCallingUid())
|| isPackageSuspended)) {
if (!isSystemToast) {
Slog.e(TAG, "Suppressing toast from package " + pkg
+ (isPackageSuspended
? " due to package suspended by administrator."
: " by user request."));
return;
}
}
synchronized (mToastQueue) {
int callingPid = Binder.getCallingPid();
long callingId = Binder.clearCallingIdentity();
try {
ToastRecord record;
int index = indexOfToastLocked(pkg, callback);
// If it's already in the queue, we update it in place, we don't
// move it to the end of the queue.
if (index >= 0) {
record = mToastQueue.get(index);
record.update(duration);
} else {
// 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) {
Slog.e(TAG, "Package has already posted " + count
+ " toasts. Not showing more. Package=" + pkg);
return;
}
}
}
}
// 产生一个新的Token 对象 source from android 7.1
// 注 : 从 Android 7.1 开始,才有的代码
Binder token = new Binder();
// 通知 WMS 去添加 Token
mWindowManagerInternal.addWindowToken(token,
WindowManager.LayoutParams.TYPE_TOAST);
record = new ToastRecord(callingPid, pkg, callback, duration, token);
// 将新生成的 ToastRecord 添加至 mToastQueue
mToastQueue.add(record);
index = mToastQueue.size() - 1;
keepProcessAliveIfNeededLocked(callingPid);
}
// If it's at index 0, it's the current toast. It doesn't matter if it's
// new or just been updated. Call back and tell it to show itself.
// If the callback fails, this will remove it from the list, so don't
// assume that it's valid after this.
if (index == 0) {
// 通知客户端去弹toast
showNextToastLocked();
}
} finally {
Binder.restoreCallingIdentity(callingId);
}
}
}
从上述代码分析可知,在SDK 25版本及以上,产生ToastRecord时会强制生成一个Token,并且在 SDK 为25 的 WMS addWindow()的代码中,会强制判断当前Token是否有效(注:强制判断的条件为当前应用的 TargetSdk 版本是否大于 25),所以,即使在 Android 7.1 的手机上,如果应用的TargetSdk 版本在 26以下,也是不会有问题的。
综上所述,唯一可能因为Token 不生效,而导致BadTokenException 的情况,只有可能是在Android 7.1 的机型上,但是安装应用的Target Sdk版本为26!
在Android O(SDK 26)中,Google为此做了修复处理,具体代码参加Toast源码如下:
public void handleShow(IBinder windowToken) {
...................
if (mHandler.hasMessages(CANCEL) || mHandler.hasMessages(HIDE)) {
return;
}
if (mView != mNextView) {
// remove the old view if necessary
handleHide();
mView = mNextView;
Context context = mView.getContext().getApplicationContext();
String packageName = mView.getContext().getOpPackageName();
if (context == null) {
context = mView.getContext();
}
mWM = (WindowManager)context.getSystemService(Context.WINDOW_SERVICE);
// We can resolve the Gravity here by using the Locale for getting
// the layout direction
final Configuration config = mView.getContext().getResources().getConfiguration();
final int gravity = Gravity.getAbsoluteGravity(mGravity, config.getLayoutDirection());
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 = mX;
mParams.y = mY;
mParams.verticalMargin = mVerticalMargin;
mParams.horizontalMargin = mHorizontalMargin;
mParams.packageName = packageName;
mParams.hideTimeoutMilliseconds = mDuration ==
Toast.LENGTH_LONG ? LONG_DURATION_TIMEOUT : SHORT_DURATION_TIMEOUT;
mParams.token = windowToken;
if (mView.getParent() != null) {
if (localLOGV) Log.v(TAG, "REMOVE! " + mView + " in " + this);
mWM.removeView(mView);
}
if (localLOGV) Log.v(TAG, "ADD! " + mView + " in " + this);
// Since the notification manager service cancels the token right
// after it notifies us to cancel the toast there is an inherent
// race and we may attempt to add a window after the token has been
// invalidated. Let us hedge against that.
// 注: Android O 在 windowmanager.addView()的时候,做了 try catch
// 处理,手动捕获了异常
try {
mWM.addView(mView, mParams);
trySendAccessibilityEvent();
} catch (WindowManager.BadTokenException e) {
/* ignore */
}
}
}
Android O在 Toast 最终要展示的时候(即 mWM.addView())的时候,做了 try catch处理,手动捕获了 badTokenException 异常。
那么如何打造一个Safe Toast 来规避Android 7.1可能遇到的问题呢,在Toast与Snackbar的那点事儿这篇文章中也已经写得很明白,将 NMS 管理Toast的代码移出来,形成一个自己的ToastManager,然后思路和Android O一致,在真正 addView() 的地方,加一个try catch去保护。至于后续文章中提到的为了适配 Android 7.1 而采用的SnackBar策略。自己觉得如果对应用自身TargetSdk版本没有特别高要求的话,可以暂时不升到26。这样的话,即使不try catch 的话,Android 7.1 也不会产生 BadTokenException问题。