在开发中咱们常常会遇到须要ScrollView嵌套RecyclerView的状况,例如美团商家首页这样式的:android
忽略其细节的交互,美团外卖商家首页大体能够抽象成两部分:bash
若是对Android开发规范不太了解的新手,布局大概会这样实现:app
<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>
复制代码
上面的布局看起来好像没什么毛病,咱们运行一下看看效果:ide
看到上图我相信大部分的心里是崩溃的:这尼玛什么鬼,为何Header不会跟随着Content一块儿滑动呢?说好的滚动视图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
* ...此处省略不相关的注释...
*/
复制代码
从注释咱们能明显看出来,官方对于ScrollView最言简意赅的定位就是可使其内部布局滚动的布局。
咱们再接着看一下RecyclerView的定位:学习
/**
* A flexible view for providing a limited window into a large data set.
* 一种灵活的视图,用于在有限的窗口展现大量的数据。
*/
复制代码
在有限的窗口展现大量的数据
,说白了,就是以滚动的方式,使用有限的空间展现大量的数据(这里的“有限”很重要,咱们下面会用到)。
那么问题就来了:两个视图都能滚动,当咱们的手指在屏幕上滑动的时候,Android系统并不知道咱们想要哪一个视图滚动起来,这就是咱们常说的滑动冲突
。flex
除此以外,
ScrollView
嵌套ListView
时,会疯狂调用Adapter中的getView()
方法,将ListView全部的item加载到内存中,消耗大量的内存和cpu资源,引发界面卡顿。这也就是为何《阿里巴巴Android开发手册》中禁止ScrollView
嵌套ListView
/GridView
/ExpandableListView
。ui
如今解决滑动冲突的方案主要有两个,其一:基于传统的事件分发机制;其二:使用NestedScrollingChild
& NestedScrollingParent
。
第一种方案网上相关教程有不少,这里就再也不赘述。关于NestedScrollingChild与NestedScrollingParent的用法推荐学习鸿洋大大的博客:Android NestedScrolling机制彻底解析 带你玩转嵌套滑动。this
因为传统事件分发机制的缺陷(父布局拦截消费滑动事件后没法继续传递给子View),因此咱们这里更推荐第二种方式解决滑动冲突。
固然,若是只是为了解决这里遇到的问题,咱们大可没必要从头研究NestedScrollingParent与NestedScrollingChild的用法,由于Android内置的许多控件已经实现了这两个接口,这其中就包括了咱们接下来要提到的NestedScrollView
.做为平常开发中的高频控件,RecyclerView固然也实现了这一机制。
对于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.
* 默认状况下启用嵌套滚动。
*/
复制代码
看起来NestedScrollView彷佛可以完美解决咱们遇到的困扰,那咱们不妨把上面的根布局换成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>
复制代码
运行起来看下效果:
鼓掌👏撒花🎉,看起来咱们成功解决了问题。真的是这样么?
咱们都知道,RecyclerView 是须要结合 Adapter来使用的,Adapter中有几个关键方法:
/**
* Called when a view created by this adapter has been attached to a window.
* 当Adapter经过onCreateViewHolder方法建立的视图被附加到窗口时调用。
*/
复制代码
也就是说,当RecyclerView中的视图滚动到屏幕咱们能够看到的时候,就会调用该方法。咱们复写该方法,打印出Log,看下NestedScrollView嵌套下的RecyclerView的onViewAttachedToWindow()方法的调用状况:
override fun onViewAttachedToWindow(holder: ViewHolder) {
super.onViewAttachedToWindow(holder)
Log.e(TAG, "onViewAttachedToWindow:" +holder.tvPosition.text.toString())
}
override fun getItemCount(): Int {
return 50
}
复制代码
调用状况以下:
2019-09-06 17:59:02.161 24351-24351/com.vision.advancedui E/MyAdapter: onViewAttachedToWindow:Position:0
2019-09-06 17:59:02.165 24351-24351/com.vision.advancedui E/MyAdapter: onViewAttachedToWindow:Position:1
2019-09-06 17:59:02.168 24351-24351/com.vision.advancedui E/MyAdapter: onViewAttachedToWindow:Position:2
2019-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个)item,和Google官方描述的“按需加载”彻底不一样,是Google注释描述的不对么?
包括《阿里巴巴Android开发规范》里,也有这样的用法示例,并标注为了“正确“用法。到底是哪里出了问题呢?
咱们上文提到了,Google对于RecyclerView的定位是:在有限的窗口展现大量的数据
,咱们很容易想到,会不会是RecyclerView的高度测量出错了?
相信大部分人都知道Android大致的绘制流程(把大象装冰箱,总共分几步?):
映射到咱们日常自定义View中的方法就是onMeasure
、onLayout
、onDraw
三个方法,对于继承自ViewGroup的视图,除了要肯定自身的大小外,还要帮助子View测量,肯定他们的大小,对此,ViewGroup提供了一个静态方法getChildMeasureSpec
:
/**
* Does the hard part of measureChildren: figuring out the MeasureSpec to
* pass to a particular child. This method figures out the right MeasureSpec
* for one dimension (height or width) of one child view.
*
* The goal is to combine information from our MeasureSpec with the
* LayoutParams of the child to get the best possible results. For example,
* if the this view knows its size (because its MeasureSpec has a mode of
* EXACTLY), and the child has indicated in its LayoutParams that it wants
* to be the same size as the parent, the parent should ask the child to
* layout given an exact size.
*
* @param spec The requirements for this view
* @param padding The padding of this view for the current dimension and
* margins, if applicable
* @param childDimension How big the child wants to be in the current
* dimension
* @return a MeasureSpec integer for the child
*/
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,关于MeasureSpce,翻译成中文即为测量规格,它是一个32位的int类型,高2位表明测量模式,低30位表明测量大小。网上关于它的介绍有不少,这里就不展开讲了。咱们这里只要知道,测量模式有3种:
总结成表格就是这样的(借用任玉刚大佬的图):
那这个方法返回的MeasureSpec参数子View又是在哪里用到的呢? 答案就是ViewGroup在测量子View的时候,会调用measureChild
将getChildMeasureSpec
传递给子View的measure
方法,measure
方法会继续调用咱们自定义View时经常使用到的onMeasure(int widthMeasureSpec, int heightMeasureSpec)
方法,这里的widthMeasureSpec
与heightMeasureSpec
参数就是父布局传递过来的。咱们来看下View类中的onMeasure方法:
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
setMeasuredDimension(getDefaultSize(getSuggestedMinimumWidth(), widthMeasureSpec),
getDefaultSize(getSuggestedMinimumHeight(), heightMeasureSpec));
}
复制代码
咱们能够认为,调用setMeasuredDimension方法就标志着子View完成了测量,其高度和宽度也就随之肯定了下来。经过不断的递归循环这个流程就能完成最终的测量。
回到咱们这个问题,经过以上View测量流程的回顾,咱们能够肯定:RecyclerView的高度是由NestedScrollView中传递给RecyclerView中的MeasureSpec参数和RecyclerView中的onMeasure两处决定的 咱们先来看看NestedScrollView中传递给RecyclerView中的MeasureSpec参数,在NestedScrollView的measureChild
方法中是这么写的:
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);
}
复制代码
咱们能够看到,传递给RecyclerView关于高度的测量模式是UNSPECIFIED
。 接下来看看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
}
}
复制代码
这块代码的逻辑仍是很清晰的,在mAutoMeasure
属性为true
时,除了RecyclerView没有精确的宽度和高度 + 至少有一个孩子也有不精确的宽度和高度的时候须要测量两次的时候,高度的测量模式为EXACTLY,其他都是调用mLayout.setMeasuredDimensionFromChildren(widthSpec, heightSpec)
来肯定RecyclerView的大小。
关于mAutoMeasure属性何时为true,源码里的注释是这么说的:
/**
* Defines whether the layout should be measured by the RecyclerView or the LayoutManager
* wants to handle the layout measurements itself.
* <p>
* This method is usually called by the LayoutManager with value {@code true} if it wants
* to support WRAP_CONTENT. If you are using a public LayoutManager but want to customize
* the measurement logic, you can call this method with {@code false} and override
* {@link LayoutManager#onMeasure(int, int)} to implement your custom measurement logic.
* <p>
* AutoMeasure is a convenience mechanism for LayoutManagers to easily wrap their content or
* handle various specs provided by the RecyclerView's parent. * It works by calling {@link LayoutManager#onLayoutChildren(Recycler, State)} during an * {@link RecyclerView#onMeasure(int, int)} call, then calculating desired dimensions based * on children's positions. It does this while supporting all existing animation
* capabilities of the RecyclerView.
复制代码
知道大家的英语和我也是半斤八两,因此这里用大白话翻译一下,中心意思就是:**若是搭配RecyclerView的LayoutManager支持WRAP_CONTENT
的属性时,这个值就应该为true
。
看到这里我相信大家又该有疑问了:
都有哪些LayoutManager支持WRAP_CONTENT属性呢?源码注释是这么说的:
/**
* AutoMeasure works as follows:
* <ol>
* <li>LayoutManager should call {@code setAutoMeasureEnabled(true)} to enable it. All of
* the framework LayoutManagers use {@code auto-measure}.</li>
*/
复制代码
意思就是所用Android提供的原生的LayoutManager的mAutoMeasure属性都为true
。
咱们再来看下setMeasuredDimensionFromChildren
方法。
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);
}
复制代码
最终,咱们定位到:RecyclerView高度的肯定重点依靠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的处理:返回了RecyclerView中子View的高度与最小值二者之间的最大值。
这也就是咱们上面介绍的
UNSPECIFIED
的意义: 不对布局大小作限制,即你想要多大就多大。最终RecyclerView的高度就是全部子View的高度了。
经过上面的探索,我相信在坐的各位应该很清楚问题的缘由了:NestedScrollView传递给子View的测量模式为UNSPECIFIED,RecyclerView在UNSPECIFIED的测量模式下,会不限制自身的高度,即RecyclerView的窗口高度将会变成全部item高度累加后加上paddding的高度。所以,表现出来就是item一次性所有加载完成。
这样作在RecyclerView的item数量较少的时候可能没什么问题,可是若是item数量比较多,随之带来的性能问题就会很严重。
因此这里我斗胆发出不同的声音:禁止使用
NestedScrollView
嵌套RecyclerView
。
推荐使用RecyclerView的多样式布局实现,毕竟RecyclerView自带滑动,不必外层套一个ScrollerView或者NestedScrollView。或者使用CoordinatorLayout
布局,玩出更多花样~
这篇文章发出来以前,个人心里也是充满忐忑的,毕竟开始接触Android的时候,我也是以为《阿里巴巴Android开发手册》是不可能错的。没想到文章的反响会这么大,针对评论里提的比较多的话题这里作一个统一的回复:
Q: RecyclerView的高度不使用WRAP_CONTENT而是使用特定的值(好比200dp)是否是就没有这个问题了?
A:答案是确定的,经过任玉刚大佬总结的表咱们也能够知道:只有当Parent的测量模式为UNSPECIFIED、子View的layoutparams中的高度设定为WRAP_CONTENT或者MATCH_PARENT时,子View的测量模式才为UNSPECIFIED。
Q: 《阿里巴巴Android开发手册》中并非倡导你们使用NestedScrollView嵌套RecyclerView,而是提倡你们使用NestedScrollView嵌套RecyclerView的方案替换ScrollView嵌套RecyclerView的方案。
A:不排除这种状况,但是NestedScrollView嵌套RecyclerView确实会有问题,除了对性能的影响外,若是项目中在onAttachViewToWindow中有其余操做(好比曝光)就会影响该操做的准确程度了,这点《阿里巴巴Android开发手册》没有提到,这篇文章的初衷也只是让你们对NestedScrollView嵌套RecyclerView的缺点有一个具体的认知,并且,我我的对于不分状况的使用NestedScrollView嵌套RecyclerView并不认同。
Q: RecyclerView的数据量小的时候,可使用NestedScrollView嵌套RecyclerView么?
A:RecyclerView数量可控的状况下,使用NestedScrollView嵌套RecyclerView可能确实不会有性能上的问题,若是在Adapter中没有对onAttachViewToWindow方法作任何扩展,也确实没有其余的影响。可是站在我的立场下我仍是不推荐这么作:RecyclerView自己支持滑动,没有必要在外层嵌套NestedScrollView,NestedScrollView嵌套RecyclerView的方案除了开发的时候节省了些许时间外其余没有一点好处。固然,写这篇文章也不是就要求你们必定按照这样的方式去实现,毕竟别人说的再好,不必定适合你的项目。
最后,我的始终以为《阿里巴巴Android开发手册》是一本好手册,上面确实提供了不少Android开发的开发者注意不到的地方,我的也从中获益匪浅,这片文章也只是针对其中的一点谈了一些本身不同的理解,毕竟开源平台“百家争鸣”。 最后的最后,谢谢大家喜欢个人文章,不胜感激。