前言:
最近,我接手了一个新项目的 Bug 修复任务,其中遇到了一个有趣的问题:在页面中有一个 ImageView,它的宽度或高度根据传入的图片自动拉伸。然后根据 ImageView 的大小,通过代码绘制一个新的 View。但问题出现在 onCreate 方法中绘制时,布局总是错乱。而当将其放入 onResume 方法后,从后台重新进入应用时,绘制就能正常工作。
一、初步怀疑是绘制时机问题
最开始,我以为是绘制时机的原因。因为在 onResume 后,重新进入应用时一切正常,而这个页面频繁地设置 View 的 LayoutParams,于是我猜测可能是某个 LayoutParams 设置的时机不对,导致了绘制的视图位置错乱。这个思路其实是接近的,但并没有进一步深入,导致走了一些弯路。尽管将其放入 onResume 后,问题依旧存在。
二、实际上是视图的绘制算法有问题
第二次绘制是正常的,甚至在重新创建的干净类中也能正确绘制。这时,我开始怀疑,是否是视图的绘制算法本身出了问题。
三、反思两次绘制过程中发生了什么
由于这个类频繁修改 LayoutParams,我将注意力集中在了这方面。查看了 LayoutParams 的源码:
public void setLayoutParams(ViewGroup.LayoutParams params) { if (params == null) { throw new NullPointerException("params == null"); } mLayoutParams = params; requestLayout();}
发现了 requestLayout() 方法。突然我想起来,修改了 View 的大小、位置等信息后,必须重新执行视图的测量(measure)和布局(layout)过程,才能更新视图的尺寸。而这个过程是异步的,系统会在下一个 UI 帧中更新视图的尺寸。因此,如果在修改 LayoutParams 后立即尝试获取视图的新尺寸,可能会得到旧的尺寸值。而我们绘制的 View 是新创建的,它的宽高是 0,这就导致了算法计算出错。
为验证这个问题,我写了个简单的测试:
view.postDelayed({ // 重新操作}, 500);
在所有布局设置完成后,延迟 500ms 再重新绘制,此时就能够正常绘制。这证明了我之前的猜测是对的,但显然,这样的解决方案并不优雅。
四、解决方案
我提出了两种解决方案:
1、在所有操作执行完成后,使用顶层 View.post() 方法,将绘制 View 的操作加入顶层 View 的最后一个操作。当顶层 View 绘制完成时,系统会自动执行绘制步骤。
binding.root.post { // 操作}
2、在所有操作完成后,监听顶层布局的绘制。当顶层 View 绘制完成时,说明真实的宽高已经计算完成。
val observer: ViewTreeObserver = binding.root.viewTreeObserverobserver.addOnGlobalLayoutListener(object : OnGlobalLayoutListener { override fun onGlobalLayout() { // 移除监听器,避免重复调用 observer.removeOnGlobalLayoutListener(this) // 获取视图的新尺寸 // 在这里可以使用新的尺寸值 }})
我推荐使用 View.post() 的解决方案。那它到底是怎么做到的呢?让我们来看看源码:
public boolean post(Runnable action) { final AttachInfo attachInfo = mAttachInfo; if (attachInfo != null) { return attachInfo.mHandler.post(action); } getRunQueue().post(action); return true;}
首先,View.post() 方法通过 mAttachInfo 获取一个 Handler,如果 mAttachInfo 不为空,就使用它来提交任务。如果为空,就将任务提交到一个任务队列中。
AttachInfo 是 View 内部的一个静态类,持有一个 Handler 对象。这个 Handler 是由 ViewRootImpl 提供的。通过 ViewRootImpl 的 performTraversals() 方法,会调用 dispatchAttachedToWindow 方法,确保所有的 View 都能够获取到 mAttachInfo 对象。
为什么 onCreate 和 onResume 中无法直接获取真实宽高?
原因是,在 onCreate 和 onResume 被回调时,ViewRootImpl 的 performTraversals() 方法尚未执行。而 performTraversals() 负责启动视图树的测量、布局和绘制过程,只有执行完 performLayout() 后,View 才能确定自己的宽高信息。所以在 onCreate 和 onResume 中,无法直接获取到真实的宽高。这也就是开头说到的bug的原因