首页 > 移动开发 > ANDROID > 从绘制时机深入浅出View.post
最近新接手了一个项目的 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
来自 “ ITPUB博客 ” ,链接:https://blog.itpub.net/69917874/viewspace-3003770/ ,如需转载,请注明出处,否则将追究法律责任。