DToast

Project Url: Dovar66/DToast
Introduction: 同学,你的系统 Toast 可能需要修复一下!自定义 Toast,解决系统 Toast 存在的问题
More: Author   ReportBugs   
Tags:

先看看使用系统 Toast 存在的问题:

1.当通知权限被关闭时在华为等手机上 Toast 不显示;

2.Toast 的队列机制在不同手机上可能会不相同;

3.Toast 的 BadTokenException 问题;

当发现系统 Toast 存在问题时,不少同学都会采用自定义的 TYPE_TOAST 弹窗来实现相同效果。虽然大部分情况下效果都是 OK 的,但其实 TYPE_TOAST 弹窗依然存在兼容问题:

4.Android8.0 之后的 token null is not valid 问题(实测部分机型问题);

5.Android7.1 之后,不允许同时展示两个 TYPE_TOAST 弹窗(实测部分机型问题)。

那么,DToast 使用的解决方案是:

1.通知权限未被关闭时,使用 SystemToast(修复了问题 2 和问题 3 的系统 Toast);
2.通知权限被关闭时,使用 DovaToast(自定义的 TYPE_TOAST 弹窗);
3.当使用 DovaToast 出现 token null is not valid 时,尝试使用 ActivityToast(自定义的 TYPE_APPLICATION_ATTACHED_DIALOG
弹窗,只有当传入 Context 为 Activity 时,才会启用 ActivityToast).

相信不少同学旧项目中封装的 ToastUtil 都是直接使用的 ApplicationContext 作为上下文,然后在需要弹窗的时候直接就是 ToastUtil.show(str) ,这样的使用方式对于我们来说是最方便的啦。

当然,使用 DToast 你也依然可以沿用这种封装方式,但这种方式在下面这个场景中可能会无法成功展示出弹窗(该场景下原生 Toast 也一样无法弹出), 不过请放心不会导致应用崩溃,而且这个场景出现的概率较小,有以下三个必要条件:1.通知栏权限被关闭(通知栏权限默认都是打开的) 2.非 MIUI 手机 3.Android8.0 以上的部分手机(我最近测试中的几部 8.0+设备都不存在该问题)。

不过,如果想要保证在所有场景下都能正常展示弹窗,还是建议在 DToast.make(context)时传入 Activity 作为上下文,这样在该场景下 DToast 会启用 ActivityToast 展示出弹窗。

接下来再详细分析下上面提到的五个问题:

问题一:关闭通知权限时 Toast 不显示

看下方 Toast 源码中的 show()方法,通过 AIDL 获取到 INotificationManager,并将接下来的显示流程控制权
交给 NotificationManagerService。
NMS 中会对 Toast 进行权限校验,当通知权限校验不通过时,Toast 将不做展示。
当然不同 ROM 中 NMS 可能会有不同,比如 MIUI 就对这部分内容进行了修改,所以小米手机关闭通知权限不会导致 Toast 不显示。

  /**
     * Show the view for the specified duration.
     */
    public void show() {
        if (mNextView == null) {
            throw new RuntimeException("setView must have been called");
        }

        INotificationManager service = getService();
        String pkg = mContext.getOpPackageName();
        TN tn = mTN;
        tn.mNextView = mNextView;

        try {
            service.enqueueToast(pkg, tn, mDuration);
        } catch (RemoteException e) {
            // Empty
        }
    }

如何解决这个问题?只要能够绕过 NotificationManagerService 即可。

DovaToast 通过使用 TYPE_TOAST 实现全局弹窗功能,不使用系统 Toast,也没有使用 NMS 服务,因此不受通知权限限制。

问题二:系统 Toast 的队列机制在不同手机上可能会不相同

 我找了四台设备,创建两个 Gravity 不同的 Toast 并调用 show()方法,结果出现了四种展示效果:

        * 荣耀 5C-android7.0(只看到展示第一个 Toast)
        * 小米 8-MIUI10(只看到展示第二个 Toast,即新的 Toast.show 会中止当前 Toast 的展示)
        * 红米 6pro-MIUI9(两个 Toast 同时展示)
        * 荣耀 5C-android6.0(第一个 TOAST 展示完成后,第二个才开始展示)

