最近新接手了一个项目的 B ug 修复任务 ,其中有一个Bug 是在一个页面有一个 ImageView,ImageView 根据传入的图片设置宽度拉满或者高度拉满,然后根据 imageView 的大小通过代码绘制一个新的 View ,在 onCreate 中绘制总是位置错乱,放入 onResume 后, 从后台 重新进入 应用 就可以正常绘制。
1) 最开始以为是 绘制的时机问题
因为放在 onResume 然后重新进入是正常的,因为这个页面比较频繁的设置 view 的 LayoutParams ,怀疑是某个 LayoutParams 设置的时机导致绘制的 view 的位置错乱(这里其实思路是接近的,但是没有继续深入,导致后面走了一些弯路),但是只放入 onResume 后还是错误的位置
2) view 的绘制 算法 有问题
但是第二次绘制可以正常执行 , 甚至后面在重新创建的干净的类中也是正常的绘制
3) 思考两次绘制过程中发生了什么
因为这个类频繁的设置 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.viewTreeObserver observer.addOnGlobalLayoutListener(object : OnGlobalLayoutListener { override fun onGlobalLayout() { // 移除监听器,避免重复调用 observer.removeOnGlobalLayoutListener(this) // 获取视图的新尺寸 // 在这里可以使用新的尺寸值 } })
我推荐 view .post 的解决方案 ,那么view.post 到底干了什么, 为什么会执行我们需要的操作呢 , 先来看看源码
public boolean post(Runnable action) { final AttachInfo attachInfo = mAttachInfo; if (attachInfo != null) { return attachInfo.mHandler.post(action); } // Postpone the runnable until we know on which thread it needs to run. // Assume that the runnable will be successfully placed after attach. getRunQueue().post(action); return true; } private HandlerActionQueue getRunQueue() { if (mRunQueue == null) { mRunQueue = new HandlerActionQueue(); } return mRunQueue; }
在 view.post 中第一个逻辑是 AttachInfo ,AttachInfo 是 View 内部的一个静态类,其内部持有一个 Handler 对象,从注释 可以知道 它是由 ViewRootImpl 提供的
inal static class AttachInfo { /** * A Handler supplied by a view's {@link android.view.ViewRootImpl}. This * handler can be used to pump events in the UI events queue. */ @UnsupportedAppUsage final Handler mHandler; AttachInfo(IWindowSession session, IWindow window, Display display, ViewRootImpl viewRootImpl, Handler handler, Callbacks effectPlayer, Context context) { ··· mHandler = handler; ··· } ··· }
查找 mAttachInfo 的赋值时机可以追踪到 View 的 dispatchAttachedToWindow 方法,该方法被调用就意味着 View 已经 Attach 到 Window 上了
@UnsupportedAppUsage(maxTargetSdk = Build.VERSION_CODES.P) void dispatchAttachedToWindow(AttachInfo info, int visibility) { mAttachInfo = info; ··· }
而 dispatchAttachedToWindow 方法的调用时机, 就到了 ViewRootImpl 类。 ViewRootImpl 内就包含一个 Handler 对象 mHandler ,并在构造函数中以 mHandler 作为构造参数之一来初始化 mAttachInfo 。
ViewRootImpl 的 performTraversals() 方法就会调用 DecorView 的 dispatchAttachedToWindow 方法并传入 mAttachInfo ,从而层层调用整个视图树中所有 View 的 dispatchAttachedToWindow 方法,使得所有 childView 都能获取到 mAttachInfo 对象 。
除此之外 ,performTraversals() 方法也负责启动整个视图树的 Measure 、 Layout 、 Draw 流程,只有当 performLayout 被调用后 View 才能确定自己的宽高信息。而 performTraversals() 本身也是交由 ViewRootHandler 来调用的 。
即整个视图树的绘制任务也是先插入到 MessageQueue 中,后续再由主线程取出任务进行执行。由于插入到 MessageQueue 中的消息是交由主线程来顺序执行的,所以 attachInfo.mHandler.post(action) 就保证了 action 一定是在 performTraversals 执行完毕后才会被调用,因此我们就可以在 Runnable 中获取到 View 的真实宽高了
再来看看第二个处理逻辑 getRunQueue().post(action);getRunQueue 实际上返回了一个 HandlerActionQueue ,所以本质上就是调用了 HandlerActionQueue.post , 让我们来看看 HandlerActionQueue 吧
public class HandlerActionQueue { private HandlerAction[] mActions; private int mCount; public void post(Runnable action) { postDelayed(action, 0); } public void postDelayed(Runnable action, long delayMillis) { final HandlerAction handlerAction = new HandlerAction(action, delayMillis); synchronized (this) { if (mActions == null) { mActions = new HandlerAction[4]; } mActions = GrowingArrayUtils.append(mActions, mCount, handlerAction); mCount++; } } public void executeActions(Handler handler) { synchronized (this) { final HandlerAction[] actions = mActions; for (int i = 0, count = mCount; i < count; i++) { final HandlerAction handlerAction = actions[i]; handler.postDelayed(handlerAction.action, handlerAction.delay); } mActions = null; mCount = 0; } } private static class HandlerAction { final Runnable action; final long delay; public HandlerAction(Runnable action, long delay) { this.action = action; this.delay = delay; } public boolean matches(Runnable otherAction) { return otherAction == null && action == null || action != null && action.equals(otherAction); } } ··· }
HandlerActionQueue 可以看做是一个专门用于存储 Runnable 的任务队列, mActions 就存储了所有要执行的 Runnable 和相应的延时时间。两个 post 方法就用于将要执行的 Runnable 对象保存到 mActions 中, executeActions 就负责将 mActions 中的所有任务提交给 Handler 执行
所以说,getRunQueue().post(action) 只是将我们提交的 Runnable 对象保存到了 mActions 中,还需要外部主动调用 executeActions 方法来执行任务
而这个主动执行任务的操作也是由 View 的 dispatchAttachedToWindow 来完成的,从而使得 mActions 中的所有任务都会被插入到 mHandler 的 MessageQueue 中,等到主线程执行完 performTraversals() 方法后就会来执行 mActions ,所以此时我们依然可以获取到 View 的真实宽高
在 onCreate 、 onResume 函数中为什么无法也直接得到 View 的真实宽高呢?
从结果反推原因,这说明当 onCreate 、 onResume 被回调时 ViewRootImpl 的 performTraversals() 方法还未执行,那么 performTraversals() 方法的具体执行时机是什么时候呢?
这可以从 ActivityThread -> WindowManagerImpl -> WindowManagerGlobal -> ViewRootImpl 这条调用链上找到答案
首先,ActivityThread 的 handleResumeActivity 方法就负责来回调 Activity 的 onResume 方法,且如果当前 Activity 是第一次启动,则会向 ViewManager ( wm )添加 DecorView
@Override public void handleResumeActivity(IBinder token, boolean finalStateRequest, boolean isForward, String reason) { ··· //Activity 的 onResume 方法 final ActivityClientRecord r = performResumeActivity(token, finalStateRequest, reason); ··· if (r.window == null && !a.mFinished && willBeVisible) { ··· ViewManager wm = a.getWindowManager(); if (a.mVisibleFromClient) { if (!a.mWindowAdded) { a.mWindowAdded = true; wm.addView(decor, l); } else { a.onWindowAttributesChanged(l); } } } else if (!willBeVisible) { if (localLOGV) Slog.v(TAG, "Launch " + r + " mStartedActivity set"); r.hideForNow = true; } ··· }
此处的 ViewManager 的具体实现类即 WindowManagerImpl , WindowManagerImpl 会将操作转交给 WindowManagerGlobal
@UnsupportedAppUsage private final WindowManagerGlobal mGlobal = WindowManagerGlobal.getInstance(); @Override public void addView(@NonNull View view, @NonNull ViewGroup.LayoutParams params) { applyDefaultToken(params); mGlobal.addView(view, params, mContext.getDisplayNoVerify(), mParentWindow, mContext.getUserId()); }
WindowManagerGlobal 就会完成 ViewRootImpl 的初始化并且调用其 setView 方法,该方法内部就会再去调用 performTraversals 方法启动视图树的绘制流程
public void addView(View view, ViewGroup.LayoutParams params, Display display, Window parentWindow, int userId) { ··· ViewRootImpl root; View panelParentView = null; synchronized (mLock) { ··· root = new ViewRootImpl(view.getContext(), display); view.setLayoutParams(wparams); mViews.add(view); mRoots.add(root); mParams.add(wparams); // do this last because it fires off messages to start doing things try { root.setView(view, wparams, panelParentView, userId); } catch (RuntimeException e) { // BadTokenException or InvalidDisplayException, clean up. if (index >= 0) { removeViewLocked(index, true); } throw e; } } }
所以说, performTraversals 方法的调用时机是在 onResume 方法之后,所以我们在 onCreate 和 onResume 函数中都无法获取到 View 的实际宽高。当然,当 Activity 在单次生命周期过程中第二次调用 onResume 方法时就可以获取到 View 的宽高属性 , 也就是文章开头我遇到的 bug