原标题:一次Android奇葩的卡屏现象分析
本文作者
作者: BlackZheng
昨晚加班太晚,大家有什么问题或者留言不要着急,留下即可,我睡醒后会回复哈。
1
前言
近期在项目中发现一个很奇葩的卡屏现象,花了不少时间排查追踪源码,终于找出原因并解决,遂发出来与诸位分享交流。
现象
废话不多说,直接看图

上面的demo非常简单,是在Android Studio直接生成的项目下的MainActivity中增加了一个Button,还有一个ProgressBar(为了展现当前的卡顿情况),可以看到,在我点击了Button之后,界面卡住了。
那么现在是提问时间:当我按下了Button后,发生了什么?可能是什么原因导致的?
我想大多数人会本能地想说:你是不是在主线程执行了耗时任务,或者触发了死锁,导致了
ANR?
可是仔细观察截屏中左边的计时器,你会发现即便过了10s也没弹出ANR的对话框,另外,仔细观察截屏中的Profiler界面,会发现整个过程中cpu,内存是没有明显的变化的(唯一的那点波动是ProgressBar带来的,去掉的话就完全没波动了),而且卡住后,点击事件依然可以响应。
不卖关子了,先来看看我在Button的OnClickListener中的代码:
button.setOnClickListener {
textView.viewTreeObserver.addOnPreDrawListener( object: ViewTreeObserver.OnPreDrawListener{
overridefunonPreDraw: Boolean{
textView.viewTreeObserver.removeOnPreDrawListener( this) //1
returnfalse//2
}
})
container.removeView(textView) //3 container是当前Activity的根容器,就只是一个LinearLayout
}
看到了吗,没有耗时任务。(爱动手的同学,可以用这段代码自己跑一下看看效果)。
2
探索
难道是OnPreDrawListener搞的鬼?那我们先来看看OnPreDrawListener是个什么鬼。
Interface definition for a callback to be invoked when the view tree is about to be drawn. – developer.android.com
Interface definition for a callback to be invoked when the view tree is about to be drawn. – developer.android.com
那onPreDraw回调又是什么来历:
Callback method to be invoked when the view tree is about to be drawn. At this point, all views in the tree have been measured and given a frame. Clients can use this to adjust their scroll bounds or even to request a new layout before drawing occurs. – developer.android.com
Callback method to be invoked when the view tree is about to be drawn. At this point, all views in the tree have been measured and given a frame. Clients can use this to adjust their scroll bounds or even to request a new layout before drawing occurs. – developer.android.com
简单翻译一下就是:
OnPreDrawListener为即将绘制视图树时执行的回调函数定义的接口;onPreDraw是即将绘制视图树时执行的回调函数。这时所有的视图都测量完成并确定了边界。客户端可以使用该方法来调整滚动边框,甚至可以在绘制之前请求新的布局。
OnPreDrawListener为即将绘制视图树时执行的回调函数定义的接口;onPreDraw是即将绘制视图树时执行的回调函数。这时所有的视图都测量完成并确定了边界。客户端可以使用该方法来调整滚动边框,甚至可以在绘制之前请求新的布局。
在实际项目中我见过的实际应用主要有两方面:
这么看似乎是个人畜无害的API呀
…………
直到我打断点发现,onPreDraw居然会不停地回调!我明明在回调时调了removeOnPreDrawListener移除了listener的!

