因调用requestLayout()而引起的死循环问题

894 阅读2分钟

问题

开发中用到了CoordinatorLayoutAppBarLayout,因需求需要在AppBarLayout.OnOffsetChangedListener中的onOffsetChanged()回调方法中调用其子viewrequestLayout()方法,但是发现在日志中不断打印警告日志,并且onOffsetChanged()方法也不断被调用,形成死循环。

警告日志
requestLayout() improperly called by **View during second layout pass: posting in next frame requestLayout() improperly called by **View during layout: running second layout pass

分析

既然是由于调用requestLayout()方法引起的,那就从这里着手分析。

ViewrequestLayout()这个方法会一层一层往上调用直到ViewRootImpl.requestLayout()方法,然后会从上往下触发View的测量和布局甚至绘制方法,那肯定是在某个地方再次触发了AppBarLayoutonOffsetChanged()

经过断点分析发现是由于子viewrequestLayout()触发了父view(CoordinatorLayout)onLayout(),其内部由再次触发子view(AppBarLayout)布局重构,从而触发了onOffsetChanged()

// CoordinatorLayout 的 onLayout 方法
 protected void onLayout(boolean changed, int l, int t, int r, int b) {
        final int layoutDirection = ViewCompat.getLayoutDirection(this);
        final int childCount = mDependencySortedChildren.size();
        for (int i = 0; i < childCount; i++) {
            final View child = mDependencySortedChildren.get(i);
            if (child.getVisibility() == GONE) {
                // If the child is GONE, skip...
                continue;
            }

            final LayoutParams lp = (LayoutParams) child.getLayoutParams();
            final Behavior behavior = lp.getBehavior();

            // 此处开始布局子view
            if (behavior == null || !behavior.onLayoutChild(this, child, layoutDirection)) {
                onLayoutChild(child, layoutDirection);
            }
        }
    }

过程如图所示:

request循环.png

那警告日志是在哪里被打印的呢?查看源码发现是在ViewRootImpl类中的performLayout()内,是在布局过程中调用了requestLayout方法,触发了警告。源码如下。

private void performLayout(WindowManager.LayoutParams lp, int desiredWindowWidth,
        int desiredWindowHeight) {
   
   ......
   
    try {
        //【1-core】调用 DecorView(GroupView)的 layout;
        host.layout(0, 0, host.getMeasuredWidth(), host.getMeasuredHeight());

        mInLayout = false;
        int numViewsRequestingLayout = mLayoutRequesters.size();
        //【2】如果 mLayoutRequesters 大小不为 0 ;说明我们在布局的过程中调用了 requestLayout 方法;
        if (numViewsRequestingLayout > 0) {
            // 在布局期间调用了 requestLayout()。
            // 如果在请求的视图上未设置布局请求标志,则没有问题。
            // 如果某些请求仍在等待处理中,那么我们需要清除这些标志并进行完整的请求/度量/布局传递以处理这种情况。
            //【-->2.5.1】获取有效的布局请求;
            ArrayList<View> validLayoutRequesters = getValidLayoutRequesters(mLayoutRequesters,
                    false);
            if (validLayoutRequesters != null) {
                //【3】设置此标志,表示即将进入第二次布局中,这样的话,如果再发起 requestlayout 的话;
                // 这些请求会延迟到下一帧
                mHandlingLayoutInLayoutRequest = true;

                //【4】处理有效的布局请求;
                int numValidRequests = validLayoutRequesters.size();
                for (int i = 0; i < numValidRequests; ++i) {
                    final View view = validLayoutRequesters.get(i);
                    Log.w("View", "requestLayout() improperly called by " + view +
                            " during layout: running second layout pass");
                    //【4.1-core】调用每一个 View 的 layout 方法;
                    view.requestLayout();
                }
                //【-->2.2】再次预测量;
                measureHierarchy(host, lp, mView.getContext().getResources(),
                        desiredWindowWidth, desiredWindowHeight);
                mInLayout = true;
                
                //【5-core】调用 DecorView(GroupView)的 layout,再次布局;
                host.layout(0, 0, host.getMeasuredWidth(), host.getMeasuredHeight());

                mHandlingLayoutInLayoutRequest = false;

                //【-->2.5.1】再次获取有效的布局请求,注意这里第二个参数传入的是 true
                // 这次不会清除布局标志,因为在第二次布局中的请求会延迟到下一帧;
                validLayoutRequesters = getValidLayoutRequesters(mLayoutRequesters, true);
                if (validLayoutRequesters != null) {
                    final ArrayList<View> finalRequesters = validLayoutRequesters;
                    //【6】将第二次的请求发布到下一帧,这个 runnable 会在 performTraversals 中执行;
                    getRunQueue().post(new Runnable() {
                        @Override
                        public void run() {
                            int numValidRequests = finalRequesters.size();
                            for (int i = 0; i < numValidRequests; ++i) {
                                final View view = finalRequesters.get(i);
                                Log.w("View", "requestLayout() improperly called by " + view +
                                        " during second layout pass: posting in next frame");
                                //【6.1】触发 requestLayout 方法!
                                view.requestLayout();
                            }
                        }
                    });
                }
            }

        }
    } finally {
        Trace.traceEnd(Trace.TRACE_TAG_VIEW);
    }
    mInLayout = false;
}

原因是在子view主动调用requestLayout方法后,又再次被动调用了一遍,此时子view正在上一次的布局过程中,两次间隔仅3毫秒左右,所以触发了警告。

解决方案

通过记录上一次的滑动数值,判断是否与上次相等,即可打断循环。

var lastverticalOffset = 0
appbar.addOnOffsetChangedListener { _, verticalOffset ->
    // 若offset相同即return
    if (verticalOffset == lastverticalOffset) return@addOnOffsetChangedListener
    lastverticalOffset = verticalOffset
    
    childView.doSomething(verticalOffset)
    childView.requestLayout()
    
}

参考

ViewDraw 第三篇 performTraversals 流程分析 | Coolqi`s Blog (lishuaiqi.top)