从绘制时机深入浅出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


请使用浏览器的分享功能分享到微信等