从源码角度分析:ScrollView 嵌套 RecyclerView 的 高度测量

前言

前几天在hongyang 大神的公众号上看到了一篇关于 ScrollView 嵌套RecyclerView 的文章,https://juejin.im/post/5d75e8cd6fb9a06afd662bf3

作者从使用角度详细阐明了这种做法的弊端,恰好前段时间重构项目的时候研究了一下这个问题,在此做一些补充说明。
我先无耻的盗个图 - - 。

<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent">
    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:orientation="vertical">
        <TextView
            android:padding="15dp"
            android:layout_width="match_parent"
            android:layout_height="200dp"
            android:background="@color/colorAccent"
            android:gravity="center"
            android:text="我是商家介绍,我们家的饭贼好吃,优惠还贼多,买到就是赚到"
            android:textColor="#fff"
            android:textSize="20dp" />
        <android.support.v7.widget.RecyclerView
            android:id="@+id/recyclerView"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:background="@color/design_default_color_primary"/>
    </LinearLayout>
</ScrollView>

错误的效果


预期的效果

  • 当按上面的方式使用时,我们的预期是RecyclerView高度是所有ItemView的高度之和,ScrollView响应滑动事件,但结果并非如此。
  • 比较特殊的一点!!!!!!当 APP 的 targetSdkVersion<23 时。上面的代码,是会按预期效果运行,也就是图2,这个后面有分析。
  • 但替换成NestedScrollView就正常了,这根本原因是RecyclerView的LinearLayoutManager 在MeasureSpec.UNSPECIFIED 测量模式下,存在一个 mInfinite 状态

提前展示一下结论,后面再做分析

  • 当 recyclerView 的 onMeasure(int widthSpec, int heightSpec) heightSpec 的Mode是 UNSPECIFIED 并且size ==0, 会触发 LinearLayoutManager 的 mInfinite 模式。 也就是 一次性把所有 ItemCount 的 ItemView 全部加载.
        /**
         * Used when there is no limit in how many views can be laid out.
         */
        boolean mInfinite;
  • 当 recyclerView 的 onMeasure(int widthSpec, int heightSpec) heightSpec 的Mode是 UNSPECIFIED 并且size >0 。则会按照正常的逻辑,在 父View指定的size 空间内去填充ITemView

一、 ScrollView 和 NestScrollView 的不同之处

提到这两个ViewGroup ,就不得不提到 UNSPECIFIED 这个 MeasureMode 。

Measure specification mode: The parent has not imposed any constraint on the child. It can be whatever size it wants.

也就是父View 没有对子View 的大小做限制,子View 可以想要多大就多大。事实上也就只有这两个ViewGroup 使用到了UNSPECIFIED 这个Mode。

大家都知道 子View 的onMeasure(int widthSpec, int heightSpec) 方法的 MeasureSpec参数 是 由父View 创建的 ,玄机就在这个地方 。

  • ScrollView 的 measureChildWithMargins 方法, childHeightMeasureSpec的Size >0
    @Override
    protected void measureChildWithMargins(View child, int parentWidthMeasureSpec, int widthUsed,
            int parentHeightMeasureSpec, int heightUsed) {
        final MarginLayoutParams lp = (MarginLayoutParams) child.getLayoutParams();

        final int childWidthMeasureSpec = getChildMeasureSpec(parentWidthMeasureSpec,
                mPaddingLeft + mPaddingRight + lp.leftMargin + lp.rightMargin
                        + widthUsed, lp.width);
        final int usedTotal = mPaddingTop + mPaddingBottom + lp.topMargin + lp.bottomMargin +
                heightUsed;
        // 传递给子View 的MesureSpec 的Size 是父布局空间减去已用空间 是大于0的
        final int childHeightMeasureSpec = MeasureSpec.makeSafeMeasureSpec(
                Math.max(0, MeasureSpec.getSize(parentHeightMeasureSpec) - usedTotal),
                MeasureSpec.UNSPECIFIED);

        child.measure(childWidthMeasureSpec, childHeightMeasureSpec);
    }
  • NestedScrollView 的 measureChildWithMargins 方法,在没设置Margin的情况下,
    childHeightMeasureSpec 的Size 为0
    @Override
    protected void measureChildWithMargins(View child, int parentWidthMeasureSpec, int widthUsed,
            int parentHeightMeasureSpec, int heightUsed) {
        final MarginLayoutParams lp = (MarginLayoutParams) child.getLayoutParams();

        final int childWidthMeasureSpec = getChildMeasureSpec(parentWidthMeasureSpec,
                getPaddingLeft() + getPaddingRight() + lp.leftMargin + lp.rightMargin
                        + widthUsed, lp.width);
        // 多数情况下 lp.topMargin + lp.bottomMargin 都是 0 
        final int childHeightMeasureSpec = MeasureSpec.makeMeasureSpec(
                lp.topMargin + lp.bottomMargin, MeasureSpec.UNSPECIFIED);

        child.measure(childWidthMeasureSpec, childHeightMeasureSpec);
    }

