美團外賣商家首頁在開發中咱們常常會遇到須要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
ScrollView 嵌套 RecyclerView, What's The Fuck!看到上圖我相信大部分的心裏是崩潰的:這尼瑪什麼鬼,爲何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
阿里巴巴Android開發手冊除此以外,
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開發的開發者注意不到的地方,我的也從中獲益匪淺,這片文章也只是針對其中的一點談了一些本身不同的理解,畢竟開源平臺「百家爭鳴」。 最後的最後,謝謝大家喜歡個人文章,不勝感激。