toast作为android系统发布以来一直伴随的一种提示交互,可以说做android开发的无人不晓。但即使就是这么一个常见到让人觉得平平无奇的系统类,但也存在不得不需要解决的问题。
系统bug?
当部分品牌的手机关闭掉app的通知栏权限后,你会发现toast居然神奇的消失了,拜托我只是想关闭掉烦人的通知栏,为何你toast也跟着消失了。关闭app的通知栏权限也算一个比较常见的操作吧,以自己为例,虽然安装了不少app,但各种系统通知栏弹的也是让人神烦,所以一般我每安装一个app都会将对应的通知栏权限给关闭掉。
实测中发现公司的三星部分测试机以及华为的pad均出现了关闭通知栏后toast弹出异常的问题。其他测试机则正常显示taost。但也仅代表公司的这部分测试机正常。toast的使用可以说是非常的常见,但是如果存在这种问题的话,那么对于toast的使用我们还能这么心安理得吗。
一种比较常见的使用场景,账号密码登录场景,如果存在toast无法弹出的问题,那么在用户输入完毕进行登录时,如果存在密码错误的情况下一般通过toast进行提示。如果此时toast无法弹出,那么可能导致用户误以为app没有交互响应影响app的后继使用。
原因分析
为什么会出现部分手机在关闭通知栏权限的情况下导致toast无法弹出,其实可以在toast的源码中找到一些蛛丝马迹。首先toast之所以可以展示在屏幕上就是通过系统的windowmanager来实现,在handleShow中存在如下源码
public void handleShow(IBinder windowToken) {
...
try {
mWM.addView(mView, mParams);
trySendAccessibilityEvent();
} catch (WindowManager.BadTokenException e) {
/* ignore */
}
}
}
通过wm的addview方法将mview展示在手机屏幕上。而mview正是toast上所要展示的ui,现在的问题就在于handleShow是如何被调用的,可以发现该方法实际上是在一个handler中被调用的
mHandler = new Handler(looper, null) {
@Override
public void handleMessage(Message msg) {
switch (msg.what) {
case SHOW: {
IBinder token = (IBinder) msg.obj;
handleShow(token);
break;
}
......
break;
}
}
}
};
而mHandler是被TN调用,关于TN就是一个binder对象用来响应远程service的指令,service发出show指令则show显示,发出hide指令则toast消失。查看toast的show源码
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
}
}
可以发现得到NotificationManager对象,并将要显示toast的请求通过enqueueToast发送给NotificationManagerService对象,换句话说toast是否可以显示完全由NotificationManagerService说了算,完全有一种命运掌握在别人手里的味道,这也就不难理解,如果系统通知栏权限关闭的情况下为什么toast无法弹出的问题
如何解决toast无法弹出问题
简单分析了上述源码,已经发现toast是否弹出完全由NotificationManagerService决定,那么解决思路比较简单,直接绕过NotificationManagerService的决策,将命运掌握在自己的手里,我命由我不由天,是show是hide我自己说了算。那么通过自己编写逻辑来决定toast的显示与隐藏即可,只需解决以下两个问题
1 如何仿系统原生效果实现toast的弹出与隐藏,包括toast的动画效果
2 当多个toast依次弹出时,需要保证toast可以全部依次展示
第一个问题的解决方法可以直接在toast的源码中找到,上述源码分析也提到过这个方法即handleshow,内部有关于wm一些参数的设置,参考这部分源码即可解决。
第二个问题可以通过维护一个toast队列来解决,将需要展示的toast依次保存然后依次弹出
只要想明白上述两个问题,剩下的就是代码的编写工作了。文末会直接给出自己实现的解决源码。但在实际编码过程中还遇到了另外的问题
如何判断通知栏关闭后toast无法弹出
并不是所有的手机在关闭了系统通知栏权限后都会出现toast无法弹出,相同版本的android系统,在国内不同品牌的手机上表现并不一致,应该是产商对这块的实现源码进行过修改导致的。如果采用依次收集问题品牌机的方式来判断是否采用自定义toast那么工作量也有太大,所以经过考虑通过简单直接的方式解决这个问题,判断通知栏权限是否关闭,如果关闭直接使用自定义的方式弹toast,否则使用系统的toast。这样就能做到toast肯定可以弹出
toast弹出使用的context
这个问题处理起来会比较麻烦一些,查看toast中关于展示的逻辑,这里再重新贴一遍代码
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 */
}
}
}
有两个地方需要特别注意一下,首先就是handleshow中传递的参数windowToken是有用处的,查看源码可以发现最终会通过
mParams.token = windowToken
赋值给layoutparams对象,而layoutparams是WindowManager调用addview时不可缺少的传参,这个token起着校验的作用,windowmanagerservice最终会检查token是否合理,只有token合理的情况才会回调到handleshow方法,否则校验不通过的话会抛出一个大家非常熟悉的一个异常
Fatal Exception: android.view.WindowManager$BadTokenException
告诉开发者这是一个错误的token,所以这个token必须填写,toast源码中接收到了一个合适的token,这个token是和notificationManagerService交互得到的。但我们自定义的toast并没有和notificationManagerService交互,那么又该如何获取这个token。
Toast源码通过notificationManagerService获取到token,并使用applicationContext得到一个mWM对象,然后调用addview。源码如下
Context context = mView.getContext().getApplicationContext();
String packageName = mView.getContext().getOpPackageName();
if (context == null) {
context = mView.getContext();
}
mWM = (WindowManager)context.getSystemService(Context.WINDOW_SERVICE);
得到的context是一个applicationContext,如果我们也获取一个applicationContext,然后忽略token设置是否可以成功,经过测试发现这条路走不通。
Activity context使用
好在google给你关上一扇门的情况,给你另外开了一扇窗,那就是大家都非常熟悉的activity context,实测后发现直接使用activity的context得到的mWM是可以不设置token参数,并且可以正常工作的!但是这里会有一个问题就是使用activity context得到mWM弹出的toast在activity关闭之后会立即消失不见的,也就是说toast没有达到真正意义上的悬浮效果,实际上虽然我们没有显式指定token,但是使用activity context得到mWM调用addview内部是会自动给我们设置token的!!
而这个token和activity的window相关联,当activity关闭window消失,那么附加上它上面的toast也会跟着消失了。看下源码设置token的流程
@Override
public void addView(@NonNull View view, @NonNull ViewGroup.LayoutParams params) {
applyDefaultToken(params);
mGlobal.addView(view, params, mContext.getDisplay(), mParentWindow);
}
applyDefaultToken方法内部会在mDefaultToken不为null的情况下设置给params的token,实际调试发现applyDefaultToken一直为null,所以真正设置token的源码在mGlobal.addView(view, params, mContext.getDisplay(), mParentWindow);mGlobal实际上是一个WindowManagerGlobal对象,源码比较多,只看重点部分
public void addView(View view, ViewGroup.LayoutParams params,
Display display, Window parentWindow) {
......
final WindowManager.LayoutParams wparams = (WindowManager.LayoutParams) params;
if (parentWindow != null) {
parentWindow.adjustLayoutParamsForSubWindow(wparams);
} else {
......
}
......
}
通过adjustLayoutParamsForSubWindow对params进行设置,parentWindow实际是一个PhoneWindow对象
void adjustLayoutParamsForSubWindow(WindowManager.LayoutParams wp) {
CharSequence curTitle = wp.getTitle();
if (wp.type >= WindowManager.LayoutParams.FIRST_SUB_WINDOW &&
wp.type <= WindowManager.LayoutParams.LAST_SUB_WINDOW) {
if (wp.token == null) {
View decor = peekDecorView();
if (decor != null) {
wp.token = decor.getWindowToken();
}
......
}
在该方法中我们找到了非常关键的设置代码,通过view的getWindowToken对象给params设置token,再继续深入看下
public IBinder getWindowToken() {
return mAttachInfo != null ? mAttachInfo.mWindowToken : null;
}
实际上就是通过mWindowToken对象得到token,而这个attachinfo又是什么时候设置的呢,如果对activity启动流程比较熟悉的话应该知道attachinfo对象是在viewrootimpl创建的时候进行设置的,而viewrootimpl对象又可以说是decorview的管理类用来发起view的测绘等各种操作,来看一下viewrootimpl创建时的逻辑
public ViewRootImpl(Context context, Display display) {
...
mWindow = new W(this);
...
mAttachInfo = new View.AttachInfo(mWindowSession, mWindow, display, this, mHandler, this,
context);
...
}
将mWindow对象传递给attachinfo,内部通过mWindow.asBind得到ibind对象然后赋值给attachinfo的mWindowToken对象,到此关于使用activity context为什么可以不显示设置token的问题就得到解答了。
使用activity context的一点优化
文章已经说过使用activity的context在activity被关闭的情况下toast是会立即消失的,这个交互相比使用toast原生的实现差了一点,不过自己确实没有太好的办法解决这个问题,好在实际使用场景影响不是很大。
不过另一个问题需要特别留意一下,如果在某一个activity上需要连续依次弹出多个toast,在toast还没有完全弹完的情况下就关闭掉了activity那么剩下的toast该如何处理,如果直接全部抛弃掉这些toast显然不太合适,其实只要将这些toast所需要的context从原先被关闭的activity重置为当前可见activity的context即可完美解决掉这个问题,如何找到当前可见的activity,通过registerActivityLifecycleCallbacks监听每一个activity即可。
到此关于自定义toast逻辑实现,解决系统通知栏关闭的情况下toast不弹出的问题的主要解决思路都已经在上述文章解释清楚了,剩下的就是代码的编写,主要实现就以下三个类ToastAdapter,ToastWrapper,TopActivityWatcher。
ToastAdapter
ToastAdapter的主要作用就是判断系统通知栏是否关闭,然后采用不同的处理方式,源码如下:
@RestrictTo(RestrictTo.Scope.LIBRARY)
public class ToastAdapter {
private static boolean notificationEnable;
private static ToastAdapter instance;
private static TopActivityWatcher liveData;
private static boolean init;
private ToastAdapter() {
if (!init) {
throw new IllegalStateException("先调用ToastAdapter init");
}
}
public static void init(Application application) {
notificationEnable = areNotificationsEnabled(application);
init = true;
if (notificationEnable) {
return;
}
liveData = ToastWrapper.registerObserver();
application.registerActivityLifecycleCallbacks(new Application.ActivityLifecycleCallbacks() {
@Override
public void onActivityCreated(Activity activity, Bundle bundle) {
// Log.e("mandy", "onActivityCreated activity==" + activity);
}
@Override
public void onActivityStarted(Activity activity) {
// Log.e("mandy", "onActivityStarted activity==" + activity);
}
@Override
public void onActivityResumed(Activity activity) {
// Log.e("mandy", "onActivityResumed activity==" + activity);
liveData.resume(activity);
}
@Override
public void onActivityPaused(Activity activity) {
// Log.e("mandy", "onActivityPaused activity==" + activity);
}
@Override
public void onActivityStopped(Activity activity) {
// Log.e("mandy", "onActivityStopped activity==" + activity);
}
@Override
public void onActivitySaveInstanceState(Activity activity, Bundle bundle) {
}
@Override
public void onActivityDestroyed(Activity activity) {
// Log.e("mandy", "onActivityDestroyed activity==" + activity);
liveData.destroyed(activity);
}
});
}
public static void show(Toast toast) {
getInstance().showInner(toast);
}
private static ToastAdapter getInstance() {
if (instance == null) {
synchronized (ToastAdapter.class) {
if (instance == null) {
instance = new ToastAdapter();
}
}
}
return instance;
}
/**
* 检测通知权限是否开启
*/
private static boolean areNotificationsEnabled(Context context) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
NotificationManager manager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
return manager != null && manager.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 packageName = 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 ((int) checkOpNoThrowMethod.invoke(appOps, value, uid, packageName) == AppOpsManager.MODE_ALLOWED);
} catch (ClassNotFoundException | NoSuchMethodException | NoSuchFieldException
| InvocationTargetException | IllegalAccessException | RuntimeException ignored) {
return true;
}
} else {
return true;
}
}
/**
* 通知栏开启的情况下直接使用原生toast即可,通知栏关闭的情况下
* 个别机型存在toast无法弹出的问题,使用ToastWrapper
*/
private void showInner(Toast toast) {
Toast localToast;
localToast = reformToast(toast);
if (localToast == null) {
localToast = toast;
}
if (notificationEnable) {
localToast.show();
} else {
ToastWrapper.show(localToast);
}
}
private Toast reformToast(Toast toast) {
if (notificationEnable && Build.VERSION.SDK_INT == Build.VERSION_CODES.N_MR1) {
return injectToast(toast);
}
return toast;
}
/**
* 7.1系统存在ui线程被阻塞,toast可能崩溃问题
*/
private Toast injectToast(Toast toast) {
try {
Field field = Toast.class.getDeclaredField("mTN");
field.setAccessible(true);
Object TN = field.get(toast);
Field handlerField = field.getType().getDeclaredField("mHandler");
handlerField.setAccessible(true);
Handler handler = (Handler) handlerField.get(TN);
handlerField.set(TN, new ToastHandler(handler));
return toast;
} catch (IllegalAccessException | NoSuchFieldException ignored) {
return null;
}
}
static class ToastHandler extends Handler {
private Handler mHandler;
ToastHandler(Handler handler) {
mHandler = handler;
}
@Override
public void handleMessage(Message msg) {
try {
mHandler.handleMessage(msg);
} catch (Exception e) {
//nothing
}
}
}
}
TopActivityWatcher
TopActivityWatcher用来监听当前可见activity,比较简单
class TopActivityWatcher extends MutableLiveData {
private WeakReference mActivity;
void resume(Activity activity) {
mActivity = new WeakReference<>(activity);
setValue(activity);
}
/**
* 个别三方库在启动activity之后直接finish掉,导致resume不能被调用到
* */
void destroyed(Activity activity) {
if (mActivity != null && mActivity.get() == activity) {
mActivity = null;
setValue(null);
}
}
}
ToastWrapper
ToastWrapper就是主要的自定义toast展示逻辑实现类,包括了toast队列的处理
class ToastWrapper extends Handler {
private final static int TOAST_HIDE = 1;
private final static int TOAST_SHOW = 2;
private final static long DELAYED_TIME = 300;
private static LinkedBlockingQueue toasts = new LinkedBlockingQueue<>();
private static WeakReference current;
private ToastWrapper() {
super(Looper.getMainLooper());
}
public static void show(Toast toast) {
ToastItem toastItem = new ToastItem();
if (current == null || current.get() == null) {
return;
}
toastItem.currentActivity = current;
toastItem.toast = toast;
boolean show;
//防止在有handler的线程出现并发问题
synchronized (ToastWrapper.class) {
toasts.offer(toastItem);
show = toasts.size() == 1;
}
if (show) {
ToastWrapper wrapper = new ToastWrapper();
wrapper.show();
}
}
static TopActivityWatcher registerObserver() {
TopActivityWatcher liveData = new TopActivityWatcher();
liveData.observeForever(new Observer() {
@Override
public void onChanged(@Nullable Activity activity) {
if (activity != null) {
current = new WeakReference<>(activity);
} else {
current = null;
}
}
});
return liveData;
}
public void show() {
showInner();
}
private void showInner() {
Message message = Message.obtain();
message.what = TOAST_SHOW;
sendMessage(message);
}
@Override
public void handleMessage(Message msg) {
try {
if (current == null) {
reset();
return;
}
/**
* 解决taost弹出时activity立即finish导致toast不显示的问题
*/
if (current.get() != null && current.get().isFinishing()) {
Message message = Message.obtain();
message.copyFrom(msg);
sendMessageDelayed(message, DELAYED_TIME);
return;
}
int what = msg.what;
if (what == TOAST_HIDE) {
hideToast();
} else if (what == TOAST_SHOW) {
showToast();
}
} catch (Exception e) {
e.printStackTrace();
LogUtil.e("mandy", "boom");
reset();
}
}
private void reset() {
toasts.clear();
}
private void showToast() {
if (toasts.isEmpty()) {
return;
}
ToastItem item = toasts.peek();
Toast toast = item.toast;
item.currentActivity = current;
Activity context = item.currentActivity == null ? null : item.currentActivity.get();
if (context == null || context.isFinishing()) {
toasts.poll();
next();
return;
}
WindowManager.LayoutParams params = new WindowManager.LayoutParams();
params.height = WindowManager.LayoutParams.WRAP_CONTENT;
params.width = WindowManager.LayoutParams.WRAP_CONTENT;
params.format = PixelFormat.TRANSLUCENT;
params.windowAnimations = android.R.style.Animation_Toast;
params.flags = WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON
| WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE
| WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE;
params.packageName = context.getPackageName();
// 重新初始化位置
params.gravity = toast.getGravity();
params.x = toast.getXOffset();
params.y = toast.getYOffset();
WindowManager wm = ((WindowManager) context.getSystemService(Context.WINDOW_SERVICE));
wm.addView(toast.getView(), params);
Message message = Message.obtain();
message.what = TOAST_HIDE;
sendMessageDelayed(message, toast.getDuration() == Toast.LENGTH_LONG ?
2000 : 1000);
}
private void hideToast() {
ToastItem toastItem = toasts.poll();
if (toastItem != null && toastItem.currentActivity.get() != null && !toastItem.currentActivity.get().isFinishing()) {
WindowManager wm = (WindowManager) toastItem.currentActivity.get().getSystemService(Context.WINDOW_SERVICE);
wm.removeViewImmediate(toastItem.toast.getView());
}
next();
}
private void next() {
// Log.e("mandy", "now toasts size==" + toasts.size());
if (!toasts.isEmpty()) {
Message message = Message.obtain();
message.what = TOAST_SHOW;
sendMessageDelayed(message, DELAYED_TIME);
}
}
private static class ToastItem {
private Toast toast;
//防泄露,理论应该不存在
private WeakReference currentActivity;
}
}
使用方式
把以上三个类拷贝到同一个文件中即可使用,常规使用方式如下
Toast.makeText(context, "hello world!!", Toast.LENGTH_SHORT).show();
只需要改成
Toast toast=Toast.makeText(context, "hello world!!", Toast.LENGTH_SHORT);
ToastAdapter.show(toast);
记得在application的oncreate方法中调用下ToastAdapter的init方法完成初始化工作。
剩下的就是自己慢慢去看源码的实现了,相信有这篇文章的详细解释再去理解代码就更加轻松了。