recyclerview嵌套recyclerview_2020年了,你还在使用ScrollView嵌套RecyclerView吗?

本文探讨了在Android开发中,使用ScrollView嵌套RecyclerView导致的滑动问题及其解决方法。通过分析事件分发机制,介绍了NestedScrollView如何解决滑动冲突,但同时指出NestedScrollView可能导致RecyclerView一次性加载所有item,从而引发性能问题。文章建议使用RecyclerView的多样式布局或CoordinatorLayout来替代嵌套布局,以优化性能和用户体验。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

前言

在现在的App上我们经常能见到如下的交互:

5b5437a17200738fc3321cfc783b9939.gif

美团外卖商家首页

不考虑细节部分的交互,这个页面我们大体上可以分为两个部分:

  • 位于顶部的商家详情(为了称呼方便,这篇文章称其为Header)

  • 位于底部的商品清单(同样,这篇文章暂且称其为Content)

看到这种页面,大概率会有开发者使用ScrollView嵌套RecyclerView实现,布局大概像这样:

<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>

问题来了

这样实现乍一看好像合情合理,我们将代码run起来看下效果:

e7f85488b6d4ceceaa93c53d386b0e81.gif

ScrollView你怎么了,怎么不会滚动了

上图中的问题很明显,我们手指在ScrollView上滑动时,Header并没有随着ScrollView一起滑动,可是没有记错的话ScrollView本身应该是可以滑动的啊?

嵌套在ScrollView中的Header为什么不能滑动了

别怀疑自己,你没有记错,ScrollView确实是可以滑动的,Google官方对其的定义是这样的:

ScrollView(以下内容截取自ScrollView源码):
A view group that allows the view hierarchy placed within it to be scrolled.
一个允许内部视图滚动的视图组。
Scroll view may have only one direct child placed within it. ScrollView 仅可包含一个直接子View。

Google官方的说法足以说明ScrollView的作用,所以出问题的看起来并不是ScrollView。
排除了ScrollView出问题的可能后,我们再来看下是不是RecyclerView,同样,我们先来看下关于RecyclerView,Google是怎么说的:

RecyclerView(以下内容截取自RecyclerView源码):
A flexible view for providing a limited window into a large data set.
一个用于在有限的窗口展示大量数据的灵活的视图。

关于RecyclerView的定位,Google官方的注释就比较耐人寻味了,“有限的窗口”我们都懂,就是指RecyclerView的高度有限,“大量的数据”我们也懂,那怎么在有限的窗口内展示大量数据呢?相信认真看文章的小伙伴脑子里已经有答案了——bingo,答案就是滑动。通过滑动的方式,RecyclerView就能用有限的空间,展示大量的数据(吃的是草,挤的是奶大概就是来形容RecyclerView了)。
这么看下来,ScrollView没错,RecyclerView也没错,难不成是他俩八字不和,不能在一起使用?如果你能这么想,那么恭喜你,你找到了问题的根本:ScrollView 与 RecyclerView 都可以滑动,当我们的手指在屏幕上滑动的时候,Android系统并不知道我们想要滑动 ScrollView 还是 RecyclerView ,体现出来的结果就和上图一样,当然就不会符合我们的预期了。
这个问题,其实就是老生常谈的滑动冲突,这么一讲,是不是顿时觉得滑动冲突这个问题也没有听起来那么的高大上了?

除此之外,ScrollView嵌套ListView时,会疯狂调用Adapter中的getView()方法,将ListView所有的item加载到内存中,消耗大量的内存和cpu资源,引起界面卡顿。这也就是为什么《阿里巴巴Android开发手册》中禁止ScrollView嵌套ListView/GridView/ExpandableListView

7768af89985b82411ae47eb91d8e3b6a.png阿里巴巴Android开发手册

我还是想让Header能滑动,应该怎么做

通过上面的分析,我们清楚的知道了,问题的根源就在于滑动冲突,那要怎么解决滑动冲突呢?
目前常见的滑动冲突的解决方案主要有两个:

  • 传统的事件分发机制

  • NestedScrollingChildNestedScrollingParent