造成这个问题的原因应该是各大厂商 ROM 中 NMS 维护 Toast 队列的逻辑有差异。 同样的,DToast 内部也维护着自己的队列逻辑,保证在所有手机上使用 DToast 的效果相同。

 DToast 中多个弹窗连续出现时:

        1.相同优先级时,会终止上一个,直接展示后一个;
        2.不同优先级时,如果后一个的优先级更高则会终止上一个,直接展示后一个。

问题三:系统 Toast 的 BadTokenException 问题

  • Toast 有个内部类 TN(extends ITransientNotification.Stub),调用 Toast.show()时会将 TN 传递给 NMS;

      public void show() {
          if (mNextView == null) {
              throw new RuntimeException("setView must have been called");
          }
          INotificationManager service = getService();
          String pkg = mContext.getOpPackageName();
          TN tn = mTN;
          tn.mNextView = mNextView;
          try {
              service.enqueueToast(pkg, tn, mDuration);
          } catch (RemoteException e) {
              // Empty
          }
      }
    
  • 在 NMS 中会生成一个 windowToken,并将 windowToken 给到 WindowManagerService,WMS 会暂时保存该 token 并用于之后的校验;

    NotificationManagerService.java #enqueueToast 源码:

          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;
                                   }
                               }
                          }
                      }
    
                      Binder token = new Binder();//生成一个 token
                      mWindowManagerInternal.addWindowToken(token, TYPE_TOAST, DEFAULT_DISPLAY);
                      record = new ToastRecord(callingPid, pkg, callback, duration, token);
                      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) {
                      showNextToastLocked();
                  }
              } finally {
                  Binder.restoreCallingIdentity(callingId);
              }
          }
    
  • 然后 NMS 通过调用 TN.show(windowToken)回传 token 给 TN;

          /**
           * schedule handleShow into the right thread
           */
          @Override
          public void show(IBinder windowToken) {
              if (localLOGV) Log.v(TAG, "SHOW: " + this);
              mHandler.obtainMessage(SHOW, windowToken).sendToTarget();
          }
    
  • TN 使用该 token 尝试向 WindowManager 中添加 Toast 视图(mParams.token = windowToken);

    在 API25 的源码中,Toast 的 WindowManager.LayoutParams 参数新增了一个 token 属性,用于对添加的窗口进行校验。

  • 当 param.token 为空时,WindowManagerImpl 会为其设置 DefaultToken;

      @Override
      public void addView(@NonNull View view, @NonNull ViewGroup.LayoutParams params) {
          applyDefaultToken(params);
          mGlobal.addView(view, params, mContext.getDisplay(), mParentWindow);
      }
    
      private void applyDefaultToken(@NonNull ViewGroup.LayoutParams params) {
          // Only use the default token if we don't have a parent window.
          if (mDefaultToken != null && mParentWindow == null) {
              if (!(params instanceof WindowManager.LayoutParams)) {
                  throw new IllegalArgumentException("Params must be WindowManager.LayoutParams");
              }
              // Only use the default token if we don't already have a token.
              final WindowManager.LayoutParams wparams = (WindowManager.LayoutParams) params;
              if (wparams.token == null) {
                  wparams.token = mDefaultToken;
              }
          }
      }
    
  • 当 WindowManager 收到 addView 请求后会检查 mParams.token 是否有效,若有效则添加窗口展示,否则抛出 BadTokenException 异常.

                switch (res) {
                    case WindowManagerGlobal.ADD_BAD_APP_TOKEN:
                    case WindowManagerGlobal.ADD_BAD_SUBWINDOW_TOKEN:
                        throw new WindowManager.BadTokenException(
                                "Unable to add window -- token " + attrs.token
                                + " is not valid; is your activity running?");
                    case WindowManagerGlobal.ADD_NOT_APP_TOKEN:
                        throw new WindowManager.BadTokenException(
                                "Unable to add window -- token " + attrs.token
                                + " is not for an application");
                    case WindowManagerGlobal.ADD_APP_EXITING:
                        throw new WindowManager.BadTokenException(
                                "Unable to add window -- app for token " + attrs.token
                                + " is exiting");
                    case WindowManagerGlobal.ADD_DUPLICATE_ADD:
                        throw new WindowManager.BadTokenException(
                                "Unable to add window -- window " + mWindow
                                + " has already been added");
                    case WindowManagerGlobal.ADD_STARTING_NOT_NEEDED:
                        // Silently ignore -- we would have just removed it
                        // right away, anyway.
                        return;
                    case WindowManagerGlobal.ADD_MULTIPLE_SINGLETON:
                        throw new WindowManager.BadTokenException("Unable to add window "
                                + mWindow + " -- another window of type "
                                + mWindowAttributes.type + " already exists");
                    case WindowManagerGlobal.ADD_PERMISSION_DENIED:
                        throw new WindowManager.BadTokenException("Unable to add window "
                                + mWindow + " -- permission denied for window type "
                                + mWindowAttributes.type);
                    case WindowManagerGlobal.ADD_INVALID_DISPLAY:
                        throw new WindowManager.InvalidDisplayException("Unable to add window "
                                + mWindow + " -- the specified display can not be found");
                    case WindowManagerGlobal.ADD_INVALID_TYPE:
                        throw new WindowManager.InvalidDisplayException("Unable to add window "
                                + mWindow + " -- the specified window type "
                                + mWindowAttributes.type + " is not valid");
                }
    

