Toast or Safe Toast?

  在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= 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问题。

参考文章:http://zhuanlan.51cto.com/art/201804/569585.htm

你可能感兴趣的:(Toast or Safe Toast?)