这里说是两个方案是有些不太准确的,因为NestedScrollingChildNestedScrollingParent的实现也是基于传统的事件分发机制。我们这里姑且认为这是两种方案。

如果你恰好不了解传统的事件分发机制也没关系,网上这方面的介绍有很多,也不影响你使用NestedScrollingChildNestedScrollingParent。关于NestedScrollingChildNestedScrollingParent的用法,推荐学习鸿洋大大的博客:Android NestedScrolling机制完全解析 带你玩转嵌套滑动。

传统的事件分发机制有一个弊端:在一次滑动事件中,当Parent拦截之后,是没有办法再把事件交给Child的(排除手动调用分发事件),而NestedScrollingChildNestedScrollingParent机制就没有这个问题。

当然,如果只是让Header能够跟随ScrollView滑动起来,我们完全没有必要从头学习NestedScrollingChildNestedScrollingParent的用法,Android提供的许多原生控件都已经实现了这一机制,RecyclerView 包括我们即将提到的 NestedScrollView 也无一例外的实现了该机制。

换上NestedScrollView好使不

知彼知己,百战不殆

在使用NestedScrollView之前,我们先来看下Google对其的定位是什么样的:

NestedScrollView(以下内容摘自NestedScrollView源码):
NestedScrollView is just like {@link android.widget.ScrollView},
NestedScrollView与ScrollView类似,
but it supports acting as both a nested scrolling parent and child on both new and old versions of Android.
但它支持在Android的新旧版本上同时充当嵌套滚动的父视图和子视图。
Nested scrolling is enabled by default.
默认情况下启用嵌套滚动。

我们需要的,就是他默认启用的嵌套滚动机制。接下来我们将上面布局中的ScrollView替换成NestedScrollView,看看能否解决我们的困扰。

<android.support.v4.widget.NestedScrollView  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>android.support.v4.widget.NestedScrollView>

再次运行,效果如下:

ee5e628f4f4985eef45eb476dcf138e6.gif

换成NestedScrollView后,交互看起来符合我们的预期了

完结,撒花~看起来 NestedScrollView 成功帮助我们解决了问题...了么?

RecyclerView里的item怎么一下子全加载完了?

众所周知,RecyclerView通过适配器Adapter完成数据与View的绑定,而Adapter中有几个关键方法:

  • onViewAttachedToWindow

  • onCreateViewHolder

  • onBindViewHolder

我们这里来聊一下onViewAttachedToWindow方法,关于这个方法,Google又是这么解释的:

onViewAttachedToWindow(以下摘自源码):
Called when a view created by this adapter has been attached to a window.
当Adapter通过onCreateViewHolder方法创建的视图被附加到窗口时调用。

这段注释就是在解释onViewAttachedToWindow方法调用的时机:当RecyclerView中的itemView滑动到屏幕的可见区域时,就会调用该方法。我们在Adapter中重写该方法,打印出调用该方法的对应的itemView的position,结果如下:

2019-09-06 17:59:02.161 24351-24351/com.vision.advancedui E/MyAdapter: onViewAttachedToWindow:Position:02019-09-06 17:59:02.165 24351-24351/com.vision.advancedui E/MyAdapter: onViewAttachedToWindow:Position:12019-09-06 17:59:02.168 24351-24351/com.vision.advancedui E/MyAdapter: onViewAttachedToWindow:Position:22019-09-06 17:59:02.171 24351-24351/com.vision.advancedui E/MyAdapter: onViewAttachedToWindow:Position:3......此处省略45条相似log......2019-09-06 17:59:02.304 24351-24351/com.vision.advancedui E/MyAdapter: onViewAttachedToWindow:Position:49

通过打印出来的日志,我们可以清楚的知道,RecyclerView几乎在一瞬间就加载完了所有的(此处共有50个)itemView。和Google官方宣称的“按需加载”截然相反,当itemView数量足够多的时候,显而易见这会造成极大的性能问题。是Google“撒谎”了么?fded7f9c0863d5499388145d9ff742ae.png《阿里巴巴Android开发规范》里,也有这样的用法例子,并将其树为正面例子。

难道NestedScrollView与RecyclerView也八字不合么

