背景
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 的调用实现。
关键代码如下:
其中 MToast 是TYPE_TOAST
类型的的 Window,这样即使禁掉通知权限,业务代码也可以不作任何修改,继续弹出 Toast。而底层已经被无感知的替换成自己的 MToast 了,以最小的成本达到了目标。
斗争二:BadTokenException
美团 App 在线上经常会上报BadTokenException
Crash,而且集中在 Android 5.0 - Android 7.1.2 的机型上。具体 Crash 堆栈如下:
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 源码中,关键代码如下:
问题验证
通过以上分析showNextToastLocked()
被调用后,如果此时主线程由于其它原因被阻塞导致handleShow()
不能及时调用,从而触发超时逻辑导致 token 失效。主线程阻塞结束后,继续执行 Toast 的 show 方法时,发现 token 已经失效了,于是抛出BadTokenException
异常从而导致上述 Crash。
可以使用以下的代码验证此异常:
解决方案
那么如何解决这个异常呢?首先想到就是对 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.1、7.1.2 和去年 8 月发布的 Android 8.0 系统中,我们的方案出现了另一个异常token null is not valid
,这个异常堆栈如下:
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
权限的,最终还是无法避免这个异常的发生。
源码中关键代码如下:
解决方案
经过调研,发现 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 的国产机型。绕过了通知权限的坑,又跳入了悬浮窗权限的坑,这是不可取的。
其次,使用 Dialog 方式也有明显的缺陷,Dialog、DialogFragment、PopupWindow 都严重依赖于 Activity,没有 Activity 作为上下文时,它们是无法创建和显示的,并且简单的通知使用这种控件过重。此外,在 UI 展示和 API 一致性上,几乎和 Toast 没有什么关系,需要额外做封装的成本比较大。
遇到问题
我们在使用 Snackbar 替换 Toast 时遇到了以下两个问题:
Snackbar 弹出的时候,被 Dialog,PopupWindow 等控件遮住。
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 替换成 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 没有区别,不仅解决了用户在禁掉通知的情况下无法看到通知的困境,也降低了客诉率。
作者简介
子尧,美团高级工程师,2017 年加入美团,负责平台搜索、平台首页等研发工作。
腾飞,美团资深工程师,2015 年加入美团,平台基础业务组负责人,负责平台业务的迭代。
评论