DToast
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.1.7'
//implementation 'com.github.Dovar66:DToast:1.1.8'//for androidx
}
使用示例
//使用默认布局
DToast.make(mContext)
.setText(R.id.tv_content_default, msg)
.setGravity(Gravity.BOTTOM | Gravity.CENTER, 0, 30)
.show();
//通过 setView()设置自定义的 Toast 布局
DToast.make(mContext)
.setView(View.inflate(mContext, R.layout.layout_toast_center, null))
.setText(R.id.tv_content_custom, msg)
.setGravity(Gravity.CENTER, 0, 0)
.showLong();
正文分析
先看看使用系统 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.通知权限被关闭时,如果系统版本为 Android8.0/8.1 则通过 hook 绕过通知栏权限,否则使用 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.你的应用设置的 targetSdkVersion>=26. 2.通知栏权限被关闭(通知栏权限默认都是打开的).
3.非 MIUI 设备(MIUI 弹吐司不需要通知栏权限).
4.运行设备的系统版本在 Android9.0 及以上。
所以,如果你的应用targetSdkVersion>=26,又想要保证在所有场景下都能正常展示弹窗,那么请在 DToast.make(context)时传入 Activity 作为上下文,这样在该场景下 DToast 会启用 ActivityToast 展示出弹窗。而 targetSdkVersion 小于 26 的同学可以放心使用 ApplicationContext 创建 DToast。
想了解为什么需要区别对待 targetSdkVersion26+?点击查看 API26 做了什么
而如果你还不了解 targetSdkVersion 点击这里查看
接下来再详细分析下上面提到的五个问题:
问题一:关闭通知权限时 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 的弹窗策略就是同一时间最多只展示一个弹窗,逻辑上就避免了此问题。因此仅捕获该异常。
其他建议
- 新项目做应用架构的时候可以考虑把整个应用(除闪屏页等特殊界面外)做成只有一个 Activity,其他全是 Fragment,这样就不存在悬浮窗的问题啦。
- 如果能够接受 Toast 不跨界面的话,建议使用 SnackBar
更新日志
1.1.5
新增 IToast.setText(idRes,text)方法
[修复]issue#6
1.1.3
[修复]issue#7
1.1.2
新增思路:对 Toast 的 INotificationManager 对象进行 hook 可以成功绕过通知栏权限,但 9.0 之后 Android 限制调用非公开 API,所以 9.0 之后此方法不可用。
代码更新:新增 hook INotificationManager 操作,在 Android8.0/8.1 上采用 hook 方式绕过通知栏权限。
我的其他项目:简单易用的支持多进程架构的组件化方案
License
Copyright (c) 2019 Dovar66
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.