可以肯定的是,RecyclerView单独使用时 没有错, NestedScrollView 也没有错。所以我们猜测又是八字不合造成的问题。
不知道关于RecyclerView的定位你们还记得么:在有限的窗口展示大量的数据
所以我们很容易能想到,会不会是NestedScrollView嵌套下的RecyclerView高度计算出了问题?
所以,View的大小是怎么得到的?

View的绘制流程

相信大部分Android开发者都能回答出来——

  1. measure

  2. layout

  3. draw

对应到我们日常见到的View中的方法就是:

  • onMeasure()

  • onLayout()

  • onDraw()

对于继承自ViewGroup的视图组来说,作为其子view的parent,除了要测量出自身的大小,还要帮助子view测量他们的大小,往往只有子view确定了他们的大小后,ViewGroup等父布局才能确定自己的大小,做到了真正意义上的又当爹又当妈。
在源码中,ViewGroup通过getChildMeasureSpec这个静态方法帮助子view进行测量,源码如下:

public static int getChildMeasureSpec(int spec, int padding, int childDimension) {        int specMode = MeasureSpec.getMode(spec);        int specSize = MeasureSpec.getSize(spec);        int size = Math.max(0, specSize - padding);        int resultSize = 0;        int resultMode = 0;        switch (specMode) {        // Parent has imposed an exact size on us        case MeasureSpec.EXACTLY:            if (childDimension >= 0) {                resultSize = childDimension;                resultMode = MeasureSpec.EXACTLY;            } else if (childDimension == LayoutParams.MATCH_PARENT) {                // Child wants to be our size. So be it.                resultSize = size;                resultMode = MeasureSpec.EXACTLY;            } else if (childDimension == LayoutParams.WRAP_CONTENT) {                // Child wants to determine its own size. It can't be                // bigger than us.                resultSize = size;                resultMode = MeasureSpec.AT_MOST;            }            break;        // Parent has imposed a maximum size on us        case MeasureSpec.AT_MOST:            if (childDimension >= 0) {                // Child wants a specific size... so be it                resultSize = childDimension;                resultMode = MeasureSpec.EXACTLY;            } else if (childDimension == LayoutParams.MATCH_PARENT) {                // Child wants to be our size, but our size is not fixed.                // Constrain child to not be bigger than us.                resultSize = size;                resultMode = MeasureSpec.AT_MOST;            } else if (childDimension == LayoutParams.WRAP_CONTENT) {                // Child wants to determine its own size. It can't be                // bigger than us.                resultSize = size;                resultMode = MeasureSpec.AT_MOST;            }            break;        // 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);    }

这个方法返回一个类型为MeasureSpec的值。
注意,这里的MeasureSpec类型的值就是父布局帮助子布局测量其大小的关键。MeasureSpec,意为测量规格,它是一个32位的int类型。高两位代表测量模式,低30位代表测量大小。其中,测量模式共有3种:

  • UNSPECIFIED 未指明模式
    父布局不限制子布局的大小,对其不做任何限制。

  • EXACTLY 精确模式
    父布局可以确定子布局的最终大小。

  • AT_MOST 至多模式
    父布局确定不了子布局的最终大小,但是子布局的大小不能超过父布局给出的大小。

最终,子布局的大小由父布局指定的MeasureSpec中的SpecMode和自身的LayoutParams按照一定的规则决定。这个规则总结成表格如下:a6270dafc87bcb189f66ee8ffa548eae.png图片来自任玉刚大佬《Android开发艺术探索》

怎么决定的呢?具体代码是在View的onMeasure(int widthMeasureSpec, int heightMeasureSpec)方法,这里的widthMeasureSpecheightMeasureSpec 参数就是父布局传递过来的,源码如下:

protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {        setMeasuredDimension(getDefaultSize(getSuggestedMinimumWidth(), widthMeasureSpec),                getDefaultSize(getSuggestedMinimumHeight(), heightMeasureSpec));    }

在View的测量中,通过调用setMeasuredDimension()方法完成View的测量,其高度和宽度也就随之确定下来。ViewGroup等视图组就是这样不断的递归循环这个流程完成最终的测量。

测量RecyclerView高度的过程中,哪一步出现了问题?

通过以上的知识点,我们可以知道,RecyclerView 的高度,是通过NestedScrollView中传递给RecyclerView中的MeasureSpec参数和RecyclerView中的onMeasure两处决定的。

我们不妨先来看下NestedScrollView中传递给RecyclerView中的MeasureSpec参数是什么样的。

在NestedScrollView中,通过调用measureChild方法将MeasureSpec参数传递给作为子View的RecyclerView,源码如下:

protected void measureChild(View child, int parentWidthMeasureSpec,            int parentHeightMeasureSpec) {        ViewGroup.LayoutParams lp = child.getLayoutParams();        int childWidthMeasureSpec;        int childHeightMeasureSpec;        childWidthMeasureSpec = getChildMeasureSpec(parentWidthMeasureSpec, getPaddingLeft()                + getPaddingRight(), lp.width);        childHeightMeasureSpec = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED);        child.measure(childWidthMeasureSpec, childHeightMeasureSpec);    }

通过源码,我们可以看到,嵌套在NestedScrollView中的RecyclerView的测量模式被指定成了UNSPECIFIED

接下来我们来看决定RecyclerView高度的第二个关键地方——RecyclerView中的onMeasure()方法。源码如下:

protected void onMeasure(int widthSpec, int heightSpec) {        if (mLayout == null) {            defaultOnMeasure(widthSpec, heightSpec);            return;        }        if (mLayout.mAutoMeasure) {            final int widthMode = MeasureSpec.getMode(widthSpec);            final int heightMode = MeasureSpec.getMode(heightSpec);            final boolean skipMeasure = widthMode == MeasureSpec.EXACTLY                    && heightMode == MeasureSpec.EXACTLY;            mLayout.onMeasure(mRecycler, mState, widthSpec, heightSpec);            if (skipMeasure || mAdapter == null) {                return;            }            if (mState.mLayoutStep == State.STEP_START) {                dispatchLayoutStep1();            }            // set dimensions in 2nd step. Pre-layout should happen with old dimensions for            // consistency            mLayout.setMeasureSpecs(widthSpec, heightSpec);            mState.mIsMeasuring = true;            dispatchLayoutStep2();            // now we can get the width and height from the children.            mLayout.setMeasuredDimensionFromChildren(widthSpec, heightSpec);            // if RecyclerView has non-exact width and height and if there is at least one child            // which also has non-exact width & height, we have to re-measure.            if (mLayout.shouldMeasureTwice()) {                mLayout.setMeasureSpecs(                        MeasureSpec.makeMeasureSpec(getMeasuredWidth(), MeasureSpec.EXACTLY),                        MeasureSpec.makeMeasureSpec(getMeasuredHeight(), MeasureSpec.EXACTLY));                mState.mIsMeasuring = true;                dispatchLayoutStep2();                // now we can get the width and height from the children.                mLayout.setMeasuredDimensionFromChildren(widthSpec, heightSpec);            }        } else {            if (mHasFixedSize) {                mLayout.onMeasure(mRecycler, mState, widthSpec, heightSpec);                return;            }            // custom onMeasure            if (mAdapterUpdateDuringMeasure) {                eatRequestLayout();                onEnterLayoutOrScroll();                processAdapterUpdatesAndSetAnimationFlags();                onExitLayoutOrScroll();                if (mState.mRunPredictiveAnimations) {                    mState.mInPreLayout = true;                } else {                    // consume remaining updates to provide a consistent state with the layout pass.                    mAdapterHelper.consumeUpdatesInOnePass();                    mState.mInPreLayout = false;                }                mAdapterUpdateDuringMeasure = false;                resumeRequestLayout(false);            }            if (mAdapter != null) {                mState.mItemCount = mAdapter.getItemCount();            } else {                mState.mItemCount = 0;            }            eatRequestLayout();            mLayout.onMeasure(mRecycler, mState, widthSpec, heightSpec);            resumeRequestLayout(false);            mState.mInPreLayout = false; // clear        }    }

不要被这段代码的长度吓到,其中的逻辑其实很清晰,就是调用调用mLayout.onMeasure(mRecycler, mState, widthSpec, heightSpec);来确定RecyclerView的大小,这个方法也很简单,就是当mAutoMeasure属性为true时,直接调用了mLayout.setMeasuredDimensionFromChildren(widthSpec,heightSpec)方法来确定RecyclerView的大小。

关于mAutoMeasure属性什么时候为true,源码里的注释是这么说的:
This method is usually called by the LayoutManager with value {@code true} if it wants to support WRAP_CONTENT.
这个方法(指的是mAutoMeasure属性的set方法)在LayoutManager支持WRAP_CONTENT属性时常常被设置为true
至于都有哪些 LayoutManager 支持 WRAP_CONTENT ,注释是这么讲的:
LayoutManager should call {@code setAutoMeasureEnabled(true)} to enable it. All of the framework LayoutManagers use {@code auto-measure}.
所有 Android 提供的原生的 LayoutManager 的 mAutoMeasure 属性都为 true。

接下来我们探究一下setMeasuredDimensionFromChildren方法是怎么确定 RecyclerView 的大小的:

void setMeasuredDimensionFromChildren(int widthSpec, int heightSpec) {            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;            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;                }            }            // 遍历RecyclerView的所有子View,将其left、top、right、bottom四个值赋值给mTempRect            mRecyclerView.mTempRect.set(minX, minY, maxX, maxY);            // 真正确定RecyclerView高度的代码            setMeasuredDimension(mRecyclerView.mTempRect, widthSpec, heightSpec);        }

看起来还需要研究一下setMeasuredDimension方法:

public void setMeasuredDimension(Rect childrenBounds, int wSpec, int hSpec) {    int usedWidth = childrenBounds.width() + getPaddingLeft() + getPaddingRight();    // 子View的高度:padding + height    int usedHeight = childrenBounds.height() + getPaddingTop() + getPaddingBottom();    int width = chooseSize(wSpec, usedWidth, getMinimumWidth());    // 看起来chooseSize方法是关键了    int height = chooseSize(hSpec, usedHeight, getMinimumHeight());    // 调用该方法即标志着测量的结束    setMeasuredDimension(width, height);}

这个方法最终调用了setMeasuredDimension(width, height)方法完成了测量,看起来 RecyclerView 的高度就是由这个 height 参数决定的了,height 参数又是由chooseSize方法返回的,我们继续往下看一下这个方法:

 public static int chooseSize(int spec, int desired, int min) {            final int mode = View.MeasureSpec.getMode(spec);            final int size = View.MeasureSpec.getSize(spec);            switch (mode) {                case View.MeasureSpec.EXACTLY:                    return size;                case View.MeasureSpec.AT_MOST:                    return Math.min(size, Math.max(desired, min));                case View.MeasureSpec.UNSPECIFIED:                default:                    // 这里的desired便是setMeasuredDimension中的子View的高度                    return Math.max(desired, min);            }        }
在这个方法中,我们又看到了熟悉的MeasureSpec——当测量模式为UNSPECIFIED时,返回了RecyclerView中子View的高度与最小值两者之间的最大值。

这也就是我们上面介绍的UNSPECIFIED的意义:不对布局大小做限制,即你想要多大就多大。

果然是高度测量出现了问题

经历了上面的一系列探索,我们终于找到了病根:
NestedScrollView传递给子View的测量模式为UNSPECIFIED,RecyclerView在UNSPECIFIED的测量模式下,会不限制自身的高度,即RecyclerView的窗口高度将会变成所有item高度累加后加上paddding的高度。因此,表现出来就是item一次性全部加载完成。

这样做在 RecyclerView 中的item数量较少的时候可能不会有什么问题,但是如果item数量足够多,随之带来的性能问题就会严重影响用户体验甚至崩溃。

所以,在开发过程中,要避免滥用NestedScrollView嵌套RecyclerView

那这种布局要怎么实现

推荐使用RecyclerView的多样式布局实现,毕竟RecyclerView自带滑动,没必要外层套一个ScrollerView或者NestedScrollView。或者使用CoordinatorLayout布局,玩出更多花样~

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值