二、LinearLayout的 测量 与 View 的sUseZeroUnspecifiedMeasureSpec 模式

当 ScrollView 或者 NestedScrollView 执行 child.measure 方法。也就是 LinearLayout 进行测量,然后执了行measureChildBeforeLayout 方法 。同样会执行measureChildWithMargins方法,去测量RecycelrView的高度

    void measureChildBeforeLayout(View child, int childIndex,
            int widthMeasureSpec, int totalWidth, int heightMeasureSpec,
            int totalHeight) {
        measureChildWithMargins(child, widthMeasureSpec, totalWidth,
                heightMeasureSpec, totalHeight);
    }

由于LinearLayout 没有重写 ViewGroup 的 measureChildWithMargins方法,所以执行的是ViewGroup 的方法。

    protected void measureChildWithMargins(View child,
            int parentWidthMeasureSpec, int widthUsed,
            int parentHeightMeasureSpec, int heightUsed) {
        final MarginLayoutParams lp = (MarginLayoutParams) child.getLayoutParams();

        final int childWidthMeasureSpec = getChildMeasureSpec(parentWidthMeasureSpec,
                mPaddingLeft + mPaddingRight + lp.leftMargin + lp.rightMargin
                        + widthUsed, lp.width);
        // 获取 RecyclerVeiw 的 childHeightMeasureSpec
        final int childHeightMeasureSpec = getChildMeasureSpec(parentHeightMeasureSpec,
                mPaddingTop + mPaddingBottom + lp.topMargin + lp.bottomMargin
                        + heightUsed, lp.height);

        child.measure(childWidthMeasureSpec, childHeightMeasureSpec);
    }

获取 childHeightMeasureSpec 的代码如下:

        // Parent asked to see how big we want to be
        case MeasureSpec.UNSPECIFIED:
            if (childDimension >= 0) {
                // Child wants a specific size... let him have it
                resultSize = childDimension;
                resultMode = MeasureSpec.EXACTLY;
            } else if (childDimension == LayoutParams.MATCH_PARENT) {
                // Child wants to be our size... find out how big it should
                // be
                resultSize = View.sUseZeroUnspecifiedMeasureSpec ? 0 : size;
                resultMode = MeasureSpec.UNSPECIFIED;
            } else if (childDimension == LayoutParams.WRAP_CONTENT) {
                // Child wants to determine its own size.... find out how
                // big it should be
                resultSize = View.sUseZeroUnspecifiedMeasureSpec ? 0 : size;
                resultMode = MeasureSpec.UNSPECIFIED;
            }
            break;
        }
        //noinspection ResourceType
        return MeasureSpec.makeMeasureSpec(resultSize, resultMode);

sUseZeroUnspecifiedMeasureSpec 是View 的一个属性
sUseZeroUnspecifiedMeasureSpec = targetSdkVersion < Build.VERSION_CODES.M;

    /**
     * Always return a size of 0 for MeasureSpec values with a mode of UNSPECIFIED
     */
    static boolean sUseZeroUnspecifiedMeasureSpec = false;
  • 在布局文件中写死RecycelrView 的高度例如 200dp,resultMode 是EXACTLY
  • 由于父View 也就是Liearlayout 的 MeasureSpec 是 UNSPECIFIED,所以Recyclerview 在 布局文件中 不管是MATCH_PARENT 还是 WRAP_CONTENT 都无所谓,他的MeasureSpec Mode 都会被设置为UNSPECIFIED
  • sUseZeroUnspecifiedMeasureSpec = targetSdkVersion < Build.VERSION_CODES.M;
    当 gradle 中的 targetSdkVersion 小于 M 也就是 23 的时候 ,这个标志位是True。
    也就是 resultSize 不管父View 传递的Size是多大 , 它都是 0
ReyclerView 的Mespec SizetargetSdkVersion <23targetSdkVersion 》=23
父布局是ScrollView0parentSize
父布局是NestedScrollView00

三、RecyclerView 的测量 过程

阅读过RecyclerView 方法的同学都知道在RecyclerView 的 onMeasure 方法中 ,会调用dispatchLayoutStep2 ()
进而调用 LinearLayoutManager的 mLayout.onLayoutChildren(mRecycler, mState) 方法。
然后执行 fill()方法进行空间的计算和View的填充

    protected void onMeasure(int widthSpec, int heightSpec) {

        if (mLayout.isAutoMeasureEnabled()) {
  
            // 1.处理Adapter更新,标记VieHolder状态,保存动画信息
            if (mState.mLayoutStep == State.STEP_START) {
                dispatchLayoutStep1();
            }
            // 为LayoutManager 设置父View 传递过来的 MeasureSpec
            // Infinite  由 heightSpec决定
            mLayout.setMeasureSpecs(widthSpec, heightSpec);
            mState.mIsMeasuring = true;
            
            // 真正进行children的测量和布局。
            dispatchLayoutStep2();
            // 已经能够获取到子View 的个数和大小,累加计算高度
            // now we can get the width and height from the children.
            mLayout.setMeasuredDimensionFromChildren(widthSpec, heightSpec);
            }
    }

