10 月,开发者不可错过的开源大数据大会-2021 WeDataSphere 社区大会深圳站 了解详情
写点什么

Toast 与 Snackbar 的那点事

2020 年 2 月 25 日

Toast与Snackbar的那点事

背景

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 的源码入手,最后源码总结步骤如下:


  1. Toast#show()源码中,Toast 的展示并非自己控制,而是通过 AIDL 使用 INotificationManager 获取到NotificationManagerService(NMS)这个远程服务。

  2. 调用service.enqueueToast(pkg, tn, mDuration)将当前 Toast 的显示加入到通知队列,并传递了一个 tn 对象,这个对象就是 NMS 用作回传 Toast 的显示状态。

  3. 在 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 的调用实现。


关键代码如下:


@Aspectpublic 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 在线上经常会上报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)标记管理。但是我们的普通应用程序怎么能拥有添加系统窗口的权限呢?查看源码后发现需要以下几个步骤:


  1. 当显示一个 Toast 时,NMS 会生成一个 token,而 NMS 本身就是一个系统级的服务,所以由它生成的 token 必然拥有权限添加系统窗口。

  2. NMS 通过 ITransientNotification 也就是 tn 对象,将生成的 token 回传到我们自己的应用程序进程中。

  3. 应用程序调用 handleShow 方法,去向 WindowManager 添加窗口。

  4. WindowManager 检查当前窗口的 token 是否有效,如果有效,则添加窗口展示 Toast;如果无效,则抛出上述异常,Crash 发生。


详细的原理图如下:



在 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.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 的可行方案

为了继续能让用户在禁掉通知权限的情况下,也能看到通知以及屏蔽上述 Toast 带来的 Crash,我们经过调研、分析并尝试了以下几种方案。


  1. 在 7.1.1 以上系统中继续使用 WindowManager 方式,只不过需要把 type 改为 TYPE_PHONE 等悬浮窗权限。

  2. 使用 Dialog、DialogFragment、PopupWindow 等弹窗控件来实现一个通知。

  3. 按照 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"/>
复制代码


其次,使用 Dialog 方式也有明显的缺陷,Dialog、DialogFragment、PopupWindow 都严重依赖于 Activity,没有 Activity 作为上下文时,它们是无法创建和显示的,并且简单的通知使用这种控件过重。此外,在 UI 展示和 API 一致性上,几乎和 Toast 没有什么关系,需要额外做封装的成本比较大。


遇到问题

我们在使用 Snackbar 替换 Toast 时遇到了以下两个问题:


  1. Snackbar 弹出的时候,被 Dialog,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();
复制代码


当直接把 Toast 替换成 Snackbar 后,这个消息会一闪而过,用户来不及查看,因为 Snackbar 依赖的 Activity 被销毁了,为了解决这个问题,我们一共探讨了三种方案:


方案一


使用startActivityForResult替换所有跨页面展示的通知,也就是在 A 页面使用startActivityForResult跳转到 B 页面,把原本在 B 页面弹出 Toast 的逻辑,改写到 A 页面自己弹出 Snackbar。



这种方案:优点在于责任清晰明确,页面被 finish 后应该展示什么通知以及应该由谁触发这个通知的展示,这个责任本身就在调用方;缺点在于代码改动比较大。因此我们舍弃了这种方案。


方案二


使用Application.ActivityLifecycleCallbacks全局监听 Activity 的生命周期,当一个页面关闭的时候,记录下 Snackbar 剩余需要展示的时间,在进入下一个 Activity 后,让没有展示完的 Snackbar 继续展示。


这种方案:优点在于代码改动量小;缺点在于在页面切换过程中,如果 Snackbar 没有展示结束,会出现一次闪烁。虽然在技术上这种方案很好,代码的侵入性极低,但是这个闪烁对于产品来说无法接受,因此这种方案也不做考虑。


方案三


使用本地广播进行跨页面展示,这也是美团最终使用的解决方案,具体原理如下


  1. 在 A 页面跳转 B 页面前,使用当前传入的 Context 注册一个广播。

  2. 在 B 页面 finish 之前,发送 A 在跳转前注册的广播,并把需要展示的消息使用 Intent 返回。

  3. 在广播中获取 A 页面的实例,使用 Snackbar 展示 B 页面回传的消息,并把当前广播 unRegister 反注册掉。


这是方案一的自动化版本,为了达到自动化的效果和对原有代码的最小侵入性,我们设计了一个辅助类,就是上图中的SnackbarHelper,原理图如下:



SnackbarHelper 提供统一的入口,接入成本低,只需要将原有使用 context.startActivity()、context.startActivityForResult()、context.finish()的地方改成 SnackBarHelper 下面的同名方法即可。这样通过广播的方法完成了 Snackbar 的跨页面展示,业务方的代码修改量仅仅是改一下调用方式,改动极小。


结语

目前这套解决方案在美团业务中被广泛使用,能覆盖到绝大部分场景。通知的展现形式基本与 Toast 没有区别,不仅解决了用户在禁掉通知的情况下无法看到通知的困境,也降低了客诉率。


作者简介

  • 子尧,美团高级工程师,2017 年加入美团,负责平台搜索、平台首页等研发工作。

  • 腾飞,美团资深工程师,2015 年加入美团,平台基础业务组负责人,负责平台业务的迭代。


2020 年 2 月 25 日 20:32231

评论

发布
暂无评论
发现更多内容

手撸二叉树之最小高度树

HelloWorld杰少

数据结构与算法 8月日更

Java开发究竟该如何学习,一文轻松搞定

Geek_f90455

Java 程序员 后端

中高级Java大厂高频面试题,已开源下载

JVM调优资料

Java 程序员 后端

几百行代码写个Mybatis,原理搞的透透的!

小傅哥

Java spring 源码 mybatis 代理

如何使用 DDD 指导微服务拆分?

架构精进之路

微服务 DDD 8月日更

硬核万字长文,深入理解 Java 字节码指令(建议收藏)

沉默王二

Java

是谁,在暗中观察

skow

Java 后端 Java设计模式 8月日更

【Vue2.x 源码学习】第二十三篇 - 依赖收集 - 视图更新部分

Brave

源码 vue2 8月日更

Java开发热门前沿知识,架构师必备技能

Geek_f90455

Java 程序员 后端

Java开发者值得深入思考的几个问题,建议收藏

Geek_f90455

Java 程序员 后端

Ubuntu 与 Mac 共享文件

TroyLiu

ubuntu 效率 Mac 文件传输 共享文件

synchronized 加锁 this 和 class 的区别!

王磊

Java 并发 8月日更

Java多线程实现方式及并发与同步,写的太详细了

Geek_f90455

Java 程序员 后端

Java开发者必须收藏的8个开源库,吊打面试官系列!

Geek_f90455

Java 程序员 后端

MediaMuxer实用封装

Changing Lin

8月日更

三面拼多多,一篇文章帮你解答

JVM调优资料

Java 程序员 后端

Java多线程从基础到并发模型统统帮你搞定!面试总结

Geek_f90455

Java 程序员 后端

用5W1H告诉你如何规划合理的测试策略

华为云开发者社区

敏捷 敏捷开发 测试 测试策略 缺陷

十大排序算法--选择排序

阿粤Ayue

排序算法 8月日更

FILECOIN矿池挖矿APP系统开发案例

获客I3O6O643Z97

挖矿矿池系统开发案例 fil挖矿

Hudi自带工具DeltaStreamer的实时入湖最佳实践

华为云开发者社区

大数据 Hudi

FastApi-01-初识

Python研究所

FastApi 8月日更

波宝TronLink钱包APP系统开发介绍

橙子区块链l53o56oloo3

钱包系统开发 DAPP智能合约交易系统开发 波宝钱包

Java大厂74道高级面试合集,附面试题

Geek_f90455

Java 程序员 后端

Java工程师跳槽经验分享,看完跪了

Geek_f90455

Java 程序员 后端

MySQL触发器介绍

Simon

MySQL

Redis挂了,流量把数据库也打挂了,怎么办?

why技术

Java 面试

☕️【系统设计】如何设计出优雅且实用的API接口

码农架构

Java 架构设计 架构设计实战

Java开发岗还不会这些问题,一文轻松搞定

Geek_f90455

Java 程序员 后端

极光开发者周刊【No.0730】

极光开发者

【Jackson技术专题】全方位系统化学习和使用指南

李浩宇/Alex

Jackson JSON库 JSON序列化 8月日更

Toast与Snackbar的那点事-InfoQ