背景
  
  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没有区别,不仅解决了用户在禁掉通知的情况下无法看到通知的困境,也降低了客诉率。

Toast与Snackbar的那点事的更多相关文章

  1. Android开发——你真的了解Dialog、Toast和Snackbar吗

    0. 前言 今天给大家带来一篇简单易懂的关于Android提醒小功能的文章.Dialog和Toast我们都不陌生,而Snackbar是Design Support库中提供的新控件,有些朋友可能还不了解 ...

  2. Android Design Support Library——Snackbar

    Snackbar是一个轻量级控件,它可以很方便的提供消息的提示和动作反馈,类似于Toast.Snackbar包括一段文字信息与一个可选的操作按钮,超时自动隐藏,也可以通过滑动来删除.效果如下所示: S ...

  3. 【Android - MD】之Snackbar的使用

    Snackbar 是 Android 5.0 新特性--Material Design 中的一个控件,用来代替 Toast ,Snackbar与Toast的主要区别是:Snackbar可以滑动退出,也 ...

  4. SnackbarUtilDemo【Snackbar的封装类】

    版权声明:本文为HaiyuKing原创文章,转载请注明出处! 前言 这个工具类参考的是<没时间解释了,快使用Snackbar!——Android Snackbar花式使用指南>,代码几乎一 ...

  5. Android Snackbar使用方法及小技巧-design

    Snackbar和Toast相似,都是为了给用户提供交互信息,Snackbar是固定在底部的,显示时从下往上滑出 要使用Snackbar,需要在项目的build.gradle中添加依赖 depende ...

  6. 【Android - 控件】之MD - Snackbar的使用

    Snackbar 是 Android 5.0 新特性——Material Design 中的一个控件,用来代替 Toast ,Snackbar与Toast的主要区别是:Snackbar可以滑动退出,也 ...

  7. Android组件内核之间组件间通信方案(四)下篇

    阿里P7Android高级架构进阶视频免费学习请点击:https://space.bilibili.com/474380680本篇文章将继续从以下两个内容来介绍通信方案: [ViewModel 与 V ...

  8. JAVA爬虫挖取CSDN博客文章

    开门见山,看看这个教程的主要任务,就去csdn博客,挖取技术文章,我以<第一行代码–安卓>的作者为例,将他在csdn发表的额博客信息都挖取出来.因为郭神是我在大学期间比较崇拜的对象之一.他 ...

  9. 一个Activity掌握Design新控件 (转)

    原文地址:http://blog.csdn.net/lavor_zl/article/details/51295364 谷歌在推出Android5.0的同时推出了全新的设计Material Desig ...

随机推荐

  1. activeX 开发

    转自 (http://www.cnblogs.com/chinadhf/archive/2010/09/03/1817336.html),并且在开发过程中遇到的问题进行了补充说明,让新手少走弯路 本文 ...

  2. Windows网络通信(一):socket同步编程

    网络通信常用API 1. WSAStartup用于初始化WinSock环境 int WSAStartup( WORD wVersionRequested, LPWSADATA lpWSAData ); ...

  3. gitlab runner 填坑记

    一.Gitlab Runner  CI/CD 错误: Couldn't connect to Docker daemon at http+docker://localhost - is it runn ...

  4. php使用mysql之sql注入(功)

    sql注入就是用户通过构造sql语句,完成sql一系列操作 准备素材如下: 这是test.html <!DOCTYPE html> <html> <meta charse ...

  5. ovs源码阅读--流表查询原理

    背景 在ovs交换机中,报文的处理流程可以划分为一下三个步骤:协议解析,表项查找和动作执行,其中最耗时的步骤在于表项查找,往往一个流表中有数目巨大的表项,如何根据数据报文的信息快速的查找到对应的流表项 ...

  6. 【Python入门总结】

    用了两周时间将python的基本语法和模块过了一遍,alex的视频也简单看了下;并且在项目中直接上了python解析语义的实现,初步感觉到了python语言的魅力.下一步,会按照廖雪峰的python学 ...

  7. Amazon Seller Central is Temporarily Unavailable

    Seller Central is Temporarily Unavailable We apologize for the inconvenience. Our technical staff is ...

  8. SQL行列轉換方法(詳細例子)

    普通行列转换(version 1.0)仅针对sql server 2000提供静态和动态写法,version 2.0增加sql server 2005的有关写法. 问题:假设有张学生成绩表(tb)如下 ...

  9. Java 学习笔记 ------第三章 基础语法

    本章学习目标: 认识类型与变量 学习运算符的基本使用 了解类型转换细节 运用基本流程语法 一.类型(基本类型) 所谓基本类型,就是在使用时,得考虑一下数据用多少内存长度存比较经济,利用程序语法告诉JV ...

  10. TDGA-需求分析

    李青:绝对的技术控,团队中扮演“猪”的角色,勤干肯干,是整个团队的主心骨,课上紧跟老师的步伐,下课谨遵老师的指令,课堂效率高,他的编程格言“没有编不出来的程序,只有解决不了的bug”. 胡金辉:半两油 ...