3
分析
带着满头问号,我开始了源码追踪之路。从上边的现象来看,removeOnPreDrawListener肯定没有执行成功。至于为什么没有成功,我先把目光转向了ViewTreeObserver,先来看看相关源码
//View.java
publicViewTreeObserver getViewTreeObserver{
if(mAttachInfo != null) {
returnmAttachInfo.mTreeObserver;
}
if(mFloatingTreeObserver == null) {
mFloatingTreeObserver = newViewTreeObserver(mContext);
}
returnmFloatingTreeObserver;
}
好家伙,viewTreeObserver原来还有两副面孔。
说白了就是当View被attach到window上时getViewTreeObserver返回的是attachInfo中的mTreeObserver, 如果View没有被attach到window上时(即被从父容器中remove或从未被添加进任何父容器),返回的是另外一个ViewTreeObserver。
这很好解释了为什么我的removeOnPreDrawListener没有生效:
PS:ViewTreeObserver,以及attachInfo后文会详细讲解
那这可怎么办?难不成我还要自己持有并维护ViewTreeObserver?如果是那还得维护两个(一个是attach状态的,一个是detach状态的)于是我查了许多资料,发现了Google的工程师对这个问题的看法,摘抄如下:
When you receive ViewTreeObserver from a View, it returns a fake one if it is not attached. When it is attached to the window, it moves all listeners to the actual window’s listener. Unfortunately, it does not remove them from the window’s listener if the view is detached.
At this point, it is very hard to change the behavior of this API because it is in the framework and there are probably apps relying on this behavior.
I’ll keep the bug open and maybe we’ll consider something for O release.
When you receive ViewTreeObserver from a View, it returns a fake one if it is not attached. When it is attached to the window, it moves all listeners to the actual window’s listener. Unfortunately, it does not remove them from the window’s listener if the view is detached.
At this point, it is very hard to change the behavior of this API because it is in the framework and there are probably apps relying on this behavior.
I’ll keep the bug open and maybe we’ll consider something for O release.
简单翻译一下:
你在一个view detach时添加的listener, 在这个view被attach时会帮你过继到View树的ViewTreeObserver上,但是当view后续被detach时,并不会帮你把之前添加的listener移除掉或者转移到mFloatingTreeObserver上。这是早期的坑现在不好改,也许会考虑在Android 8的时候做点措施(然而现在都Android 11了)。
不过Google的工程师还是提到了规避的方法:
As a workaround for your use case, you should always receive the view tree observer from a view that you know won’t be detached. (e.g. the RecyclerView). There is only a single real view tree observer for your view tree so it does not matter from which view you receive it.
As a workaround for your use case, you should always receive the view tree observer from a view that you know won’t be detached. (e.g. the RecyclerView). There is only a single real view tree observer for your view tree so it does not matter from which view you receive it.
我再翻译一下:对于一个View树来说全局只有一个有效的ViewTreeObserver, 如果要获取该变量,应该通过那些不会被移除的顶层View(我们上面犯的错就在于通过textView来获取ViewTreeObserver,而这个textView会被移除),这样你就会获取到临时工mFloatingTreeObserver了,导致前后的ViewTreeObserver不一致。
关于这一点,我们也可以到源码求证一下(以下这一段如果看不懂,请先阅读View的绘制流程相关文章)
//View.java
voiddispatchAttachedToWindow( AttachInfo info, intvisibility ) {
mAttachInfo = info;
…
}
voiddispatchDetachedFromWindow{
…
mAttachInfo = null;
…
}
上面我们说过真正的ViewTreeObserver是由View的mAttachInfo中提供的,而mAttachInfo是在dispatchAttachedToWindow方法参数中传入的, dispatchAttachedToWindow的调用者则是我们的老朋友ViewRootImpl
//ViewRootImpl.java
publicViewRootImpl(Context context, Display display){
…
mAttachInfo = newView.AttachInfo(mWindowSession, mWindow, display, this, mHandler, this,
context);
…
}
privatevoidperformTraversals{
// cache mView since it is used so much below…
finalView host = mView;
…
if(mFirst) { //如果是第一次绘制
host.dispatchAttachedToWindow(mAttachInfo, 0);
}
…
}
从上面的代码可以看出,attachInfo随着ViewRoot创建,并且在第一次绘制时传入到整个View树上的所有View,因此通过View树上任何一个View获取的ViewTreeObserver都是同一个。
所以只要将我的代码做简单的修改就可以解决这个问题了:
button.setOnClickListener {
container.viewTreeObserver.addOnPreDrawListener( object: ViewTreeObserver.OnPreDrawListener{
overridefunonPreDraw: Boolean{
container.viewTreeObserver.removeOnPreDrawListener( this) //1 不再用textView去获取viewTreeObserver了
returnfalse//2
}
})
container.removeView(textView) //3 container是当前Activity的根容器,就只是一个LinearLayout
}
4
更进一步
通过以上的修改,卡顿确实解决了,但没有解决我的另外一个疑问:为什么这样会造成卡顿?不就是onPreDraw会频繁回调吗(在回调里也没做什么耗时操作),但也没导致ANR啊,为什么会整个屏幕卡住,但是点击事件又能响应?
我们再来重新看看OnPreDrawListener:
publicinterface OnPreDrawListener {
/**
* Callback method tobe invoked when the view tree isabout tobe drawn. At this point, all
* views inthe tree have been measured andgiven a frame. Clients can use this toadjust
* their scroll bounds oreven torequesta newlayout before drawing occurs.
*
* @return Return truetoproceed withthe current drawing pass, orfalsetocancel. 注意这句话!
*
* @see android.view.View#onMeasure
* @see android.view.View#onLayout
* @see android.view.View#onDraw
*/
publicboolean onPreDraw;
}
对于onPreDraw的返回值,文档的意思是返回true会继续进行本次绘制,false则取消。
文字有点苍白,我们还是到源码里逛逛。既然持有OnPreDrawListener的是ViewTreeObserver,那我们从它看起:
//ViewTreeObserver.java
/**
* @returnTrue if the current draw should be canceled and resceduled, false otherwise.
*/
publicfinalbooleandispatchOnPreDraw{
booleancancelDraw = false;
finalCopyOnWriteArray<OnPreDrawListener> listeners = mOnPreDrawListeners;
if(listeners != null&& listeners.size > 0) {
CopyOnWriteArray.Access<OnPreDrawListener> access = listeners.start;
try{
intcount = access.size;
for( inti = 0; i < count; i++) {
cancelDraw |= !(access.get(i).onPreDraw);
}
} finally{
listeners.end;
}
}
returncancelDraw;
}
注意这个方法的返回值,如果返回true, 绘制流程会被取消(canceled)或延迟(resceduled), false则继续进行绘制, 注意跟onPreDraw是相反的,关键在这一句:cancelDraw |= !(access.get(i).onPreDraw);,这里取了反。
那dispatchOnPreDraw在哪里起作用呢,一顿搜发现:
//ViewRootImpl.java
privatevoidperformTraversals{
…
booleancancelDraw = mAttachInfo.mTreeObserver.dispatchOnPreDraw || !isViewVisible;
if(!cancelDraw && !newSurface) { //这里是重点
…
performDraw;
} else{
if(isViewVisible) {
// Try again
scheduleTraversals;
} elseif(mPendingTransitions != null&& mPendingTransitions.size > 0) {
…
}
}
}
众所周知,View的绘制就是从performTraversals开始,经历三大流程:
performMeasure,performLayout,performDraw对应的测量,布局,绘制。但是看上边的代码,如果dispatchOnPreDraw返回了true就跑不到performDraw了,而是继续等待下一次的绘制(scheduleTraversals),即等待下一次的Vsync信号到来,但是下次dispatchOnPreDraw返回的值仍然是true, 会继续跳过performDraw。
如此循环往复,View根本绘制不出来,也就表现为卡屏了。但是这里只是performDraw这个方法跑不到,除此之外没有任何流程被阻塞,主线程消息队列不受影响,所以也就看不到ANR的对话框了。
5
总结
在我一开始贴出来的代码犯了两个错:
1. 通过可能会被移除的View来获取ViewTreeObserver(导致OnPreDrawListener解注册失败)
2. OnPreDrawListener.onPreDraw返回了false (导致绘制流程无法进行)
经验
1. 获取ViewTreeObserver时应该通过比较顶层或不会被remove的View
责任编辑: