本文章已授权鸿洋微信公众号转载:Toast不显示了?
吐司弹不出来完美的解决方案:ToastUtils,接下来让我们来一步步开始分析这个问题是如何出现,解决的过程,以及解决的方法
首先我们先看一下大厂 APP 的弹吐司
疑问
连吐司弹不出来的手机是个什么梗?
是少部分机型问题还是大多数机型的问题?
为什么关闭了通知栏权限弹不出来?
为什么有的机型可以弹有的却不行?
解答
自从我的 ToastUtils 框架发布了之后,被问最多的一个问题,你的Toast框架关闭通知栏权限还能弹出来吗?我心想这 Toast 跟通知栏扯不上啥关系吧,但是既然有人这样问了,也只能半信半疑了,于是我便拿了我的小米8还有红米Note5进行了测试,发现并没有该问题,于是我统一回复,这个是兼容问题,极少数机型才可能出现的问题,为保证框架稳定性,不给予兼容
于是还有人陆陆续续给我反馈了这个问题,反馈的人都是用华为机型出现的问题,我便开始重视起来,刚好有同事用的是华为 P9,我跟他借了一下手机,一借不要紧,一借一下午。估计同事的内心是崩溃的,因为这个问题被 100% 复现了,真的关闭通知栏权限后吐司弹不出来了
于是我翻遍了 Toast 的源码,吐司底层是 WindowManager 实现的,但是这跟通知栏权限有什么关系呢?就算有关系也是和 NotificationManager 有关系,到底和通知栏权限扯上啥关系了呢?经过查看系统源码发现,吐司的创建是使用到了 WindowManager 去创建,但是显示吐司的时候使用了 INotificationManager ,看类名就知道肯定和 NotificationManager 有联系,这就是为什么关闭了通知栏权限后导致了吐司显示不出来的问题
现在经过测试,大部分小米机型不会因为通知栏权限被关闭而原生的Toast弹不出来,而华为荣耀,三星等都会出现通知栏权限被关闭后导致原生Toast显示不出来,这可能是小米手机对这个吐司的显示做了特殊处理,这个问题在Github上排名前几的Toast框架都会出现,并且一些大厂的APP(除QQ微信和美团外)也会出现该问题
吐司弹不出来的后果
Toast是我们日常开发中最常用的类,如果我们的APP在通知栏推送的消息比较多,用户就会把我们的通知栏权限屏蔽了,但是这个会引起一个连带反应,就是应用中所有使用到 Toast 的地方都会显示不出来,彻底成为一个哑巴应用,例如以下情景:
账户密码输入错误,吐司弹不出来
用户网络支付失败,吐司弹不出来
网络请求错误,吐司弹不出来
双击退出应用,吐司弹不出来
等等情况,只要用到原生 Toast 都显示不出来
其实这是一个系统的Bug,谷歌为了让应用的 Toast 能够显示在其他应用上面,所以使用了通知栏相关的 API,但是这个 API 随着用户屏蔽通知栏而变得不可用,系统错误地认为你没有通知栏权限,从而间接导致 Toast 有 show 请求时被系统所拦截
Toast 源码解析
首先看一下 Toast 的构成
再看一下 Toast 内部的 API
里面还有一个内部类,再看一下内部的 API
从这里我们不难推断,Toast 只是一个外观类,最终实现还是由其内部类来实现,由于这个内部类太长,这里放一下这个内部类的源码,简单过一遍就好
private static class TN extends ITransientNotification.Stub {
private final WindowManager.LayoutParams mParams = new WindowManager.LayoutParams();
private static final int SHOW = 0;
private static final int HIDE = 1;
private static final int CANCEL = 2;
final Handler mHandler;
int mGravity;
int mX, mY;
float mHorizontalMargin;
float mVerticalMargin;
View mView;
View mNextView;
int mDuration;
WindowManager mWM;
String mPackageName;
static final long SHORT_DURATION_TIMEOUT = 4000;
static final long LONG_DURATION_TIMEOUT = 7000;
TN(String packageName, @Nullable Looper looper) {
// XXX This should be changed to use a Dialog, with a Theme.Toast
// defined that sets up the layout params appropriately.
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;
mPackageName = packageName;
if (looper == null) {
// Use Looper.myLooper() if looper is not specified.
looper = Looper.myLooper();
if (looper == null) {
throw new RuntimeException(
"Can't toast on a thread that has not called Looper.prepare()");
}
}
mHandler = new Handler(looper, null) {
@Override
public void handleMessage(Message msg) {
switch (msg.what) {
case SHOW: {
IBinder token = (IBinder) msg.obj;
handleShow(token);
break;
}
case HIDE: {
handleHide();
// Don't do this in handleHide() because it is also invoked by
// handleShow()
mNextView = null;
break;
}
case CANCEL: {
handleHide();
// Don't do this in handleHide() because it is also invoked by
// handleShow()
mNextView = null;
try {
getService().cancelToast(mPackageName, TN.this);
} catch (RemoteException e) {
}
break;
}
}
}
};
}
/**
* schedule handleShow into the right thread
*/
@Override
public void show(IBinder windowToken) {
if (localLOGV) Log.v(TAG, "SHOW: " + this);
mHandler.obtainMessage(SHOW, windowToken).sendToTarget();
}
/**
* schedule handleHide into the right thread
*/
@Override
public void hide() {
if (localLOGV) Log.v(TAG, "HIDE: " + this);
mHandler.obtainMessage(HIDE).sendToTarget();
}
public void cancel() {
if (localLOGV) Log.v(TAG, "CANCEL: " + this);
mHandler.obtainMessage(CANCEL).sendToTarget();
}
public void handleShow(IBinder windowToken) {
if (localLOGV) Log.v(TAG, "HANDLE SHOW: " + this + " mView=" + mView
+ " mNextView=" + mNextView);
// If a cancel/hide is pending - no need to show - at this point
// the window token is already invalid and no need to do any work.
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.
try {
mWM.addView(mView, mParams);
trySendAccessibilityEvent();
} catch (WindowManager.BadTokenException e) {
/* ignore */
}
}
}
private void trySendAccessibilityEvent() {
AccessibilityManager accessibilityManager =
AccessibilityManager.getInstance(mView.getContext());
if (!accessibilityManager.isEnabled()) {
return;
}
// treat toasts as notifications since they are used to
// announce a transient piece of information to the user
AccessibilityEvent event = AccessibilityEvent.obtain(
AccessibilityEvent.TYPE_NOTIFICATION_STATE_CHANGED);
event.setClassName(getClass().getName());
event.setPackageName(mView.getContext().getPackageName());
mView.dispatchPopulateAccessibilityEvent(event);
accessibilityManager.sendAccessibilityEvent(event);
}
public void handleHide() {
if (localLOGV) Log.v(TAG, "HANDLE HIDE: " + this + " mView=" + mView);
if (mView != null) {
// note: checking parent() just to make sure the view has
// been added... i have seen cases where we get here when
// the view isn't yet added, so let's try not to crash.
if (mView.getParent() != null) {
if (localLOGV) Log.v(TAG, "REMOVE! " + mView + " in " + this);
mWM.removeViewImmediate(mView);
}
mView = null;
}
}
}
只需要稍微简单看一下就看懂,Toast 底层就是用这个内部类去实现,请记住,这个内部类叫做 TN,字段名为 mTN,接下来先让我们看一下 Toast 中 cancel 方法的源码
cancel最终还是调用了内部类 TN 中的同名方法,接下来再看 Toast 中 show 方法的源码
仔细观察的同学就会发现了,这个 show 的方法可不是像 cancel 一样只调用了 TN 内部类中的同名方法,还调用了 INotificationManager 这个 API,其实不难发现,这个 INotificationManager 是系统的 AIDL,不信的话我们再看一下这个 INotificationManager
我相信学过 AIDL 的同学会明白,这里不再讲 AIDL 相关知识,如需了解请自行百度
重点讲一下 INotificationManager,这个 AIDL 由系统实现的一个类,不同系统这个 AIDL 所对应的类也不相同,这就充分说明了为什么导致小米的机型关闭了通知栏权限还可以显示,而华为就不行的原因,具体原因请再看源码
因为这里传了应用的包名给系统通知栏,如果这个包名对应的APP的通知栏权限被关闭了,吐司自然也就弹不出来了
那么该如何着手解决这个问题
先思考一个问题,Toast 显示是使用了 INotificationManager,和通知栏有关系,而Toast 的创建是使用了 WindowManager,和通知栏没有关系,那么我们可不可以通过 WindowManager 的方式来创建类似于 Toast 一样的东西呢,答案也是可以的,只不过在过程中会遇到非常棘手的问题,接下来让我们解决这些遇到的问题
首先创建一个 WindowManager 需要 一个 View 参数和 WindowManager.LayoutParams 参数,这里说一下 WindowManager.LayoutParams 的创建,直接复制 Toast 部分代码
WindowManager.LayoutParams params = new WindowManager.LayoutParams();
params.height = WindowManager.LayoutParams.WRAP_CONTENT;
params.width = WindowManager.LayoutParams.WRAP_CONTENT;
params.format = PixelFormat.TRANSLUCENT;
// 找不到 com.android.internal.R.style.Animation_Toast
// params.windowAnimations = com.android.internal.R.style.Animation_Toast;
params.windowAnimations = -1;
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;
然后使用 WindowManager 调用 addView 显示,然后报了错
android.view.WindowManager$BadTokenException: Unable to add window -- token null is not valid; is your activity running?
其原因在于我们使用了 type,为什么不能加 TYPE_TOAST,因为通知权限在关闭后设置显示的类型为Toast会报错,所以这里我们把这句代码注释掉,然后就可以显示出来了
params.type = WindowManager.LayoutParams.TYPE_TOAST;
WindowManager 没有吐司的显示效果
其原因在于我们复制了 Toast 的部分代码,而其中的动画代码引用了系统 R 文件中资源,而我无法直接在 Java 代码中引用
params.windowAnimations = com.android.internal.R.style.Animation_Toast;
Java代码不能引用这个Style不代表XML就不行,在这里创建一个 Style 并且继承原生 Toast 样式,这里我们可以自定义,也可以直接使用系统的,为了和系统的样式统一,这里就直接使用系统的
然后重新指定 params.windowAnimations 即可解决该问题
params.windowAnimations = R.style.ToastAnimation;
WindowManager 没有自动消失的问题
首先 WindowManager 并不能像 Toast 显示后自动消失,如果要像 Toast 一样自动消失很容易,在 WindowManager 显示后发送一个定时关闭的任务,那么问题来了,这个显示的时间如何定义?系统 Toast 显示的时间是什么样子?首先我们需要先看一下 Toast 给我们提供的两个常量值
从这张图上我们并没有发现什么有价值的东西,我们继续往下找,看看是什么地方引用了这些常量
继续通过查看源码得知
但是通过测试,短吐司显示的时长为2-3秒,而长吐司显示的时长是3-4秒,所以这两个值并不是吐司显示时长的毫秒数,那么我们该如何得出正确的毫秒数呢?这个问题就留给大家去思考,这里不做解答
只能使用当前 Activity 创建 WindowManager 的缺陷
发现一个问题,Activity 和 Application 同样是 Context 的子类,如果使用 Activity 获取的 WindowManager 对象可以创建出来,但是如果使用 Application 获取的 WindowManager 对象却报了错
android.view.WindowManager$BadTokenException: Unable to add window -- token null is not for an application
报错已经说得很清楚了,创建 WindowManager 不能使用 Application 对象去创建,也就是说只能通过 Activity 对象去创建 WindowManager
那么问题来了,每次弹这种 “Toast” 需要当前 Activity 对象,这个问题对于常年使用框架的同学是致命的
这里以我做的框架 ToastUtils 为例子,显示一个吐司是这样子调用的
ToastUtils.show("我是吐司");
如果要解决在关闭通知栏权限后吐司还能再弹出来的问题,就需要改成
ToastUtils.show(MainActivity.this, "我是吐司");
先说一下这个问题带来的影响吧,我是框架的作者,对于我来说,只需要在 ToastUtils 中 show 方法多添加一个 Activity 参数即可,但是对于使用框架的人,在更新完框架后,整个项目所有使用到这个ToastUtils.show()方法都会报错,需要多传入一个Activity 参数,相信他们的内心几乎是崩溃的,那么有没有一种好的办法解决这个问题,答案当然是有了,可以用一个冷门的 API
Application.registerActivityLifecycleCallbacks(ActivityLifecycleCallbacks callback);
这个 API 是在 安卓 4.0 之后才有的,而现在大多数设备已经在 安卓 5.0 及以上,所以这个 API 还是有前途的,接下看一下 ActivityLifecycleCallbacks 这个接口有什么方法吧
public interface ActivityLifecycleCallbacks {
void onActivityCreated(Activity activity, Bundle savedInstanceState);
void onActivityStarted(Activity activity);
void onActivityResumed(Activity activity);
void onActivityPaused(Activity activity);
void onActivityStopped(Activity activity);
void onActivitySaveInstanceState(Activity activity, Bundle outState);
void onActivityDestroyed(Activity activity);
}
看到这里,相信各位已经知道真相了,这个方法用于监听应用中 Activity 中的生命周期方法
那么我们就可以通过这个 API 来获取当前和用户交互的 Activity 对象,从而完成让当前 Activity 对象去创建 WindowManager
使用 WindowManager 实现 Toast 出现局限性的问题
当然用 WindowManager 创建的 View 必然也会受 Activity 的限制,因为就只能显示这个 Activity 上,如果在其他界面上则会显示不了,而系统原生的 Toast 则可以出现别的界面上,那有没有什么解决办法呢?
WindowManager 在没有悬浮窗权限的时候就只能显示依附于调用的 Activity,当有授予了悬浮窗权限之后,可以通过改变type参数来更改 WindowManager 显示范围,可以让这个 WindowManager 显示在其他界面之上,这样 Toast 就不会随着 Activity 的不可见而变得不可见
// 判断是否为 Android 6.0 及以上系统并且有悬浮窗权限
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && Settings.canDrawOverlays(mToast.getView().getContext())) {
// 解决使用 WindowManager 创建的 Toast 只能显示在当前 Activity 的问题
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
params.type = WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY;
}else {
params.type = WindowManager.LayoutParams.TYPE_PHONE;
}
}
如何在原生 Toast 和 WindowManager 中取舍
这样我们比对一组数据:
类型 | 显示范围 | 需要参数 | 兼容性 | 效率 | 通知栏权限 | 悬浮窗权限 |
---|---|---|---|---|---|---|
原生 Toast | 所有界面 | Context子类 | 高 | 一般 | 需要 | 不需要 |
WindowManager | 当前Activity | Activity子类 | 一般 | 高 | 不需要 | 不需要 |
经过对比,原生的 Toast 的优势还是要大于 WindowManager 的,所以如果在有在通知栏权限的前提下,建议使用原生的 Toast,我们可以通过判断通知栏权限是否被关闭,来判断是来显示原生 Toast 还是 WindowManager,方法代码如下:
/**
* 检查通知栏权限有没有开启
*/
public static boolean isNotificationEnabled(Context context){
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
return ((NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE)).areNotificationsEnabled();
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
AppOpsManager appOps = (AppOpsManager) context.getSystemService(Context.APP_OPS_SERVICE);
ApplicationInfo appInfo = context.getApplicationInfo();
String pkg = context.getApplicationContext().getPackageName();
int uid = appInfo.uid;
try {
Class> appOpsClass = Class.forName(AppOpsManager.class.getName());
Method checkOpNoThrowMethod = appOpsClass.getMethod("checkOpNoThrow", Integer.TYPE, Integer.TYPE, String.class);
Field opPostNotificationValue = appOpsClass.getDeclaredField("OP_POST_NOTIFICATION");
int value = (Integer) opPostNotificationValue.get(Integer.class);
return (Integer) checkOpNoThrowMethod.invoke(appOps, value, uid, pkg) == 0;
} catch (NoSuchMethodException | NoSuchFieldException | InvocationTargetException | IllegalAccessException | RuntimeException | ClassNotFoundException ignored) {
return true;
}
} else {
return true;
}
}