Toast是Android平台上的经常使用技术。从用户角度来看,Toast是用户与App交互最基本的提示控件;从开发者角度来看,Toast是开发过程当中经常使用的调试手段之一。此外,Toast语法也很是简单,仅需一行代码。基于简单易用的优势,Toast在Android开发过程当中被普遍使用。html
可是,Toast是系统层面提供的,不依赖于前台页面,存在滥用的风险。为了规避这些风险,Google在Android系统版本的迭代过程当中,不断进行了优化和限制。这些限制不可避免的影响到了正常的业务逻辑,在迭代过程当中,咱们遇到过如下几个问题:java
BadTokenException
异常,致使App崩溃。TYPE_TOAST
类型的Window,在Android 7.1.一、7.1.2发生token null is not valid
异常,致使App崩溃。在美团平台的业务中,Toast被用做主流程交互的提示控件,好比在完成下单、评价、分享后进行各类提示。Toast被限制以后会给用户带来误解。为了解决正常的业务Toast被系统限制误伤的问题,咱们与Toast展开了一系列的斗争。android
举个案例:某个用户投诉美团App在分享朋友圈后没有任何提示,不知道是否分享成功。具体缘由是用户在设置里关闭了美团App的【显示通知】开关,致使通知权限没法获取,这极大的影响了用户体验。然而,在Android 4.4(API19)如下系统中,这个开关的打开状态,也就是通知权限是否开启的状态咱们是没法判断的,所以咱们也没法感知Toast弹出与否,为了解决这个问题,须要从Toast的源码入手,最后源码总结步骤以下:编程
Toast#show()
源码中,Toast的展现并不是本身控制,而是经过AIDL使用INotificationManager获取到NotificationManagerService(NMS)这个远程服务。service.enqueueToast(pkg, tn, mDuration)
将当前Toast的显示加入到通知队列,并传递了一个tn对象,这个对象就是NMS用做回传Toast的显示状态。WindowManager
将构造的Toast添加到当前的window中,须要注意的是这个window的type类型是TYPE_TOAST
。那么为何禁掉通知权限会致使Toast再也不弹出呢? 经过以上分析,Toast的展现是由NMS
服务控制的,NMS
服务会作一些权限、token等的校验,当通知权限一旦关闭,Toast将再也不弹出。app
若是可以绕过NMS
服务的校验那么就能够达到咱们的诉求,绕过的方法是按照Toast的源码,实现咱们本身的MToast,并将NMS替换成本身的ToastManager,以下图:ide
方案定了后,须要作的事情就是代码替换。做为平台型App,美团App大量使用了Toast,人工替换确定会出现遗漏的地方,为了能用更少的人力来解决这个问题,咱们采用了以下方案。oop
美团App在早期就因业务须要接入了AspectJ,AspectJ是Java中作AOP编程的利器,基本原理就是在代码编译期对切面的代码进行修改,插入咱们预先写好的逻辑或者直接替换当前方法的实现。美团App的作法就是借用AspectJ,从源头拦截并替换Toast的调用实现。布局
关键代码以下:测试
@Aspect
public class ToastAspect {
@Pointcut("call(* android.widget.Toast+.show(..))")
public void toastShow() {
}
@Around("toastShow()")
public void toastShow(ProceedingJoinPoint point) {
Toast toast = (Toast) point.getTarget();
Context context = (Context) ReflectUtils.getValue(toast, "mContext");
if (Build.VERSION.SDK_INT >= 19 && NotificationManagerCompat.from(context).areNotificationsEnabled()) {
point.proceed(point.getArgs());
} else {
floatToastShow(toast, context);
}
}
private static void floatToastShow(Toast toast, Context context) {
...
new MToast(context)
.setDuration(mDuration)
.setView(mNextView)
.setGravity(mGravity, mX, mY)
.setMargin(mHorizontalMargin, mVerticalMargin)
.show();
}
}
复制代码
其中MToast是TYPE_TOAST
类型的的Window,这样即便禁掉通知权限,业务代码也能够不做任何修改,继续弹出Toast。而底层已经被无感知的替换成本身的MToast了,以最小的成本达到了目标。优化
BadTokenException
美团App在线上常常会上报BadTokenException
Crash,并且集中在Android 5.0 - Android 7.1.2的机型上。具体Crash堆栈以下:
android.view.WindowManager$BadTokenException: Unable to add window -- token android.os.BinderProxy@6caa743 is not valid; is your activity running?
at android.view.ViewRootImpl.setView(ViewRootImpl.java:607)
at android.view.WindowManagerGlobal.addView(WindowManagerGlobal.java:341)
at android.view.WindowManagerImpl.addView(WindowManagerImpl.java:106)
at android.app.ActivityThread.handleResumeActivity(ActivityThread.java:3242)`BadTokenException`
at android.app.ActivityThread.handleLaunchActivity(ActivityThread.java:2544)
at android.app.ActivityThread.access$900(ActivityThread.java:168)
at android.app.ActivityThread$H.handleMessage(ActivityThread.java:1378)
at android.os.Handler.dispatchMessage(Handler.java:102)
at android.os.Looper.loop(Looper.java:150)
at android.app.ActivityThread.main(ActivityThread.java:5665)
at java.lang.reflect.Method.invoke(Native Method)
at com.android.internal.os.ZygoteInit$MethodAndArgsCaller.run(ZygoteInit.java:822)
at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:712)
复制代码
BadTokenException
缘由分析咱们知道在Android上,任何视图的显示都要依赖于一个视图窗口Window,一样Toast的显示也须要一个窗口,前文已经分析了这个窗口的类型就是TYPE_TOAST,是一个系统窗口,这个窗口最终会被WindowManagerService(WMS)标记管理。可是咱们的普通应用程序怎么能拥有添加系统窗口的权限呢?查看源码后发现须要如下几个步骤:
详细的原理图以下:
在Android 7.1.1的NMS源码中,关键代码以下:
void showNextToastLocked() {
ToastRecord record = mToastQueue.get(0);
while (record != null) {
try {
// 调用tn对象的show方法展现toast,并回传token
record.callback.show(record.token);
// 超时处理
scheduleTimeoutLocked(record);
return;
} catch (RemoteException e) {
...
}
}
}
private void scheduleTimeoutLocked(ToastRecord r) {
mHandler.removeCallbacksAndMessages(r);
Message m = Message.obtain(mHandler, MESSAGE_TIMEOUT, r);
long delay = r.duration == Toast.LENGTH_LONG ? LONG_DELAY : SHORT_DELAY;
// 根据toast显示的时长,延迟触发消息,最终调用下面的方法
mHandler.sendMessageDelayed(m, delay);
}
private void handleTimeout(ToastRecord record) {
synchronized (mToastQueue) {
int index = indexOfToastLocked(record.pkg, record.callback);
if (index >= 0) {
cancelToastLocked(index);
}
}
}
void cancelToastLocked(int index) {
ToastRecord record = mToastQueue.get(index);
try {
// 调用tn对象的hide方法隐藏toast
record.callback.hide();
} catch (RemoteException e) {
...
}
ToastRecord lastToast = mToastQueue.remove(index);
// 移除当前的toast的token,token就此失效
mWindowManagerInternal.removeWindowToken(lastToast.token, true, DEFAULT_DISPLAY);
...
}
复制代码
经过以上分析showNextToastLocked()
被调用后,若是此时主线程因为其它缘由被阻塞致使handleShow()
不能及时调用,从而触发超时逻辑致使token失效。主线程阻塞结束后,继续执行Toast的show方法时,发现token已经失效了,因而抛出BadTokenException
异常从而致使上述Crash。
可使用如下的代码验证此异常:
Toast.makeText(this, "测试Crash", Toast.LENGTH_SHORT).show();
try {
Thread.sleep(5000);
} catch (InterruptedException e) {
e.printStackTrace();
}
复制代码
那么如何解决这个异常呢?首先想到就是对Toast加上try-catch,可是发现不起做用,缘由是这个异常并不是在当前线程中当即被抛出的,而是添加到了消息队列中,等待消息真正执行时才会被抛出。Google在Android 8.0的代码提交中修复了这个问题,把8.0的源码和前一版本对比能够发现,如同咱们的分析,Google在消息执行处将异常catch住了。那么针对8.0以前的版本发生的Crash怎么办呢?美团平台使用了一个相似代理反射的通用解决方案,结构以下图:
基本原理:使用咱们本身实现的ToastHandler替换Toast内部的Handler,ToastHandler做用就是把异常catch住,这种修改思路和Android 8.0修复思路保持一致,只不过一个是在系统层面解决,一个是在用户层面解决。
token null is not valid
在Android 7.1.一、7.1.2和去年8月发布的Android 8.0系统中,咱们的方案出现了另外一个异常token null is not valid
,这个异常堆栈以下:
android.view.WindowManager$BadTokenException: Unable to add window -- token null is not valid; is your activity running?
at android.view.ViewRootImpl.setView(ViewRootImpl.java:683)
at android.view.WindowManagerGlobal.addView(WindowManagerGlobal.java:342)
at android.view.WindowManagerImpl.addView(WindowManagerImpl.java:94)
复制代码
token null is not valid
缘由分析这个异常其实并不是是Toast的异常,而是Google对WindowManage的一些限制致使的。Android从7.1.1版本开始,对WindowManager作了一些限制和修改,特别是TYPE_TOAST
类型的窗口,必需要传递一个token用于权限校验才容许添加。Toast源码在7.1.1及以上也有了变化,Toast的WindowManager.LayoutParams参数额外添加了一个token属性,这个属性的来源就已经在上文分析过了,它是在NMS中被初始化的,用于对添加的窗口类型进行校验。当用户禁掉通知权限时,因为AspectJ的存在,最终会调用咱们封装的MToast,可是MToast没有通过NMS,所以没法获取到这个属性,另外就算咱们按照NMS的方法本身生成一个token,这个token也是没有添加TYPE_TOAST
权限的,最终仍是没法避免这个异常的发生。
源码中关键代码以下:
// 方法签名多了一个IBinder类型的token,它是在NMS中建立的
public void handleShow(IBinder windowToken) {
...
if (mView != mNextView) {
...
mWM = (WindowManager)context.getSystemService(Context.WINDOW_SERVICE);
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;
// 这里添加了token
mParams.token = windowToken;
if (mView.getParent() != null) {
if (localLOGV) Log.v(TAG, "REMOVE! " + mView + " in " + this);
mWM.removeView(mView);
}
...
try {
// 8.0版本的系统,将这里的异常catch住了
mWM.addView(mView, mParams);
trySendAccessibilityEvent();
} catch (WindowManager.BadTokenException e) {
/* ignore */
}
}
}
复制代码
通过调研,发现Google对WindowManager的限制,让咱们不得不放弃使用TYPE_TOAST
类型的窗口替代Toast,也表明了咱们上述使用WindowManager方案的终结。
咱们的核心目标只是但愿在用户关闭通知消息开关的状况下,能继续看到通知,因此咱们使用了WindowManager添加自定义window的方式来替换Toast,可是在替换的过程当中遇到了一些Toast的Crash异常,为了解决这些Crash,咱们提出了使用自定义ToastHandler的方式来catch住异常,确保app正常运行。在方案推广上,为了能用更少的人力,更高的效率完成替换,咱们使用了AspectJ的方案。最后,在Android 7.1.1版本开始,因为Google对WindowManager的限制,致使这种使用自定义window的替换Toast的方式再也不可行,咱们便开始寻找替换Toast的其它可行方案。
为了继续能让用户在禁掉通知权限的状况下,也能看到通知以及屏蔽上述Toast带来的Crash,咱们通过调研、分析并尝试了如下几种方案。
以上几种方案的共同点是为了绕过通知权限的检查,即便用户禁掉了通知权限,咱们自定义的通知依然能够不受影响的弹出来,可是也有很明显的缺陷,以下图:
通过对比,咱们也采用了Snackbar替换Toast的方案,缘由是Snackbar是Android自5.0系统推出MaterialDesign后官方推荐的控件,在交互友好性方面比Toast要好,例如:支持手势操做,支持与CoordinatorLayout联动等,Snackbar做为提示控件目前在市面上也被普遍使用,而其它方案有明显的缺陷以下:
首先,使用WindowManager添加悬浮窗的方式,虽然这种方式能和原生的Toast保持完美的一致性,可是须要的权限过高,坑也太多。TYPE_PHONE
的权限要比TYPE_TOAST
权限敏感太多,并且在Android 8.0系统上必须使用TYPE_APPLICATION_OVERLAY
这个type,而且要申请如下两个权限,这两个权限不只须要在清单文件中声明,并且绝大部分手机默认是关闭状态,须要咱们引导用户开启,若是用户选择不开启,那么Toast仍是不能弹出。同时还须要适配众多定制化ROM的国产机型。绕过了通知权限的坑,又跳入了悬浮窗权限的坑,这是不可取的。
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
<uses-permission android:name="android.permission.SYSTEM_OVERLAY_WINDOW"/>
复制代码
其次,使用Dialog方式也有明显的缺陷,Dialog、DialogFragment、PopupWindow都严重依赖于Activity,没有Activity做为上下文时,它们是没法建立和显示的,而且简单的通知使用这种控件太重。此外,在UI展现和API一致性上,几乎和Toast没有什么关系,须要额外作封装的成本比较大。
咱们在使用Snackbar替换Toast时遇到了如下两个问题:
首先,为了知足自身业务的扩展性、灵活性,咱们参照系统Snackbar的源码,进行了按需定制,好比多样化的样式扩展、进入进出的动画扩展、支持自定义布局的扩展等,接口更加丰富。一方面是为了解决以上遇到的问题,另外一方面也是为了在业务的迭代过程当中能快速开发和适配。如下是基本的类图依赖关系:
针对Snackbar弹出的时候,被Dialog,PopupWindow等控件遮住的问题,缘由在于Snackbar依赖于View,当把Activity布局的View传给Snackbar作为Snackbar展现依赖的父View时,后面再弹Dialog,PopupWindow等控件,Snackbar就会被控件遮挡。正确的作法是直接把PopupWindow和Dialog所依赖的View传给Snackbar。那么咱们定制化的Snackbar不只支持传递这个View,也支持直接传递PopupWindow和Dialog的实例,上图中SnackbarBuilder的方法反应了这个改动。
比较复杂的问题是Snackbar不支持跨页面展现,咱们在项目中有大量这样的代码:
Toast.makeText(this, "弹出消息", Toast.LENGTH_SHORT).show();
finish();
复制代码
当直接把Toast替换成Snackbar后,这个消息会一闪而过,用户来不及查看,由于Snackbar依赖的Activity被销毁了,为了解决这个问题,咱们一共探讨了三种方案:
方案一: 使用startActivityForResult
替换全部跨页面展现的通知,也就是在A页面使用startActivityForResult
跳转到B页面,把本来在B页面弹出Toast的逻辑,改写到A页面本身弹出Snackbar。
这种方案:优势在于责任清晰明确,页面被finish后应该展现什么通知以及应该由谁触发这个通知的展现,这个责任自己就在调用方;缺点在于代码改动比较大。所以咱们舍弃了这种方案。
方案二: 使用Application.ActivityLifecycleCallbacks
全局监听Activity的生命周期,当一个页面关闭的时候,记录下Snackbar剩余须要展现的时间,在进入下一个Activity后,让没有展现完的Snackbar继续展现。
这种方案:优势在于代码改动量小;缺点在于在页面切换过程当中,若是Snackbar没有展现结束,会出现一次闪烁。虽然在技术上这种方案很好,代码的侵入性极低,可是这个闪烁对于产品来讲没法接受,所以这种方案也不作考虑。
方案三: 使用本地广播进行跨页面展现,这也是美团最终使用的解决方案,具体原理以下
这是方案一的自动化版本,为了达到自动化的效果和对原有代码的最小侵入性,咱们设计了一个辅助类,就是上图中的SnackbarHelper
,原理图以下:
SnackbarHelper提供统一的入口,接入成本低,只须要将原有使用context.startActivity()、context.startActivityForResult()、context.finish()的地方改为SnackBarHelper下面的同名方法便可。这样经过广播的方法完成了Snackbar的跨页面展现,业务方的代码修改量仅仅是改一下调用方式,改动极小。
目前这套解决方案在美团业务中被普遍使用,能覆盖到绝大部分场景。通知的展示形式基本与Toast没有区别,不只解决了用户在禁掉通知的状况下没法看到通知的困境,也下降了客诉率。
子尧,美团点评高级工程师,2017年加入美团点评,负责平台搜索、平台首页等研发工做。
腾飞,美团点评资深工程师,2015年加入美团点评,平台基础业务组负责人,负责平台业务的迭代。
对咱们团队感兴趣,能够关注咱们的专栏。美团平台客户端技术团队长期招聘技术专家,有兴趣的同窗能够发送简历到:fangjintao#meituan.com,详细JD。