废话不多说,直接看图
上面的demo非常简单,是在Android Studio直接生成的项目下的MainActivity中增加了一个Button,还有一个ProgressBar(为了展现当前的卡顿情况),可以看到,在我点击了Button之后,界面卡住了。
那么现在是提问时间:当我按下了Button后,发生了什么?可能是什么原因导致的?
我想大多数人会本能地想说:你是不是在主线程执行了耗时任务,或者触发了死锁,导致了
可是仔细观察截屏中左边的计时器,你会发现即便过了10s也没弹出ANR的对话框,另外,仔细观察截屏中的Profiler界面,会发现整个过程中cpu,内存是没有明显的变化的(唯一的那点波动是ProgressBar带来的,去掉的话就完全没波动了),而且卡住后,点击事件依然可以响应。
不卖关子了,先来看看我在Button的OnClickListener中的代码:
button.setOnClickListener {
textView.viewTreeObserver.addOnPreDrawListener(
object
: ViewTreeObserver.OnPreDrawListener{
override
fun
onPreDraw
:
Boolean
{
textView.viewTreeObserver.removeOnPreDrawListener(
this
)
//1
return
false
//2
container.removeView(textView)
//3 container是当前Activity的根容器,就只是一个LinearLayout
看到了吗,没有耗时任务。(爱动手的同学,可以用这段代码自己跑一下看看效果)。
难道是OnPreDrawListener搞的鬼?那我们先来看看OnPreDrawListener是个什么鬼。
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
简单翻译一下就是:
OnPreDrawListener为即将绘制视图树时执行的回调函数定义的接口;onPreDraw是即将绘制视图树时执行的回调函数。这时所有的视图都测量完成并确定了边界。客户端可以使用该方法来调整滚动边框,甚至可以在绘制之前请求新的布局。
在实际项目中我见过的实际应用主要有两方面:
用于获取View的尺寸测量值。众所周知View要经过测量后才能正确获取宽高,而onPreDraw正好是测量后绘制前的时机;在自定义View时,当自定义行为需要依赖View的实际尺寸来决策时,OnPreDrawListener就很适合了。
性能监控。比如计算首帧的渲染时机,或者计算帧率。
这么看似乎是个人畜无害的API呀
直到我打断点发现,onPreDraw居然会不停地回调!我明明在回调时调了removeOnPreDrawListener移除了listener的!
带着满头问号,我开始了源码追踪之路。从上边的现象来看,removeOnPreDrawListener肯定没有执行成功。至于为什么没有成功,我先把目光转向了ViewTreeObserver,先来看看相关源码
//View.java
public
ViewTreeObserver
getViewTreeObserver
{
if
(mAttachInfo !=
null
) {
return
mAttachInfo.mTreeObserver;
if
(mFloatingTreeObserver ==
null
) {
mFloatingTreeObserver =
new
ViewTreeObserver(mContext);
return
mFloatingTreeObserver;
好家伙,viewTreeObserver原来还有两副面孔。
说白了就是当View被attach到window上时getViewTreeObserver返回的是attachInfo中的mTreeObserver, 如果View没有被attach到window上时(即被从父容器中remove或从未被添加进任何父容器),返回的是另外一个ViewTreeObserver。
这很好解释了为什么我的removeOnPreDrawListener没有生效:
在我调用addOnPreDrawListener时,此时textView是attach在View树里的,这里的OnPreDrawListener是被当前View树的attachInfo的mTreeObserver持有
第一次onPreDraw的回调发生在我调用了removeView(注释3)之后
在注释2,由于此时textView已经被remove了,那么attachInfo是空的,于是构建一个新的ViewTreeObserver返回,而这个新的ViewTreeObserver并没有持有我们刚刚添加的OnPreDrawListener。
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.
简单翻译一下:
你在一个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.
我再翻译一下:对于一个View树来说全局只有一个有效的ViewTreeObserver, 如果要获取该变量,应该通过那些不会被移除的顶层View(我们上面犯的错就在于通过textView来获取ViewTreeObserver,而这个textView会被移除),这样你就会获取到临时工mFloatingTreeObserver了,导致前后的ViewTreeObserver不一致。
关于这一点,我们也可以到源码求证一下(以下这一段如果看不懂,请先阅读View的绘制流程相关文章)
//View.java
void
dispatchAttachedToWindow
(
AttachInfo info,
int
visibility
)
{
mAttachInfo = info;
void
dispatchDetachedFromWindow
{
mAttachInfo =
null
;
上面我们说过真正的ViewTreeObserver是由View的mAttachInfo中提供的,而mAttachInfo是在dispatchAttachedToWindow方法参数中传入的, dispatchAttachedToWindow的调用者则是我们的老朋友ViewRootImpl
//ViewRootImpl.java
public
ViewRootImpl
(Context context, Display display)
{
mAttachInfo =
new
View.AttachInfo(mWindowSession, mWindow, display,
this
, mHandler,
this
,
context);
private
void
performTraversals
{
// cache mView since it is used so much below...
final
View host = mView;
if
(mFirst) {
//如果是第一次绘制
host.dispatchAttachedToWindow(mAttachInfo,
0
);
从上面的代码可以看出,attachInfo随着ViewRoot创建,并且在第一次绘制时传入到整个View树上的所有View,因此通过View树上任何一个View获取的ViewTreeObserver都是同一个。
所以只要将我的代码做简单的修改就可以解决这个问题了:
button.setOnClickListener {
container.viewTreeObserver.addOnPreDrawListener(
object
: ViewTreeObserver.OnPreDrawListener{
override
fun
onPreDraw
:
Boolean
{
container.viewTreeObserver.removeOnPreDrawListener(
this
)
//1 不再用textView去获取viewTreeObserver了
return
false
//2
container.removeView(textView)
//3 container是当前Activity的根容器,就只是一个LinearLayout
通过以上的修改,卡顿确实解决了,但没有解决我的另外一个疑问:为什么这样会造成卡顿?不就是onPreDraw会频繁回调吗(在回调里也没做什么耗时操作),但也没导致ANR啊,为什么会整个屏幕卡住,但是点击事件又能响应?
我们再来重新看看OnPreDrawListener:
public
interface OnPreDrawListener {
* 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.
* @return Return
true
to
proceed
with
the current drawing pass,
or
false
to
cancel. 注意这句话!
* @see android.view.View#onMeasure
* @see android.view.View#onLayout
* @see android.view.View#onDraw
public
boolean onPreDraw;
对于onPreDraw的返回值,文档的意思是返回true会继续进行本次绘制,false则取消。
文字有点苍白,我们还是到源码里逛逛。既然持有OnPreDrawListener的是ViewTreeObserver,那我们从它看起:
//ViewTreeObserver.java
*
@return
True if the current draw should be canceled and resceduled, false otherwise.
public
final
boolean
dispatchOnPreDraw
{
boolean
cancelDraw =
false
;
final
CopyOnWriteArray<OnPreDrawListener> listeners = mOnPreDrawListeners;
if
(listeners !=
null
&& listeners.size >
0
) {
CopyOnWriteArray.Access<OnPreDrawListener> access = listeners.start;
try
{
int
count = access.size;
for
(
int
i =
0
; i < count; i++) {
cancelDraw |= !(access.get(i).onPreDraw);
}
finally
{
listeners.end;
return
cancelDraw;
注意这个方法的返回值,如果返回true, 绘制流程会被取消(canceled)或延迟(resceduled), false则继续进行绘制, 注意跟onPreDraw是相反的,关键在这一句:cancelDraw |= !(access.get(i).onPreDraw);,这里取了反。
那dispatchOnPreDraw在哪里起作用呢,一顿搜发现:
//ViewRootImpl.java
private
void
performTraversals
{
boolean
cancelDraw = mAttachInfo.mTreeObserver.dispatchOnPreDraw || !isViewVisible;
if
(!cancelDraw && !newSurface) {
//这里是重点
performDraw;
}
else
{
if
(isViewVisible) {
// Try again
scheduleTraversals;
}
else
if
(mPendingTransitions !=
null
&& mPendingTransitions.size >
0
) {
众所周知,View的绘制就是从performTraversals开始,经历三大流程:
performMeasure,performLayout,performDraw对应的测量,布局,绘制。但是看上边的代码,如果dispatchOnPreDraw返回了true就跑不到performDraw了,而是继续等待下一次的绘制(scheduleTraversals),即等待下一次的Vsync信号到来,但是下次dispatchOnPreDraw返回的值仍然是true, 会继续跳过performDraw。
如此循环往复,View根本绘制不出来,也就表现为卡屏了。但是这里只是performDraw这个方法跑不到,除此之外没有任何流程被阻塞,主线程消息队列不受影响,所以也就看不到ANR的对话框了。
在我一开始贴出来的代码犯了两个错:
1. 通过可能会被移除的View来获取ViewTreeObserver(导致OnPreDrawListener解注册失败)
2. OnPreDrawListener.onPreDraw返回了false (导致绘制流程无法进行)
1. 获取ViewTreeObserver时应该通过比较顶层或不会被remove的View
2. 使用OnPreDrawListener时onPreDraw返回false应该要慎重(至少不能一直返回false)
返回搜狐,查看更多
责任编辑:
声明:该文观点仅代表作者本人,搜狐号系信息发布平台,搜狐仅提供信息存储空间服务。