[Android] Toast问题深度剖析(二)
欢迎大家前往云+社区,获取更多腾讯海量技术实践干货哦~
作者: QQ音乐技术团队
题记
Toast 作为 Android 系统中最常用的类之一,由于其方便的api设计和简洁的交互体验,被我们所广泛采用。但是,伴随着我们开发的深入,Toast 的问题也逐渐暴露出来。 本系列文章将分成两篇: 第一篇,我们将分析 Toast 所带来的问题 第二篇,将提供解决 Toast 问题的解决方案 (注:本文源码基于Android 7.0)
1.回顾
上一篇 [[Android] Toast问题深度剖析(一)] 笔者解释了:
Toast系统如何构建窗口(通过系统服务NotificationManager来生成系统窗口)Toast异常出现的原因(系统调用Toast的时序紊乱)
而本篇的重点,在于解决我们第一章所说的 Toast 问题。
2.解决思路
基于第一篇的知识,我们知道,Toast 的窗口属于系统窗口,它的生成和生命周期依赖于系统服务 NotificationManager。一旦 NotificationManager 所管理的窗口生命周期跟我们本地的进程不一致,就会发生异常。那么,我们能不能不使用系统的窗口,而使用自己的窗口,并且由我们自己控制生命周期呢?事实上, SnackBar 就是这样的方案。不过,如果不使用系统类型的窗口,就意味着你的Toast 界面,无法在其他应用之上显示。(比如,我们经常看到的一个场景就是你在你的应用出调用了多次 Toast.show函数,然后退回到桌面,结果发现桌面也会弹出 Toast,就是因为系统的 Toast 使用了系统窗口,具有高的层级)不过在某些版本的手机上,你的应用可以申请权限,往系统中添加 TYPE_SYSTEM_ALERT 窗口,这也是一种系统窗口,经常用来作为浮层显示在所有应用程序之上。不过,这种方式需要申请权限,并不能做到让所有版本的系统都能正常使用。 如果我们从体验的角度来看,当用户离开了该进程,就不应该弹出另外一个进程的 Toast 提示去干扰用户的。Android 系统似乎也意识到了这一点,在新版本的系统更新中,限制了很多在桌面提示窗口相关的权限。所以,从体验上考虑,这个情况并不属于问题。
“那么我们可以选择哪些窗口的类型呢?”
- 使用子窗口: 在
Android进程内,我们可以直接使用类型为子窗口类型的窗口。在Android代码中的直接应用是PopupWindow或者是Dialog。这当然可以,不过这种窗口依赖于它的宿主窗口,它可用的条件是你的宿主窗口可用 - 采用
View系统: 使用View系统去模拟一个Toast窗口行为,做起来不仅方便,而且能更加快速的实现动画效果,我们的SnackBar就是采用这套方案。这也是我们今天重点讲的方案
“如果采用 View 系统方案,那么我要往哪个控件中添加我的 Toast 控件呢?”
在Android进程中,我们所有的可视操作都依赖于一个 Activity 。 Activity 提供上下文(Context)和视图窗口(Window) 对象。我们通过 Activity.setContentView 方法所传递的任何 View对象 都将被视图窗口( Window) 中的 DecorView 所装饰。而在 DecorView 的子节点中,有一个 id 为 android.R.id.content 的 FrameLayout 节点(后面简称 content 节点) 是用来容纳我们所传递进去的 View 对象。一般情况下,这个节点占据了除了通知栏的所有区域。这就特别适合用来作为 Toast 的父控件节点。
“我什么时机往这个content节点中添加合适呢?这个 content 节点什么时候被初始化呢?”
根据不同的需求,你可能会关注以下两个时机:
Content节点生成Content内容显示
实际我们只需要将我们的 Toast 添加到 Content 节点中,只要满足第一条即可。如果你是为了完成性能检测,测量或者其他目的,那么你可能更关心第二条。 那么什么情况下 Content 节点生成呢?刚才我们说了,Content 节点包含在我们的 DecorView 控件中,而 DecorView 是由 Activity 的 Window对象所持有的控件。Window 在 Android 中的实现类是 PhoneWindow,(这部分代码有兴趣可以自行阅读) 我们来看下源码:
//code PhoneWindow.java
@Override
public void setContentView(int layoutResID) {
if (mContentParent == null) { //mContentParent就是我们的 content 节点
installDecor();//生成一个DecorView
} else {
mContentParent.removeAllViews();
}
mLayoutInflater.inflate(layoutResID, mContentParent);
final Callback cb = getCallback();
if (cb != null && !isDestroyed()) {
cb.onContentChanged();
}
}
PhoneWindow 对象通过 installDecor 函数生成 DecorView 和 我们所需要的 content 节点(最终会存到 mContentParent) 变量中去。但是, setContentView 函数需要我们主动调用,如果我并没有调用这个 setContentView 函数,installDecor 方法将不被调用。那么,有没有某个时刻,content 节点是必然生成的呢?当然有,除了在 setContentView 函数中调用installDecor外,还有一个函数也调用到了这个,那就是:
//code PhoneWindow.java
@Override
public final View getDecorView() {
if (mDecor == null) {
installDecor();
}
return mDecor;
}
而这个函数,将在 Activity.findViewById 的时候调用:
//code Activity.java
public View findViewById(@IdRes int id) {
return getWindow().findViewById(id);
}
//code Window.java
public View findViewById(@IdRes int id) {
return getDecorView().findViewById(id);
}
因此,只要我们只要调用了 findViewById 函数,一样可以保证 content 被正常初始化。这样我们解释了第一个”就绪”(Content 节点生成)。我们再来看下第二个”就绪”,也就是 Android 界面什么时候显示呢?相信你可能迫不及待的回答不是 onResume 回调的时候么?实际上,在 onResume 的时候,根本还没处理跟界面相关的事情。我们来看下 Android 进程是如何处理 resume 消息的: (注: AcitivityThread 是 Android 进程的入口类, Android 进程处理 resume 相关消息将会调用到 AcitivityThread.handleResumeActivity 函数)
//code AcitivityThread.java
void handleResumeActivity(...) {
...
ActivityClientRecord r = performResumeActivity(token, clearHide);
// 之后会调用call onResume
...
View decor = r.window.getDecorView();
//调用getDecorView 生成 content节点
decor.setVisibility(View.INVISIBLE);
....
if (r.activity.mVisibleFromClient) {
r.activity.makeVisible();//add to WM 管理
}
...
}
//code Activity.java
void makeVisible() {
if (!mWindowAdded) {
ViewManager wm = getWindowManager();
wm.addView(mDecor, getWindow().getAttributes());
mWindowAdded = true;
}
mDecor.setVisibility(View.VISIBLE);
}
Android 进程在处理 resume 消息的时候,将走以下的流程:
- 调用
performResumeActivity回调Activity的onResume函数 - 调用
Window的getDecorView生成DecorView对象和content节点 - 将
DecorView纳入WindowManager(进程内服务)的管理 - 调用
Activity.makeVisible显示当前Activity
按照上述的流程,在 Activity.onResume 回调之后,才将控件纳入本地服务 WindowManager 的管理中。也就是说, Activity.onResume 根本没有显示任何东西。我们不妨写个代码验证一下:
//code DemoActivity.java
public DemoActivity extends Activity {
private View view ;
@Override
protected void onCreate( Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
view = new View(this);
this.setContentView(view);
}
@Override
protected void onResume() {
super.onResume();
Log.d("cdw","onResume :" +view.getHeight());// 有高度是显示的必要条件
}
}
这里,我们通过在 onResume 中获取高度的方式验证界面是否被绘制,最终我们将输出日志:
D cdw : onResume :
那么,界面又是在什么时候完成的绘制呢?是不是在 WindowManager.addView 之后呢?我们在 onResume之后会调用Activity.makeVisible,里面会调用 WindowManager.addView。因此我们在onResume 里post一个消息就可以检测WindowManager.addView 之后的情况:
@Override
protected void onResume() {
super.onResume();
this.runOnUiThread(new Runnable() {
@Override
public void run() {
Log.d("cdw","onResume :" +view.getHeight());
}
});
} //控制台输出:
- ::27.445 D cdw : onResume :
从结果上看,我们在 WindowManager.addView 之后,也并没有绘制界面。那么,Android的绘制是什么时候开始的?又是到什么时候结束?
在 Android 系统中,每一次的绘制都是通过一个 16ms 左右的 VSYNC 信号控制的,这种信号可能来自于硬件也可能来自于软件模拟。每一次非动画的绘制,都包含:测量,布局,绘制三个函数。而一般触发这一事件的的动作有:
View的某些属性的变更View重新布局Layout- 增删
View节点
当调用 WindowManager.addView 将空间添加到 WM 服务管理的时候,会调用一次Layout请求,这就触发了一次 VSYNC 绘制。因此,我们只需要在 onResume 里 post 一个帧回调就可以检测绘制开始的时间:

@Override
protected void onResume() {
super.onResume();
Choreographer.getInstance().postFrameCallback(new Choreographer.FrameCallback() {
@Override
public void doFrame(long frameTimeNanos) {
//TODO 绘制开始
}
});
}
我们先来看下 View.requestLayout 是怎么触发界面重新绘制的:
//code View.java
public void requestLayout() {
....
if (mParent != null) {
...
if (!mParent.isLayoutRequested()) {
mParent.requestLayout();
}
}
}
View 对象调用 requestLayout 的时候会委托给自己的父节点处理,这里之所以不称为父控件而是父节点,是因为除了控件外,还有 ViewRootImpl 这个非控件类型作为父节点,而这个父节点会作为整个控件树的根节点。按照我们上面说的委托的机制,requestLayout 最终将会调用到 ViewRootImpl.requestLayout。
//code ViewRootImpl.java
@Override
public void requestLayout() {
if (!mHandlingLayoutInLayoutRequest) {
checkThread();
mLayoutRequested = true;
scheduleTraversals();//申请绘制请求
}
} void scheduleTraversals() {
if (!mTraversalScheduled) {
mTraversalScheduled = true;
....
mChoreographer.postCallback(
Choreographer.CALLBACK_TRAVERSAL, mTraversalRunnable, null);//申请绘制
....
}
}
ViewRootImpl 最终会将 mTraversalRunnable 处理命令放到 CALLBACK_TRAVERSAL 绘制队列中去:
final class TraversalRunnable implements Runnable {
@Override
public void run() {
doTraversal();//执行布局和绘制
}
}
void doTraversal() {
if (mTraversalScheduled) {
mTraversalScheduled = false;
...
performTraversals();
...
}
}
mTraversalRunnable 命令最终会调用到 performTraversals() 函数:
private void performTraversals() {
final View host = mView;
...
host.dispatchAttachedToWindow(mAttachInfo, );//attachWindow
...
getRunQueue().executeActions(attachInfo.mHandler);//执行某个指令
...
childWidthMeasureSpec = getRootMeasureSpec(desiredWindowWidth, lp.width);
childHeightMeasureSpec = getRootMeasureSpec(desiredWindowHeight, lp.height);
host.measure(childWidthMeasureSpec, childHeightMeasureSpec);//测量
....
host.layout(, , host.getMeasuredWidth(), host.getMeasuredHeight());//布局
...
draw(fullRedrawNeeded);//绘制
...
}
performTraversals 函数实现了以下流程:
- 调用
dispatchAttachedToWindow通知子控件树当前控件被attach到窗口中 - 执行一个命令队列
getRunQueue - 执行
meausre测量指令 - 执行
layout布局函数 - 执行绘制
draw
这里我们看到一句方法调用:
getRunQueue().executeActions(attachInfo.mHandler);
这个函数将执行一个延时的命令队列,在 View 对象被 attach 到 View树之前,通过调用 View.post 函数,可以将执行消息命令加入到延时执行队列中去:
//code View.java
public boolean post(Runnable action) {
Handler handler;
AttachInfo attachInfo = mAttachInfo;
if (attachInfo != null) {
handler = attachInfo.mHandler;
} else {
// Assume that post will succeed later
ViewRootImpl.getRunQueue().post(action);
return true;
}
return handler.post(action);
}
getRunQueue().executeActions 函数执行的时候,会将该命令消息延后一个UI线程消息执行,这就保证了执行的这个命令消息发生在我们的绘制之后:
//code RunQueue.java
void executeActions(Handler handler) {
synchronized (mActions) {
...
for (int i = ; i < count; i++) {
final HandlerAction handlerAction = actions.get(i);
handler.postDelayed(handlerAction.action, handlerAction.delay);//推迟一个消息
}
}
}
所以,我们只需要在视图被 attach 之前通过一个 View 来抛出一个命令消息,就可以检测视图绘制结束的时间点:
//code DemoActivity.java
@Override
protected void onResume() {
super.onResume();
Choreographer.getInstance().postFrameCallback(new Choreographer.FrameCallback() {
@Override
public void doFrame(long frameTimeNanos) {
start = SystemClock.uptimeMillis();
log("绘制开始:height = "+view.getHeight());
}
});
} @Override
protected void onCreate( Bundle savedInstanceState) {
super.onCreate(savedInstanceState); view = new View(this);
view.post(new Runnable() {
@Override
public void run() {
log("绘制耗时:"+(SystemClock.uptimeMillis()-start)+"ms");
log("绘制结束后:height = "+view.getHeight());
}
});
this.setContentView(view);
}
//控制台输出:
- ::27.251 D cdw : --->绘制开始:height =
- ::27.295 D cdw : --->绘制耗时:44ms
- ::27.295 D cdw : --->绘制结束后:height =
我们带着我们上面的知识储备,来看下SnackBar是如何做的呢:
3.Snackbar