什么情况下 windowToken 会失效?

UI 线程发生阻塞,导致 TN.show()没有及时执行,当 NotificationManager 的检测超时后便会删除 WMS 中的该 token,即造成 token 失效。

如何解决?

Google 在 API26 中修复了这个问题,即增加了 try-catch:

            // 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.
            try {
                mWM.addView(mView, mParams);
                trySendAccessibilityEvent();
            } catch (WindowManager.BadTokenException e) {
                /* ignore */
            }

因此对于 8.0 之前的我们也需要做相同的处理。DToast 是通过反射完成这个动作,具体看下方实现:

  //捕获 8.0 之前 Toast 的 BadTokenException,Google 在 Android 8.0 的代码提交中修复了这个问题
     private void hook(Toast toast) {
         try {
             Field sField_TN = Toast.class.getDeclaredField("mTN");
             sField_TN.setAccessible(true);
             Field sField_TN_Handler = sField_TN.getType().getDeclaredField("mHandler");
             sField_TN_Handler.setAccessible(true);

             Object tn = sField_TN.get(toast);
             Handler preHandler = (Handler) sField_TN_Handler.get(tn);
             sField_TN_Handler.set(tn, new SafelyHandlerWrapper(preHandler));
         } catch (Exception e) {
             e.printStackTrace();
         }
     }


     public class SafelyHandlerWrapper extends Handler {
         private Handler impl;

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

         @Override
         public void dispatchMessage(Message msg) {
             try {
                 impl.dispatchMessage(msg);
             } catch (Exception e) {
             }
         }

         @Override
         public void handleMessage(Message msg) {
             impl.handleMessage(msg);//需要委托给原 Handler 执行
         }
     }

问题四:Android8.0 之后的 token null is not valid 问题

Android8.0 后对 WindowManager 做了限制和修改,特别是 TYPE_TOAST 类型的窗口,必须要传递一个 token 用于校验。

API25:(PhoneWindowManager.java 源码)

public int checkAddPermission(WindowManager.LayoutParams attrs, int[] outAppOp) {
    int type = attrs.type;

    outAppOp[0] = AppOpsManager.OP_NONE;

    if (!((type >= FIRST_APPLICATION_WINDOW && type <= LAST_APPLICATION_WINDOW)
            || (type >= FIRST_SUB_WINDOW && type <= LAST_SUB_WINDOW)
            || (type >= FIRST_SYSTEM_WINDOW && type <= LAST_SYSTEM_WINDOW))) {
        return WindowManagerGlobal.ADD_INVALID_TYPE;
    }

    if (type < FIRST_SYSTEM_WINDOW || type > LAST_SYSTEM_WINDOW) {
        // Window manager will make sure these are okay.
        return WindowManagerGlobal.ADD_OKAY;
    }
    String permission = null;
    switch (type) {
        case TYPE_TOAST:
            // XXX right now the app process has complete control over
            // this...  should introduce a token to let the system
            // monitor/control what they are doing.
            outAppOp[0] = AppOpsManager.OP_TOAST_WINDOW;
            break;
        case TYPE_DREAM:
        case TYPE_INPUT_METHOD:
        case TYPE_WALLPAPER:
        case TYPE_PRIVATE_PRESENTATION:
        case TYPE_VOICE_INTERACTION:
        case TYPE_ACCESSIBILITY_OVERLAY:
        case TYPE_QS_DIALOG:
            // The window manager will check these.
            break;
        case TYPE_PHONE:
        case TYPE_PRIORITY_PHONE:
        case TYPE_SYSTEM_ALERT:
        case TYPE_SYSTEM_ERROR:
        case TYPE_SYSTEM_OVERLAY:
            permission = android.Manifest.permission.SYSTEM_ALERT_WINDOW;
            outAppOp[0] = AppOpsManager.OP_SYSTEM_ALERT_WINDOW;
            break;
        default:
            permission = android.Manifest.permission.INTERNAL_SYSTEM_WINDOW;
    }
    if (permission != null) {
        ...
    }
    return WindowManagerGlobal.ADD_OKAY;
}