LinearLayoutManager的onLayoutChildren 方法

    //关于锚点的选取和布局方向的计算省略。
    public void onLayoutChildren(RecyclerView.Recycler recycler, RecyclerView.State state) {
        // 确定是否是无限制大小的模式
        mLayoutState.mInfinite = resolveIsInfinite();
        // 填充子View
        fill(recycler, mLayoutState, state, false);
    }

至此,我们又见到了开篇提到的 Infinite 模式,英文翻译是无法衡量的; 无限的;听起来倍牛逼。那么这个boolean 值的 决定代码如下:

    boolean resolveIsInfinite() {
        return mOrientationHelper.getMode() == View.MeasureSpec.UNSPECIFIED
                && mOrientationHelper.getEnd() == 0;
    }

目前模式已经对应上了 View.MeasureSpec.UNSPECIFIED 。

            public int getEnd() {
                return mLayoutManager.getHeight();
            }

而这个mLayoutManager.getHeight() 就是前面 onMeaure 方法中设置的heightSpec的Size

            // 为LayoutManager 设置父View 传递过来的 MeasureSpec
            // Infinite  由 heightSpec决定
            mLayout.setMeasureSpecs(widthSpec, heightSpec);

真相大白了,正如前文列表所展示的情况,ReyclerView 的Mespec Size 在 父布局是 NestedScrollView 的情况下 是 0 ,所以Infinite == true 。

fill() 方法填充逻辑如下,由于是Infinite 模式,会将Adapter 的getItemCount 数目的ItemView 全部添加至RecyclerView。

        // 当处于 所以Infinite 或者 拥有剩余空间,则layoutChunk 进行填充
        while ((layoutState.mInfinite || remainingSpace > 0) && layoutState.hasMore(state)) {
            layoutChunkResult.resetInternal();
            if (VERBOSE_TRACING) {
                TraceCompat.beginSection("LLM LayoutChunk");
            }
            layoutChunk(recycler, state, layoutState, layoutChunkResult);
    void layoutChunk(RecyclerView.Recycler recycler, RecyclerView.State state,
            LayoutState layoutState, LayoutChunkResult result) {
        // 创建或者 ,从缓存中取View
        View view = layoutState.next(recycler);
        // 添加View 也就是ViewHolder 的ItemView 到 RecyclerView 
        addView(view);
    }

layoutChunk 代码就不深入分析了 ,随便搜一篇 RecyclerView 缓存相关的博客都会解释。

再次回到 onMesure 的 setMeasuredDimensionFromChildren

        void setMeasuredDimensionFromChildren(int widthSpec, int heightSpec) {
            // Infinite 模式下,count == Adapter 的getItemCount 
            final int count = getChildCount();
            if (count == 0) {
                mRecyclerView.defaultOnMeasure(widthSpec, heightSpec);
                return;
            }
            int minX = Integer.MAX_VALUE;
            int minY = Integer.MAX_VALUE;
            int maxX = Integer.MIN_VALUE;
            int maxY = Integer.MIN_VALUE;
            
            // 累加所有子View 的高度
            for (int i = 0; i < count; i++) {
                View child = getChildAt(i);
                final Rect bounds = mRecyclerView.mTempRect;
                getDecoratedBoundsWithMargins(child, bounds);
                if (bounds.left < minX) {
                    minX = bounds.left;
                }
                if (bounds.right > maxX) {
                    maxX = bounds.right;
                }
                if (bounds.top < minY) {
                    minY = bounds.top;
                }
                if (bounds.bottom > maxY) {
                    maxY = bounds.bottom;
                }
            }
            mRecyclerView.mTempRect.set(minX, minY, maxX, maxY);
            // 设置RecycerView 的高度
            setMeasuredDimension(mRecyclerView.mTempRect, widthSpec, heightSpec);
        }

四、总结

RecyclerView 的 LinearLayoutManager 对于 UNSPECIFIED Mode 的处理存在一个特殊模式 Infinite 。在这个模式下,RecyclerView 将一次性加载所有的 ItemView 。并且这个模式下,RecyclerVeiw 不会 回收和复用 ViewHolder 和ItemView 。

五、题外话

关于嵌套的 fling 手势问题,可以设置 mRecyclerView.setNestedScrollingEnabled(false)解决

关于Header 和 RecyclerVeiw的滑动,最好使用 CoordinatorLayout 来解决。
CoordinatorLayout 与 NestedScroll 机制也经历过一次大的升级。
NestedScrollingParent 到 NestedScrollingParent2有时间整理一下。

  • 2
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值