SnackBar 系统主要依赖于两个类:
SnackBar作为门面,与业务程序交互SnackBarManager作为时序管理器,SnackBar与SnackBarManager的交互,通过Callback回调对象进行
SnackBarManager 的时序管理跟 NotifycationManager 的很类似不再赘述
SnackBar 通过静态方法 make 静态构造一个 SnackBar:
public static Snackbar make(@NonNull View view, @NonNull CharSequence text,
@Duration int duration) {
Snackbar snackbar = new Snackbar(findSuitableParent(view));
snackbar.setText(text);
snackbar.setDuration(duration);
return snackbar;
}
这里有一个关键函数 findSuitableParent ,这个函数的目的就相当于我们上面的 findViewById(R.id.content) 一样,给 SnackBar 所定义的 Toast 控件找一个合适的容器:
private static ViewGroup findSuitableParent(View view) {
ViewGroup fallback = null;
do {
if (view instanceof CoordinatorLayout) {
return (ViewGroup) view;
} else if (view instanceof FrameLayout) {
if (view.getId() == android.R.id.content) {//把 `Content` 节点作为容器
...
return (ViewGroup) view;
} else {
// It's not the content view but we'll use it as our fallback
fallback = (ViewGroup) view;
}
}
...
} while (view != null);
// If we reach here then we didn't find a CoL or a suitable content view so we'll fallback
return fallback;
}
我们发现,除了包含 CoordinatorLayout 控件的情况, 默认情况下, SnackBar 也是找的 Content 节点。找到的这个父节点,作为 Snackbar 构造器的形参:
private Snackbar(ViewGroup parent) {
mTargetParent = parent;
mContext = parent.getContext();
...
LayoutInflater inflater = LayoutInflater.from(mContext);
mView = (SnackbarLayout) inflater.inflate(
R.layout.design_layout_snackbar, mTargetParent, false);
...
}
Snackbar 将生成一个 SnackbarLayout 控件作为 Toast 控件。最后当时序控制器 SnackBarManager 回调返回的时候,通知 SnackBar 显示,即将 SnackBar.mView 增加到 mTargetParent 控件中去。
这里有人或许会有疑问,这里使用强引用,会不会造成一段时间内的内存泄漏呢? 假如你现在弹了 10 个 Toast ,每个 Toast 的显示时间是 2s 。也就是说你的最后一个 SnackBar 将被 SnackBarManager 持有至少 20s。而 SnackBar 中又存在有父控件 mTargetParent 的强引用。相当于在这20s内, 你的mTargetParent 和它所持有的 Context (一般是 Activity)无法释放
这个其实是不会的,原因在于 SnackBarManager 在管理这种回调 callback 的时候,采用了弱引用。
private static class SnackbarRecord {
final WeakReference<Callback> callback;
....
}
但是,我们从 SnackBar 的设计可以看出,SnackBar无法定制具体的样式: SnackBar 只能生成 SnackBarLayout 这种控件和布局,可能并不满足你的业务需求。当然你也可以变更 SnackBarLayout 也能达到目的。不过,有了上面的知识储备,我们完全可以写一个自己的 Snackbar。
4.基于Toast的改法
从第一篇文章我们知道,我们直接在 Toast.show 函数外增加 try-catch 是没有意义的。因为 Toast.show 实际上只是发了一条命令给 NotificationManager 服务。真正的显示需要等 NotificationManager 通知我们的 TN 对象 show 的时候才能触发。NotificationManager 通知给 TN 对象的消息,都会被 TN.mHandler 这个内部对象进行处理
//code Toast.java
private static class TN {
final Runnable mHide = new Runnable() {// 通过 mHandler.post(mHide) 执行
@Override
public void run() {
handleHide();
mNextView = null;
}
};
final Handler mHandler = new Handler() {
@Override
public void handleMessage(Message msg) {
IBinder token = (IBinder) msg.obj;
handleShow(token);// 处理 show 消息
}
};
}
在NotificationManager 通知给 TN 对象显示的时候,TN 对象将给 mHandler 对象发送一条消息,并在 mHandler 的 handleMessage 函数中执行。 当NotificationManager 通知 TN 对象隐藏的时候,将通过 mHandler.post(mHide) 方法,发送隐藏指令。不论采用哪种方式发送的指令,都将执行 Handler 的 dispatchMessage(Message msg) 函数:
//code Handler.java
public void dispatchMessage(Message msg) {
if (msg.callback != null) {
handleCallback(msg);// 执行 post(Runnable)形式的消息
} else {
...
handleMessage(msg);// 执行 sendMessage形式的消息
}
}
因此,我们只需要在 dispatchMessage 方法体内加入 try-catch 就可以避免 Toast 崩溃对应用程序的影响:
public void dispatchMessage(Message msg) {
try {
super.dispatchMessage(msg);
} catch(Exception e) {}
}
因此,我们可以定义一个安全的 Handler 装饰器:
private static class SafelyHandlerWarpper extends Handler {
private Handler impl;
public SafelyHandlerWarpper(Handler impl) {
this.impl = impl;
}
@Override
public void dispatchMessage(Message msg) {
try {
super.dispatchMessage(msg);
} catch (Exception e) {}
}
@Override
public void handleMessage(Message msg) {
impl.handleMessage(msg);//需要委托给原Handler执行
}
}
由于 TN.mHandler 对象复写了 handleMessage 方法,因此,在 Handler 装饰器里,需要将 handleMessage 方法委托给 TN.mHandler 执行。定义完装饰器之后,我们就可以通过反射往我们的 Toast 对象中注入了:
public class ToastUtils {
private static Field sField_TN ;
private static Field sField_TN_Handler ;
static {
try {
sField_TN = Toast.class.getDeclaredField("mTN");
sField_TN.setAccessible(true);
sField_TN_Handler = sField_TN.getType().getDeclaredField("mHandler");
sField_TN_Handler.setAccessible(true);
} catch (Exception e) {}
}
private static void hook(Toast toast) {
try {
Object tn = sField_TN.get(toast);
Handler preHandler = (Handler)sField_TN_Handler.get(tn);
sField_TN_Handler.set(tn,new SafelyHandlerWarpper(preHandler));
} catch (Exception e) {}
}
public static void showToast(Context context,CharSequence cs, int length) {
Toast toast = Toast.makeText(context,cs,length);
hook(toast);
toast.show();
}
}
我们再用第一章中的代码测试一下:
public void showToast(View view) {
ToastUtils.showToast(this,"hello", Toast.LENGTH_LONG);
try {
Thread.sleep();
} catch (InterruptedException e) {}
}
等 10s 之后,进程正常运行,不会因为 Toast 的问题而崩溃。
相关阅读
此文已由作者授权云加社区发布,转载请注明文章出处
[Android] Toast问题深度剖析(二)的更多相关文章
- [Android] Toast问题深度剖析(一)
欢迎大家前往云+社区,获取更多腾讯海量技术实践干货哦~ 题记 Toast 作为 Android 系统中最常用的类之一,由于其方便的api设计和简洁的交互体验,被我们所广泛采用.但是,伴随着我们开发的深 ...
- libevent源码深度剖析二
libevent源码深度剖析二 ——Reactor模式 张亮 前面讲到,整个libevent本身就是一个Reactor,因此本节将专门对Reactor模式进行必要的介绍,并列出libevnet中的几个 ...
- Android对话框Dialog深度剖析
对话框 对话框是提示用户作出决定或输入额外信息的小窗口. 对话框不会填充屏幕,通常用于需要用户采取行动才能继续执行的模式事件. 对话框设计 Dialog 类是对话框的基类,但您应该避免直接实例化 Di ...
- Android硬件抽象层(HAL)深入剖析(二)【转】
上一篇我们分析了android HAL层的主要的两个结构体hw_module_t(硬件模块)和hw_device_t(硬件设备)的成员,下面我们来具体看看上层app到底是怎么实现操作硬件的? 我们知道 ...
- 笔记四:python乱码深度剖析二
一:学习内容 获取更改系统编码 判断字符的编码类型 文件存储和读取的编码 二:获取更改系统编码 1. 获取系统编码 import sys print sys.getdefaultencoding() ...
- Android硬件抽象层(HAL)深入剖析(二)
上一篇我们分析了android HAL层的主要的两个结构体hw_module_t(硬件模块)和hw_device_t(硬件设备)的成员,下面我们来具体看看上层app到底是怎么实现操作硬件的? 我们知道 ...
- Django深度剖析-二
WEBserver处理过程 先写个大家熟悉的socketserver例子 #! /usr/bin/env python # encoding: utf-8 """ @Au ...
- Android实训案例(四)——关于Game,2048方块的设计,逻辑,实现,编写,加上色彩,分数等深度剖析开发过程!
Android实训案例(四)--关于Game,2048方块的设计,逻辑,实现,编写,加上色彩,分数等深度剖析开发过程! 关于2048,我看到很多大神,比如医生,郭神,所以我也研究了一段时间,还好是研究 ...
- Android应用开发以及设计思想深度剖析
Android应用开发以及设计思想深度剖析(1) 21cnbao.blog.51cto.com/109393/956049
随机推荐
- Spring框架学习笔记(10)——Spring中的事务管理
什么是事务 举例:A给B转500,两个动作,A的账户少500,B的账户多500 事务就是一系列的动作, 它们被当做一个单独的工作单元. 这些动作要么全部完成, 要么全部不起作用 一.注解添加事务管理方 ...
- java与C++变量初始化的对比
java尽力保证:所有变量在使用前都能得到恰当的初始化 ①函数/方法局部变量的初始化 在C/C++中,变量的初始化还是得依赖于程序员的自觉性.对于函数局部变量,编译器不会为基本类型赋予默认初始值,新手 ...
- RabbitMQ 使用demo
1.新建一个控制台应用程序:如图 2.代码如下: using RabbitMQ.Client;using RabbitMQ.Client.Events;using System;using Syste ...
- [国嵌攻略][117][LED驱动程序设计]
LED程序设计 1.编写内核模块 2.搭建字符驱动框架 3.实现设备方法 头文件 <linux/io.h> writel() 1.编译/安装驱动 make cp leddev.ko ... ...
- Spark算子--reduceByKey
reduceByKey--Transformation类算子 代码示例 result
- 根据PV统计出前三的热门板块,并统计出热门板块下的用户数--方式一
根据PV统计出前三的热门板块,并统计出热门板块下的用户数--方式一 测试数据 java代码 package com.hzf.spark.study; import java.util.ArrayLis ...
- 如何把本地项目上传到Github
作为一个有追求的程序员,需要撸点自己的开源项目,虽然我现在只是在学着造轮子,但这并不影响我成为大神的心. Github是基于git实现的代码托管,很多程序员在上面托管自己的开源项目,我使用Github ...
- React版本更新及升级须知(持续更新)
p.p1 { margin: 0.0px 0.0px 0.0px 0.0px; text-align: center; font: 18.0px "PingFang SC Semibold& ...
- /dev/shm 引起的内存统计问题
最近,有个同事问我,怎么准确地描述linux系统到底还有多少内存可供我使用.这里不扯内存碎片问题,就说剩余总量. 如下: cat /proc/meminfo MemTotal: 263796812 k ...
- js 原型规则与示例
五大规则 1. 所有的引用类型( 数组 对象 函数 ) 都是 具有对象特性即自由拓展属性 (除了 "null")意外 2. 所有的引用类型(数组 对象 函数 ) 都有一个 prot ...