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)起到归位的作用。