欢迎大家前往云+社区,获取更多腾讯海量技术实践干货哦~
作者: 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 :0复制代码
那么,界面又是在什么时候完成的绘制呢?是不是在 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;
....
}复制代码
但是,我们从 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
的问题而崩溃。
相关阅读
[Android] Toast问题深度剖析(一)
Android基础:Fragment,看这篇就够了
Android图像处理 - 高斯模糊的原理及实现
此文已由作者授权云加社区发布,转载请注明文章出处