轉(zhuǎn)自RecyclerView系列之四實現(xiàn)回收復(fù)用
在上面文章自定義LayoutManager中講解了LayoutManager的自定義,實現(xiàn)了界面的展示和滑動。熟悉RecyclerView的都知道其緩存機(jī)制,在添加緩存的回收與復(fù)用之前,先簡單介紹下RecyclerView的緩存機(jī)制。
1、RecyclerView的回收復(fù)用原理
1.1、RecyclerView的回收
在滑動過程中,RecyclerView會將即將與之分離的ViewHolder放到mCachedViews和mRecyclerPool,可使用
removeAndRecycleView(View child, Recycler recycler)
函數(shù)進(jìn)行回收。
這兩級緩存的區(qū)別是:mCachedViews是第一級緩存,大小默認(rèn)為2,數(shù)據(jù)結(jié)構(gòu)是ArrayList。當(dāng)其中數(shù)量超過2時,會根據(jù)FIFO的原則移除元素,并將移除的元素添加到mRecyclerPool中。
mRecyclerPool是一個緩存池,本質(zhì)上它是SparseArray,key是itemViewType,value是一個ArrayList,value的大小默認(rèn)為5。除了上面說到兩級緩存還有mAttachedScrap,在onLayoutChildren中會調(diào)用函數(shù)
detachAndScrapAttachedViews(recycler);
將屏幕上ViewHolder進(jìn)行detach,并暫存到mAttachedScrap,再重新布局時從mAttachedScrap中取出,attach到RecyclerView上。
1.2、RecyclerView的復(fù)用
通過View view = recycler.getViewForPosition(position)
可以實現(xiàn)復(fù)用,根據(jù)源碼可知,在RecyclerView中,總共有四級緩存,優(yōu)先級:mAttachedScrap>mCachedViews>mViewCacheExtension>mRecyclerPool。
- mAttachedScrap:只保存當(dāng)前屏幕中detach的ViewHolder,在重新布局時復(fù)用。
- mCachedViews:緩存的是剛從RecyclerView中移除的ViewHolder(通過
removeAndRecycleView(view, recycler)方法
),在復(fù)用時需要position或id匹配才能復(fù)用,所以只有在來回滑動過程中才會復(fù)用mCachedViews中的ViewHolder。如果不能匹配就需要從mRecyclerPool中取出ViewHolder并重新綁定數(shù)據(jù)。 - 復(fù)用mAttachedScrap、mCachedViews中的ViewHolder是需要精確匹配的,如果能匹配上可直接使用不需綁定數(shù)據(jù),如果不能精確匹配,即使mAttachedScrap、mCachedViews中有緩存也不能取出使用,只能從mRecyclerPool中取出使用,并且需重綁數(shù)據(jù)。如果mRecyclerPool中沒有緩存就需要調(diào)用onCreateViewHolder進(jìn)行創(chuàng)建。
2、幾個函數(shù)
public void detachAndScrapAttachedViews(Recycler recycler)
僅用于onLayoutChildren中,在布局前將屏幕上的ViewHolder從RecyclerView中detach掉,將其放在mAttachedScrap中,以供重新布局時使用。View view = recycler.getViewForPosition(position)
當(dāng)我們需要填充布局時,就可以調(diào)用該方法,從四個緩存容器中取出合適的View,然后添加到RecyclerView中。removeAndRecycleView(child, recycler)
該函數(shù)僅用于在滑動過程中,在滾動時,將滾出屏幕的ViewHolder進(jìn)行remove并添加到mCachedViews或mRecyclerPool中。
可以看到,正是這三個函數(shù)的使用,可以讓我們自定義的LayoutManager具有復(fù)用功能。
另外,還有幾個常用,但經(jīng)常出錯的函數(shù):int getItemCount()
得到的是Adapter中總共有多少數(shù)據(jù)要顯示,也就是總共有多少個itemint getChildCount()
得到的是當(dāng)前RecyclerView在顯示的item的個數(shù),所以這就是getChildCount()與 getItemCount()的區(qū)別View getChildAt(int position)
獲取某個可見位置的View,需要非常注意的是,它的位置索引并不是Adapter中的位置索引,而是當(dāng)前在屏幕上的位置的索引。也就是說,要獲取當(dāng)前屏幕上在顯示的第一個item的View,應(yīng)該用getChidAt(0),同樣,如果要得到當(dāng)前屏幕上在顯示的最后一個item的View,應(yīng)該用getChildAt(getChildCount()-1)int getPosition(View view)
這個函數(shù)用于得到某個View在Adapter中的索引位置,我們經(jīng)常將它與getChildAt(int position)聯(lián)合使用,得到某個當(dāng)前屏幕上在顯示的View在Adapter中的位置。
3、自定義LayoutManager的回收和復(fù)用原理
從上面的原理中可以看到,回收復(fù)用主要有兩部分:
第一:在onLayoutChildren初始布局時:
- 1、在布局前調(diào)用
detachAndScrapAttachedViews(recycler)
將所有可見的ViewHolder detach。 - 2、通過調(diào)用
recycler.getViewForPosition(position)
申請一個View,并添加到RecyclerView中,直到填充滿整個屏幕。
第二:在scrollVerticallyBy滑動時
- 1、判斷滾動dy后,那些ViewHolder需要回收,然后調(diào)用
removeAndRecycleView(child, recycler)
進(jìn)行回收。 - 2、然后通過調(diào)用
recycler.getViewForPosition(position)
獲取View,填充空白區(qū)域。
4、為自定義LayoutManager添加回收復(fù)用
4.1、修改onLayoutChildren
上面已經(jīng)提到,在onLayoutChildren中,我們主要做兩件事:
- 1、在布局前調(diào)用
detachAndScrapAttachedViews(recycler)
將所有可見的ViewHolder detach。 - 2、通過調(diào)用
recycler.getViewForPosition(position)
申請一個View,并添加到RecyclerView中,直到填充滿整個屏幕。
關(guān)鍵就在于如何判斷一屏能顯示多少個item,在這里每個item高度相同,所以可以通過RecyclerView的高度處于item的高度即可
private int mItemWidth,mItemHeight;
@Override
public void onLayoutChildren(RecyclerView.Recycler recycler, RecyclerView.State state) {
if (getItemCount() == 0) {//沒有Item,界面空著吧
detachAndScrapAttachedViews(recycler);
return;
}
detachAndScrapAttachedViews(recycler);
View childView = recycler.getViewForPosition(0);
measureChildWithMargins(childView, 0, 0);
mItemWidth = getDecoratedMeasuredWidth(childView);
mItemHeight = getDecoratedMeasuredHeight(childView);
int visiableCount = (int) Math.ceil(getVerticalSpace() * 1.0f / mItemHeight);
if (visiableCount > itemCount)
visiableCount = itemCount;
…………
}
//其中 getVerticalSpace()在上面已經(jīng)提到,得到的是RecyclerView用于顯示的高度,它的定義是:
private int getVerticalSpace() {
return getHeight() - getPaddingBottom() - getPaddingTop();
}
一屏可見的item個數(shù)=(int) Math.ceil(getVerticalSpace() * 1.0f / mItemHeight);
,這里使用Math.ceil進(jìn)行向上取整的原因就是:如果一屏內(nèi)可顯示1.5個item,此時可見的item應(yīng)該為2才對。
除此之外,由于item高度相同,為了布局方便,我們在初始化時,利用一個變量來保存在初始化時,在Adapter中每一個item的位置:
int offsetY = 0;
for (int i = 0; i < getItemCount(); i++) {
Rect rect = new Rect(0, offsetY, mItemWidth, offsetY + mItemHeight);
mItemRects.put(i, rect);
offsetY += mItemHeight;
}
接下來布局可見的item,不可見的item不再布局
for (int i = 0; i < visibleCount; i++) {
Rect rect = mItemRects.get(i);
View view = recycler.getViewForPosition(i);
addView(view);
//addView后一定要measure,先measure再layout
measureChildWithMargins(view, 0, 0);
layoutDecorated(view, rect.left, rect.top, rect.right, rect.bottom);
}
mTotalHeight = Math.max(offsetY, getVerticalVisibleHeight());
因為,在上面我們已經(jīng)從保存了初始化狀態(tài)下每個Item的位置,所以在初始化時,直接從mItemRects中取出當(dāng)前要顯示的Item的位置,直接將它擺放在這個位置就可以了。需要注意的是,因為我們在之前已經(jīng)使用detachAndScrapAttachedViews(recycler);
將所有view從RecyclerView中剝離,所以,我們需要重新通過addView(view)
添加進(jìn)來。在添加進(jìn)來以后,需要走一個這個View的測量和layout邏輯,先經(jīng)過測量,再將它layout到指定位置。如果我們沒有測量直接layout,會什么都出不來,因為任何view的layout都是依賴measure出來的位置信息的。
到此,完整的onLayoutChildren的代碼如下:
private int mItemWidth, mItemHeight;
private SparseArray<Rect> mItemRects = new SparseArray<>();;
@Override
public void onLayoutChildren(RecyclerView.Recycler recycler, RecyclerView.State state) {
if (getItemCount() == 0) {//沒有Item,界面空著吧
detachAndScrapAttachedViews(recycler);
return;
}
detachAndScrapAttachedViews(recycler);
//將item的位置存儲起來
View childView = recycler.getViewForPosition(0);
measureChildWithMargins(childView, 0, 0);
mItemWidth = getDecoratedMeasuredWidth(childView);
mItemHeight = getDecoratedMeasuredHeight(childView);
int visiableCount = (int) Math.ceil(getVerticalSpace() * 1.0f / mItemHeight);
if (visiableCount > itemCount)
visiableCount = itemCount;
//定義豎直方向的偏移量
int offsetY = 0;
for (int i = 0; i < getItemCount(); i++) {
Rect rect = new Rect(0, offsetY, mItemWidth, offsetY + mItemHeight);
mItemRects.put(i, rect);
offsetY += mItemHeight;
}
for (int i = 0; i < visibleCount; i++) {
Rect rect = mItemRects.get(i);
View view = recycler.getViewForPosition(i);
addView(view);
//addView后一定要measure,先measure再layout
measureChildWithMargins(view, 0, 0);
layoutDecorated(view, rect.left, rect.top, rect.right, rect.bottom);
}
//如果所有子View的高度和沒有填滿RecyclerView的高度,
// 則將高度設(shè)置為RecyclerView的高度
mTotalHeight = Math.max(offsetY, getVerticalSpace());
}
4.2、處理滾動
經(jīng)過上面的分析可知,我們可知,我們首先回收滾出屏幕的ViewHolder,然后再填充滾動后的空白區(qū)域。向上滾動和向下滾動雖然都是回收滾出屏幕的ViewHolder,但是處理邏輯還是有區(qū)別的,下面就按照滾動方向分為兩種情況進(jìn)行分析。
4.2.1、處理向上滾動
向上滾動時dy>0,這里先假設(shè)向上滾動dy,然后判斷哪些ViewHolder需要回收,需要新增哪些item,然后再執(zhí)行offsetChildrenVertical(-travel)
進(jìn)行滑動。
因為在開始移動之前,我們對dy做了到頂、到底的邊界判斷并進(jìn)行了修正。
int travel = dy;
//如果滑動到最頂部
if (mSumDy + dy < 0) {
travel = -mSumDy;
} else if (mSumDy + dy > mTotalHeight - getVerticalSpace()) {
//如果滑動到最底部
travel = mTotalHeight - getVerticalSpace() - mSumDy;
}
所以真正移動的距離,是修正后的travel。所以在進(jìn)行處理回收和填充item,應(yīng)該以travel進(jìn)行判斷。
1、判斷回收item
在判斷向上滑動回收哪些item時,應(yīng)該遍歷當(dāng)前屏幕所有可見的item,假設(shè)讓它們向上滑動travel,然后判斷是否已經(jīng)超出了上邊界(y=0),如果超出就進(jìn)行回收。
for (int i = getChildCount() - 1; i >= 0; i--) {
View child = getChildAt(i);
if (travel > 0) {//需要回收當(dāng)前屏幕,上越界的View
if (getDecoratedBottom(child) - travel< 0) {
removeAndRecycleView(child, recycler);
}
}
}
在上面代碼中:
- 首先遍歷屏幕上所有可見的item,這里getChildCount()-1表示屏幕上可見的最后一個item,注意和getItemCount()的區(qū)別。
- 由于是獲取屏幕上可見的item,所以可以調(diào)用getChildAt(i)直接獲取。開始我不注意使用了recycler.getViewForPosition(i)去獲取了,這是從四個緩存池中獲取,很明顯不對。
-
getDecoratedBottom(child) - travel< 0
表示向上移動travel后超出了上邊界,故對進(jìn)入該判斷的item進(jìn)行回收。 - 注意這里使用
removeAndRecycleView(child, recycler)
方法進(jìn)行回收,而不是detachAndScrapAttachedViews(recycler)
方法。在滾動時,滾出屏幕的ViewHolder應(yīng)該remove掉,而不是detach掉。在onLayoutChildren中進(jìn)行布局時,需要暫存屏幕上的ViewHolder,在再次布局時使用,此時就需要使用detachAndScrapAttachedViews(recycler)
方法。
2、填充空白區(qū)域
假設(shè)向上滾動了travel后,屏幕的位置如下圖,左邊是初始狀態(tài),右邊是移動后的情況,其中綠色框表示屏幕。其實RecyclerView的滑動只是其中的內(nèi)容在滑動,這里假設(shè)內(nèi)容的位置不動,那么屏幕相對于內(nèi)容就發(fā)生滑動。
在滾動travel后,屏幕此時所在的區(qū)域如下:
private Rect getVisibleArea(int travel) {
Rect result = new Rect(getPaddingLeft(), getPaddingTop() + mSumDy + travel, getWidth() - getPaddingRight(), getHeight()-getPaddingBottom() + mSumDy + travel);
return result;
}
mSumDy表示已經(jīng)滑動的距離,travel表示即將滑動的距離。所以mSumDy + travel表示此時滑動后,屏幕的位置。
由于在onLayoutChildren中初始化布局時,已經(jīng)記錄每個item的初始位置,在拿到屏幕移動后的位置后,只需要和初始化item的位置進(jìn)行比對,如果存在交集就表示在屏幕內(nèi),否則表示已滑出了屏幕。
分析到這里,我們還是不知道哪些item要滑入屏幕,再回看下上圖不難看出,滑入屏幕的item無非就是在當(dāng)前屏幕中可見的最后一個item的下一個item一直到第itemCount個item中一些item將要滑入屏幕。
Rect visibleRect = getVisibleArea(travel);
//布局子View階段
if (travel >= 0) {
View lastView = getChildAt(getChildCount() - 1);
int minPos = getPosition(lastView) + 1;//從最后一個View+1開始吧\
//順序addChildView
for (int i = minPos; i <= getItemCount() - 1; i++) {
Rect rect = mItemRects.get(i);
if (Rect.intersects(visibleRect, rect)) {
View child = recycler.getViewForPosition(i);
addView(child);
measureChildWithMargins(child, 0, 0);
layoutDecorated(child, rect.left, rect.top - mSumDy, rect.right, rect.bottom - mSumDy);
} else {
break;
}
}
}
mSumDy += travel;
// 平移容器內(nèi)的item
offsetChildrenVertical(-travel);
我們來看看上面代碼:
首先獲取滑動后的屏幕的位置
Rect visibleRect = getVisibleArea(travel);
然后,找到移動前最后一個可見的View
View lastView = getChildAt(getChildCount() - 1);
然后,找到它之后的一個item:
int minPos = getPosition(lastView) + 1;
然后從這個item開始查詢,看它和它之后的每個item是不是都在可見區(qū)域內(nèi),之后就是判斷這個item是不是在顯示區(qū)域,如果在就加進(jìn)來并且布局,如果不在就退出循環(huán):
for (int i = minPos; i <= getItemCount() - 1; i++) {
Rect rect = mItemRects.get(i);
if (Rect.intersects(visibleRect, rect)) {
View child = recycler.getViewForPosition(i);
addView(child);
measureChildWithMargins(child, 0, 0);
layoutDecorated(child, rect.left, rect.top - mSumDy, rect.right, rect.bottom - mSumDy);
} else {
break;
}
}
需要注意的是:mItemRects中記錄的是item的位置是參考屏幕(0,0),在向上滾動時,我們需要把高度減去滑動的距離,這樣才能實現(xiàn)滾入屏幕。注意這個滑動距離并不包括即將滑動的距離travel,雖然我們判斷哪些item是新增顯示時,假設(shè)移動了travel,其實到目前為止并沒有發(fā)生滾動。所以我們在布局時,仍然需要按上次的移動距離來進(jìn)行布局,所以這里在布局時使用是layoutDecorated(child, rect.left, rect.top - mSumDy, rect.right, rect.bottom - mSumDy),單純只是減去了mSumDy,并沒有同時減去mSumDy和travel,最后才調(diào)用offsetChildrenVertical(-travel)來整體移動布局好的item。這時才會把我們剛才新增布局上的item顯示出來。
所以,此時完整的scrollVerticallyBy的代碼如下:
public int scrollVerticallyBy(int dy, RecyclerView.Recycler recycler, RecyclerView.State state) {
if (getChildCount() <= 0) {
return dy;
}
int travel = dy;
//如果滑動到最頂部
if (mSumDy + dy < 0) {
travel = -mSumDy;
} else if (mSumDy + dy > mTotalHeight - getVerticalSpace()) {
//如果滑動到最底部
travel = mTotalHeight - getVerticalSpace() - mSumDy;
}
//回收越界子View
for (int i = getChildCount() - 1; i >= 0; i--) {
View child = getChildAt(i);
if (travel > 0) {//需要回收當(dāng)前屏幕,上越界的View
if (getDecoratedBottom(child) - travel < 0) {
removeAndRecycleView(child, recycler);
}
}
}
Rect visibleRect = getVisibleArea(travel);
//布局子View階段
if (travel >= 0) {
View lastView = getChildAt(getChildCount() - 1);
int minPos = getPosition(lastView) + 1;//從最后一個View+1開始吧
//順序addChildView
for (int i = minPos; i <= getItemCount() - 1; i++) {
Rect rect = mItemRects.get(i);
if (Rect.intersects(visibleRect, rect)) {
View child = recycler.getViewForPosition(i);
addView(child);
measureChildWithMargins(child, 0, 0);
layoutDecorated(child, rect.left, rect.top - mSumDy, rect.right, rect.bottom - mSumDy);
} else {
break;
}
}
}
mSumDy += travel;
// 平移容器內(nèi)的item
offsetChildrenVertical(-travel);
return travel;
}
此時已經(jīng)實現(xiàn)了向上滾動的功能,并在向上滾動過程中,回收滑動屏幕的ViewHolder。
4.2.2、處理向下滾動
在分析完向上滾動的處理后,向下滾動的處理就很簡單了,和向上滾動是完全相反的。
1、判斷回收item
遍歷當(dāng)前屏幕可見的item,假設(shè)向下移動travel后,判斷哪些item滑出了屏幕的底部,回收滑出的item即可。
for (int i = getChildCount() - 1; i >= 0; i--) {
View child = getChildAt(i);
if (travel > 0) {//需要回收當(dāng)前屏幕,上越界的View
…………
}else if (travel < 0) {//回收當(dāng)前屏幕,下越界的View
if (getDecoratedTop(child) - travel > getHeight() - getPaddingBottom()) {
removeAndRecycleView(child, recycler);
}
}
}
getDecoratedTop(child) - travel
得到移動travel距離后item的頂部位置,然后判斷是否大于屏幕底部的位置getHeight() - getPaddingBottom()
,若大于則表示滑動了屏幕。
2、為滾動后的空白處填充Item
向下滾動,RecyclerView的頭部位置滾動后會有空白,故可以從當(dāng)前屏幕可見的第一個item的上一個開始遍歷,到第0個item結(jié)束,判斷哪些item在屏幕內(nèi),將在屏幕內(nèi)的item添加進(jìn)來。
Rect visibleRect = getVisibleArea(travel);
//布局子View階段
if (travel >= 0) {
…………
} else {
View firstView = getChildAt(0);
int maxPos = getPosition(firstView) - 1;
for (int i = maxPos; i >= 0; i--) {
Rect rect = mItemRects.get(i);
if (Rect.intersects(visibleRect, rect)) {
View child = recycler.getViewForPosition(i);
addView(child, 0);
measureChildWithMargins(child, 0, 0);
layoutDecoratedWithMargins(child, rect.left, rect.top - mSumDy, rect.right, rect.bottom - mSumDy);
} else {
break;
}
}
}
下面來看看這段代碼:
在這里,先得到在滾動前顯示的第一個item的前一個item:
View firstView = getChildAt(0);
int maxPos = getPosition(firstView) - 1;
如果在顯示區(qū)域,那么,就將它插在第一的位置:
addView(child, 0);
同樣,在布局Item時,由于還沒有移動,所以在布局時并不考慮travel的事:layoutDecoratedWithMargins(child, rect.left, rect.top - mSumDy, rect.right, rect.bottom - mSumDy)
。
其它的代碼都很好理解了,這里就不再講了。
這樣就完整實現(xiàn)了滾動的回收和復(fù)用功能了,完整的scrollVerticallyBy代碼如下:
public int scrollVerticallyBy(int dy, RecyclerView.Recycler recycler, RecyclerView.State state) {
if (getChildCount() <= 0) {
return dy;
}
int travel = dy;
//如果滑動到最頂部
if (mSumDy + dy < 0) {
travel = -mSumDy;
} else if (mSumDy + dy > mTotalHeight - getVerticalSpace()) {
//如果滑動到最底部
travel = mTotalHeight - getVerticalSpace() - mSumDy;
}
//回收越界子View
for (int i = getChildCount() - 1; i >= 0; i--) {
View child = getChildAt(i);
if (travel > 0) {//需要回收當(dāng)前屏幕,上越界的View
if (getDecoratedBottom(child) - travel < 0) {
removeAndRecycleView(child, recycler);
continue;
}
} else if (travel < 0) {//回收當(dāng)前屏幕,下越界的View
if (getDecoratedTop(child) - travel > getHeight() - getPaddingBottom()) {
removeAndRecycleView(child, recycler);
}
}
}
Rect visibleRect = getVisibleArea(travel);
//布局子View階段
if (travel >= 0) {
View lastView = getChildAt(getChildCount() - 1);
int minPos = getPosition(lastView) + 1;//從最后一個View+1開始吧
//順序addChildView
for (int i = minPos; i <= getItemCount() - 1; i++) {
Rect rect = mItemRects.get(i);
if (Rect.intersects(visibleRect, rect)) {
View child = recycler.getViewForPosition(i);
addView(child);
measureChildWithMargins(child, 0, 0);
layoutDecorated(child, rect.left, rect.top - mSumDy, rect.right, rect.bottom - mSumDy);
} else {
break;
}
}
} else {
View firstView = getChildAt(0);
int maxPos = getPosition(firstView) - 1;
for (int i = maxPos; i >= 0; i--) {
Rect rect = mItemRects.get(i);
if (Rect.intersects(visibleRect, rect)) {
View child = recycler.getViewForPosition(i);
addView(child, 0);//將View添加至RecyclerView中,childIndex為1,但是View的位置還是由layout的位置決定
measureChildWithMargins(child, 0, 0);
layoutDecoratedWithMargins(child, rect.left, rect.top - mSumDy, rect.right, rect.bottom - mSumDy);
} else {
break;
}
}
}
mSumDy += travel;
// 平移容器內(nèi)的item
offsetChildrenVertical(-travel);
return travel;
}
到此為止,我們已經(jīng)為LayoutManager增加了回收復(fù)用的功能,但是這里我們調(diào)用offsetChildrenVertical(-travel)
來實現(xiàn)平移,當(dāng)需要實現(xiàn)在平移時,改變每個item的大小、角度等參數(shù)時,offsetChildrenVertical(-travel)
就無法完成了。