[Android] Toast问题深度剖析(二)
题记
Toast 作为 Android 系统中最常用的类之一,由于其方便的api设计和简洁的交互体验,被我们所广泛采用。但是,伴随着我们开发的深入,Toast 的问题也逐渐暴露出来。本系列文章将分成两篇:第一篇,我们将分析 Toast 所带来的问题第二篇,将提供解决 Toast 问题的解决方案
(注:本文源码基于Android 7.0)
1.回顾
上一篇[[Android] Toast问题深度剖析(一)]解释了:
- Toast 系统如何构建窗口(通过系统服务NotificationManager来生成系统窗口)
- 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());// 有高度是显示的必要条件
}
}
那么,界面又是在什么时候完成的绘制呢?是不是在 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());
}
});
}
//控制台输出:
01-02 21:30:27.445 2562 2562 D cdw : onResume :0
从结果上看,我们在 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, 0);//attachWindow
...
getRunQueue().executeActions(attachInfo.mHandler);//执行某个指令
...
childWidthMeasureSpec = getRootMeasureSpec(desiredWindowWidth, lp.width);
childHeightMeasureSpec = getRootMeasureSpec(desiredWindowHeight, lp.height);
host.measure(childWidthMeasureSpec, childHeightMeasureSpec);//测量
....
host.layout(0, 0, 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 = 0; 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);
}
//控制台输出:
01-03 23:39:27.251 27069 27069 D cdw : --->绘制开始:height = 0
01-03 23:39:27.295 27069 27069 D cdw : --->绘制耗时:44ms
01-03 23:39:27.295 27069 27069 D cdw : --->绘制结束后:height = 1232
我们带着我们上面的知识储备,来看下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 (10000);
} catch (InterruptedException e) {}
}
等 10s 之后,进程正常运行,不会因为 Toast 的问题而崩溃。