ITPub博客

首页 > 移动开发 > ANDROID > 从绘制时机深入浅出View.post

从绘制时机深入浅出View.post

原创 ANDROID 作者:山有木xi 时间:2024-01-14 10:01:49 0 删除 编辑

最近新接手了一个项目的 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/ ,如需转载,请注明出处,否则将追究法律责任。

请登录后发表评论 登录
全部评论
Oracle OCA(Java),IBM高级工程师认证,中国软件行业人才(高级c语言),高级Android工程师,对数据库并发与性能调优也有一定了解

注册时间:2019-04-25

  • 博文量
    147
  • 访问量
    390768