背景
  
  Toast是Android平台上的常用技术。从用户角度来看,Toast是用户与App交互最基本的提示控件;从开发者角度来看,Toast是开发过程中常用的调试手段之一。此外,Toast语法也非常简单,仅需一行代码。基于简单易用的优点,Toast在Android开发过程中被广泛使用。
  
  但是,Toast是系统层面提供的,不依赖于前台页面,存在滥用的风险。为了规避这些风险,Google在Android系统版本的迭代过程中,不断进行了优化和限制。这些限制不可避免的影响到了正常的业务逻辑,在迭代过程中,我们遇到过以下几个问题:
  
  设置中关闭某个App的【显示通知】开关,Toast不再弹出,极大的影响了用户体验。
  
  Toast在Android 7.1.2(API25)以下会发生BadTokenException异常,导致App崩溃。
  
  自定义TYPE_TOAST类型的Window,在Android 7.1.1、7.1.2发生token null is not valid异常,导致App崩溃。
  
  与Toast斗争
  
  在美团平台的业务中,Toast被用作主流程交互的提示控件,比如在完成下单、评价、分享后进行各种提示。Toast被限制之后会给用户带来误解。为了解决正常的业务Toast被系统限制误伤的问题,我们与Toast展开了一系列的斗争。
  
  斗争一:Toast不弹出
  
  举个案例:某个用户投诉美团App在分享朋友圈后没有任何提示,不知道是否分享成功。具体原因是用户在设置里关闭了美团App的【显示通知】开关,导致通知权限无法获取,这极大的影响了用户体验。然而,在Android 4.4(API19)以下系统中,这个开关的打开状态,也就是通知权限是否开启的状态我们是无法判断的,因此我们也无法感知Toast弹出与否,为了解决这个问题,需要从Toast的源码入手,最后源码总结步骤如下:
  
  在Toast#show()源码中,Toast的展示并非自己控制,而是通过AIDL使用INotificationManager获取到NotificationManagerService(NMS)这个远程服务。
  
  调用service.enqueueToast(pkg, tn, mDuration)将当前Toast的显示加入到通知队列,并传递了一个tn对象,这个对象就是NMS用作回传Toast的显示状态。
  
  在tn的回调方法中,使用WindowManager将构造的Toast添加到当前的window中,需要注意的是这个window的type类型是TYPE_TOAST。
  
  Toast不弹出原因分析
  
  那么为什么禁掉通知权限会导致Toast不再弹出呢?
  
  通过以上分析,Toast的展示是由NMS服务控制的,NMS服务会做一些权限、token等的校验,当通知权限一旦关闭,Toast将不再弹出。
  
  可行性方案调研
  
  如果能够绕过NMS服务的校验那么就可以达到我们的诉求,绕过的方法是按照Toast的源码,实现我们自己的MToast,并将NMS替换成自己的ToastManager,如下图:
  
  方案定了后,需要做的事情就是代码替换。作为平台型App,美团App大量使用了Toast,人工替换肯定会出现遗漏的地方,为了能用更少的人力来解决这个问题,我们采用了如下方案。
  
  解决方案
  
  美团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();
  
  }
  
  }
  
  1
  
  2
  
  3
  
  4
  
  5
  
  6
  
  7
  
  8
  
  9
  
  10
  
  11
  
  12
  
  13
  
  14
  
  15
  
  16
  
  17
  
  18
  
  19
  
  20
  
  21
  
  22
  
  23
  
  24
  
  25
  
  26
  
  27
  
  28
  
  其中MToast是TYPE_TOAST类型的的Window,这样即使禁掉通知权限,业务代码也可以不作任何修改,继续弹出Toast。而底层已经被无感知的替换成自己的MToast了,以最小的成本达到了目标。
  
  斗争二:BadTokenException
  
  美团App在线上经常会上报BadTokenExceptionCrash,而且集中在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)标记管理。但是我们的普通应用程序怎么能拥有添加系统窗口的权限呢?查看源码后发现需要以下几个步骤:
  
  当显示一个Toast时,NMS会生成一个token,而NMS本身就是一个系统级的服务,所以由它生成的token必然拥有权限添加系统窗口。
  
  NMS通过ITransientNotification也就是tn对象,将生成的token回传到我们自己的应用程序进程中。
  
  应用程序调用handleShow方法,去向WindowManager添加窗口。
  
  WindowManager检查当前窗口的token是否有效,如果有效,则添加窗口展示Toast;如果无效,则抛出上述异常,Crash发生。
  
  详细的原理图如下:
  
  在Android 7.1.1的NMS源码中,关键代码如下:
  
  void showNextToastLocked(www.dongfan178.com) {
  
  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(www.hjshidpt.com/ 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)
  解决方案
  
  经过调研,发现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的可行方案
  
  为了继续能让用户在禁掉通知权限的情况下,也能看到通知以及屏蔽上述Toast带来的Crash,我们经过调研、分析并尝试了以下几种方案。
  
  在7.1.1以上系统中继续使用WindowManager方式,只不过需要把type改为TYPE_PHONE等悬浮窗权限。
  
  使用Dialog、DialogFragment、PopupWindow等弹窗控件来实现一个通知。
  
  按照Snackbar的实现方式,找到一个可以添加布局的父布局,采用addView的方式添加通知。
  
  以上几种方案的共同点是为了绕过通知权限的检查,即使用户禁掉了通知权限,我们自定义的通知依然可以不受影响的弹出来,但是也有很明显的缺陷,如下图:
  
  经过对比,我们也采用了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"/>
  
  1
  
  2
  
  其次,使用Dialog方式也有明显的缺陷,Dialog、DialogFragment、PopupWindow都严重依赖于Activity,没有Activity作为上下文时,它们是无法创建和显示的,并且简单的通知使用这种控件过重。此外,在UI展示和API一致性上,几乎和Toast没有什么关系,需要额外做封装的成本比较大。
  
  遇到问题
  
  我们在使用Snackbar替换Toast时遇到了以下两个问题:
  
  1. Snackbar弹出的时候,被Dialog,www.longboshyl.cn PopupWindow等控件遮住。
  
  2. Snackbar无法进行跨页面展示,这是Snackbar实现原理决定的。
  
  解决方案
  
  首先,为了满足自身业务的扩展性、灵活性,我们参照系统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();
  
  1
  
  2
  
  当直接把Toast替换成Snackbar后,这个消息会一闪而过,用户来不及查看,因为Snackbar依赖的Activity被销毁了,为了解决这个问题,我们一共探讨了三种方案:
  
  方案一:
  
  使用startActivityForResult替换所有跨页面展示的通知,也就是在A页面使用startActivityForResult跳转到B页面,把原本在B页面弹出Toast的逻辑,改写到A页面自己弹出Snackbar。
  
  这种方案:优点在于责任清晰明确,页面被finish后应该展示什么通知以及应该由谁触发这个通知的展示,这个责任本身就在调用方;缺点在于代码改动比较大。因此我们舍弃了这种方案。
  
  方案二:
  
  使用Application.ActivityLifecycleCallbacks全局监听Activity的生命周期,当一个页面关闭的时候,记录下Snackbar剩余需要展示的时间,在进入下一个Activity后,让没有展示完的Snackbar继续展示。
  
  这种方案:优点在于代码改动量小;缺点在于在页面切换过程中,如果Snackbar没有展示结束,会出现一次闪烁。虽然在技术上这种方案很好,代码的侵入性极低,但是这个闪烁对于产品来说无法接受,因此这种方案也不做考虑。
  
  方案三:
  
  使用本地广播进行跨页面展示,这也是美团最终使用的解决方案,具体原理如下
  
  在A页面跳转B页面前,使用当前传入的Context注册一个广播。
  
  在B页面finish之前,发送A在跳转前注册的广播,并把需要展示的消息使用Intent返回。
  
  在广播中获取A页面的实例,使用Snackbar展示B页面回传的消息,并把当前广播unRegister反注册掉。
  
  这是方案一的自动化版本,为了达到自动化的效果和对原有代码的最小侵入性,我们设计了一个辅助类,就是上图中的SnackbarHelper,原理图如下:
  
  SnackbarHelper提供统一的入口,接入成本低,只需要将原有使用context.startActivity()、context.startActivityForResult()、context.finish()的地方改成SnackBarHelper下面的同名方法即可。这样通过广播的方法完成了Snackbar的跨页面展示,业务方的代码修改量仅仅是改一下调用方式,改动极小。
  
  结语
  
  目前这套解决方案在美团业务中被广泛使用,能覆盖到绝大部分场景。通知的展现形式基本与Toast没有区别,不仅解决了用户在禁掉通知的情况下无法看到通知的困境,也降低了客诉率。

05-11 22:41