Toast工作原理依赖于通知,关闭应用通知权限后,Toast无法显示。在发布SmartShow1.0.0版的时候,我注意到了这个问题,立即用自己的手机(魅族pro 6 plus)对淘宝、优酷等知名app进行测试,发现关闭通知权限后,它们的“再按一次退出程序”的Toast无法显示。因为Toast的工作机制如此,我并没有把它当做一个问题看待。但是在前两篇文章发布时,关闭通知权限依然能够显示Toast的呼声之高,让我不得不着手解决这个问题。

有不少Toast开源库解决了该问题,采用独立悬浮窗,也就是另起炉灶,废弃了原生Toast,自行实现一套弹窗提示。我们知道,不同手机品牌设备的Toast外观及弹出动画不尽相同,直接强行统一成一种风格,并不一定符合开发者的意愿,我们应该把是否定制Toast的权利交给开发者。另外,独立悬浮窗本身也需要申请权限,用户会关闭通知权限,难道就没有可能关闭悬浮窗权限么?

完美的解决方案是什么?当通知权限关闭时,我们需要制造一个VirtualToast来代替,对开发者来说,在使用上和原生Toast毫无差别。对app用户来说,在体验上(弹窗的外观、动画)与原生一致。

乍一看,这个问题好像很棘手,换一个角度思考问题,则柳暗花明,哈哈。

先进行技术选型,基于上面提到的原因,我们采用Dialog而不是悬浮窗。

先分析一下,关闭通知权限后,Toast的工作流程卡在了哪里。Toast的工作流程是一个基于Binder的IPC(进程通信)过程,应用程序作为客户端仅仅发起Toast请求和被动接受回调。系统服务负责管理请求队列、Token等,并通过Toast的内部类Tn来实现与应用程序的交互,即通过回调Tn的show/hide方法来显示/隐藏Toast窗口。关闭了通知权限,导致无法与系统服务“通信”,最终无法添加Toast窗口。

不过当调用Toast的show方法时,此时添加窗口所需的View及各种窗口参数已全部准备就绪。

public class Toast{

 public void show() {
        ...
        //创建tn,笔者注释
        TN tn = mTN;
        //窗口View,笔者注释
        tn.mNextView = mNextView;
        ...
    }

}
private static class TN extends ITransientNotification.Stub {
       ...
       TN(String packageName, @Nullable Looper looper) {
            ...
            final WindowManager.LayoutParams params = mParams;
            //窗口宽度,笔者注释
            params.height = WindowManager.LayoutParams.WRAP_CONTENT;
            //窗口高度,笔者注释
            params.width = WindowManager.LayoutParams.WRAP_CONTENT;
            params.format = PixelFormat.TRANSLUCENT;
            //窗口动画,笔者注释
            params.windowAnimations = com.android.internal.R.style.Animation_Toast;
            params.type = WindowManager.LayoutParams.TYPE_TOAST;
            params.setTitle("Toast");
            //不可获取焦点,不接收触碰事件,笔者注释
            params.flags = WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON
                    | WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE
                    | WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE;
            ...

        }
      ...
}

如此,当通知权限关闭,我们只需在调用Toast的show方法前用Dialog“截胡”,将其View作为Dialog的ContentView。

public void showToast() {
        applySetting();
        //如果具有通知权限,正常显示Toast
        if (Utils.isNotificationPermitted()) {
            mToast.show();
        } else {
            //无通知权限,用Dialog代替
            VirtualToastManager.get().show(getToastType(), mToast, mWindowParams);
        }
    }

将Toast窗口的参数设置给Dialog

public void show(int toastType, Toast toast, WindowManager.LayoutParams windowParams) {
        //获取栈顶activity
        Activity activity = ActivityStack.getTop();
        //若activity生命周期不符合条件,则什么都不做
        if (!Utils.isUpdateActivityUIPermitted(activity)) {
            EasyLogger.d("activity is can not show virtual toast dialog ,so do nothing but return.");
            return;
        }
        ...
        //取出Dialog窗口布局参数,原样复制Toast窗口的布局参数
        WindowManager.LayoutParams lp = virtualToastDialog.getWindow().getAttributes();
        //窗口宽度
        lp.width = windowParams.width;
        //窗口高度
        lp.height = windowParams.height;
        //窗口动画
        lp.windowAnimations = windowParams.windowAnimations;
        //窗口gravity
        lp.gravity = toast.getGravity();
        //窗口x,y坐标
        lp.x = toast.getXOffset();
        lp.y = toast.getYOffset();

        ViewGroup content = virtualToastDialog.findViewById(android.R.id.content);
        if (toast.getView().getParent() != content) {
            if (toast.getView().getParent() != null) {
                ViewGroup parent = (ViewGroup) toast.getView().getParent();
                parent.removeView(toast.getView());
            }
            content.removeAllViews();
            virtualToastDialog.setContentView(toast.getView());
        }
        try {
            virtualToastDialog.show();
        } catch (WindowManager.BadTokenException e) {
            EasyLogger.e("bad token has happened when show virtual toast!");
            mHostActivity = null;
        }
        ...
}

去除Dialog显示时周围变暗的特性

virtualToastDialog.getWindow().clearFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND);

Dialog不响应back键

virtualToastDialog.getWindow().setFlags(
WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE, WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE);

如此这般VirtualToastDialog的外观及动画与目标设备的原生Toast完全一致了。就剩下间隔一段时间自动消失的控制了。

定义一个Handler,延时特定时间后,执行隐藏VirtualToastDialog的代码。

Runnable dismissRunnable = toastType == BaseToastManager.PLAIN_TOAST ? mDismissPlainToastRunnable
                : mDismissTypeToastRunnable;
        mDismissHandler.removeCallbacks(dismissRunnable);
        mDismissHandler.postDelayed(dismissRunnable, toast.getDuration() == Toast.LENGTH_SHORT ?
                DURATION_SHORT : DURATION_LONG);

这里,始终复用同一个runnable任务即可,并且在发起一个新的延时隐藏时,我们要清除所有已存在的延时隐藏任务。假如,在上一个隐藏任务还差200毫秒执行的时候,主动调用了隐藏方法使Toast消失,那么再显示一个‘’新的‘’Toast时,200毫秒后就被自动隐藏了。所以mDismissHandler.removeCallbacks(dismissRunnable)起到归位的作用。