API26:(PhoneWindowManager.java 源码)

public int checkAddPermission(WindowManager.LayoutParams attrs, int[] outAppOp) {
    int type = attrs.type;

    outAppOp[0] = AppOpsManager.OP_NONE;

    if (!((type >= FIRST_APPLICATION_WINDOW && type <= LAST_APPLICATION_WINDOW)
            || (type >= FIRST_SUB_WINDOW && type <= LAST_SUB_WINDOW)
            || (type >= FIRST_SYSTEM_WINDOW && type <= LAST_SYSTEM_WINDOW))) {
        return WindowManagerGlobal.ADD_INVALID_TYPE;
    }

    if (type < FIRST_SYSTEM_WINDOW || type > LAST_SYSTEM_WINDOW) {
        // Window manager will make sure these are okay.
        return ADD_OKAY;
    }

    if (!isSystemAlertWindowType(type)) {
        switch (type) {
            case TYPE_TOAST:
                // Only apps that target older than O SDK can add window without a token, after
                // that we require a token so apps cannot add toasts directly as the token is
                // added by the notification system.
                // Window manager does the checking for this.
                outAppOp[0] = OP_TOAST_WINDOW;
                return ADD_OKAY;
            case TYPE_DREAM:
            case TYPE_INPUT_METHOD:
            case TYPE_WALLPAPER:
            case TYPE_PRESENTATION:
            case TYPE_PRIVATE_PRESENTATION:
            case TYPE_VOICE_INTERACTION:
            case TYPE_ACCESSIBILITY_OVERLAY:
            case TYPE_QS_DIALOG:
                // The window manager will check these.
                return ADD_OKAY;
        }
        return mContext.checkCallingOrSelfPermission(INTERNAL_SYSTEM_WINDOW)
                == PERMISSION_GRANTED ? ADD_OKAY : ADD_PERMISSION_DENIED;
    }
}

为了解决问题一,DovaToast 不得不选择绕过 NotificationManagerService 的控制,但由于 windowToken 是 NMS 生成的, 绕过 NMS 就无法获取到有效的 windowToken,于是作为 TYPE_TOAST 的 DovaToast 就可能陷入第四个问题。因此,DToast 选择在 DovaToast 出现 该问题时引入 ActivityToast,在 DovaToast 无法正常展示时创建一个依附于 Activity 的弹窗展示出来,不过 ActivityToast 只会展示在当前 Activity,不具有跨页面功能。 如果说有更好的方案,那肯定是去获取悬浮窗权限然后改用 TYPE_PHONE 等类型,但悬浮窗权限往往不容易获取,目前来看恐怕除了微信其他 APP 都不能保证拿得到用户的悬浮窗权限。

问题五:Android7.1 之后,不允许同时展示两个 TYPE_TOAST 弹窗

DToast 的弹窗策略就是同一时间最多只展示一个弹窗,逻辑上就避免了此问题。因此仅捕获该异常。

通过 Gradle 引用本库

Step 1. Add the JitPack repository to your build file

Add it in your root build.gradle at the end of repositories:

allprojects {
    repositories {
        ...
        maven { url 'https://jitpack.io' }
    }
}

Step 2. Add the dependency

dependencies {
        implementation 'com.github.Dovar66:DToast:1.0.3'
}

TODO LIST:

  • 增加适配应用已获取到悬浮窗权限的情况
  • 考虑是否需要支持同时展示多个弹窗

其他建议

  • 新项目做应用架构的时候可以考虑把整个应用(除闪屏页等特殊界面外)做成只有一个 Activity,其他全是 Fragment,这样就不存在悬浮窗的问题啦。
  • 如果能够接受 Toast 不跨界面的话,建议使用 SnackBar
Support Me
Apps
About Me
Google+: Trinea trinea
GitHub: Trinea