前言
在现在的App上我们经常能见到如下的交互:
美团外卖商家首页
不考虑细节部分的交互,这个页面我们大体上可以分为两个部分:
位于顶部的商家详情(为了称呼方便,这篇文章称其为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起来看下效果:
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
。
阿里巴巴Android开发手册
我还是想让Header能滑动,应该怎么做
通过上面的分析,我们清楚的知道了,问题的根源就在于滑动冲突
,那要怎么解决滑动冲突呢?
目前常见的滑动冲突的解决方案主要有两个:
传统的事件分发机制
NestedScrollingChild
与NestedScrollingParent
这里说是两个方案是有些不太准确的,因为NestedScrollingChild
与NestedScrollingParent
的实现也是基于传统的事件分发机制。我们这里姑且认为这是两种方案。
如果你恰好不了解传统的事件分发机制也没关系,网上这方面的介绍有很多,也不影响你使用NestedScrollingChild
与NestedScrollingParent
。关于NestedScrollingChild
与NestedScrollingParent
的用法,推荐学习鸿洋大大的博客:Android NestedScrolling机制完全解析 带你玩转嵌套滑动。
传统的事件分发机制有一个弊端:在一次滑动事件中,当Parent拦截之后,是没有办法再把事件交给Child的(排除手动调用分发事件),而
NestedScrollingChild
与NestedScrollingParent
机制就没有这个问题。
当然,如果只是让Header能够跟随ScrollView滑动起来,我们完全没有必要从头学习NestedScrollingChild
与NestedScrollingParent
的用法,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>
再次运行,效果如下:
换成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“撒谎”了么?《阿里巴巴Android开发规范》里,也有这样的用法例子,并将其树为正面例子。
难道NestedScrollView与RecyclerView也八字不合么
可以肯定的是,RecyclerView单独使用时 没有错, NestedScrollView 也没有错。所以我们猜测又是八字不合造成的问题。
不知道关于RecyclerView的定位你们还记得么:在有限的窗口展示大量的数据
。
所以我们很容易能想到,会不会是NestedScrollView嵌套下的RecyclerView高度计算出了问题?
所以,View的大小是怎么得到的?
View的绘制流程
相信大部分Android开发者都能回答出来——
measure
layout
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按照一定的规则决定。这个规则总结成表格如下:图片来自任玉刚大佬《Android开发艺术探索》
怎么决定的呢?具体代码是在View的onMeasure(int widthMeasureSpec, int heightMeasureSpec)
方法,这里的widthMeasureSpec
与heightMeasureSpec
参数就是父布局传递过来的,源码如下:
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布局,玩出更多花样~