
吸頂效果
RecyclerView已經成為在Android Native開發過程中的明星元件,出鏡率超高,隻要需要清單展示的内容,我們第一想到的就是使用RecyclerView。RecyclerView确實是一個很容易上手功能又很強大的元件,通過設定不同的LayoutManager就可以實作不同的顯示樣式清單、網格等。在日常的開發過程中我經常會遇到“吸頂”這種情況,就是清單中的某些Item在滾動到清單的頂部的時候需要固定住,如上圖的效果。要實作這種效果的兩種最常見的方案是使用ItemDecoration群組合布局的方式,這兩種方案分别有個字的優缺點這裡我們簡單的分析一下。
1. 使用組合布局
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
android:id="@+id/rlv"
android:layout_width="match_parent"
android:layout_height="match_parent" />
大體實作方案如上所示,将要吸頂的ViewHolder(為友善後面的描述我們這裡把顯示在RecyclerView中的ViewHolder叫真ViewHolder,飄在RecyclerView上面的叫假ViewHolder)的布局放在RecyclerView布局上層,在業務層的代碼中通過監聽RecyclerView的滾動事件,控制假ViewHolder的顯示、隐藏以及移動等,目前市面上大部分App使用的都是這種方案(我是怎麼知道的?用AS的ViewTree工具分析一下就知道了😊),但是這種方案存在以下缺點:
如果有多種不同的ViewHolder需要吸頂的時候,業務處理的複雜度會呈幾何級數上升,這會導緻bug層出不窮。
吸頂的ViewHolder如果是可互動的(例如響應橫向滾動,選中等)就需要做真假ViewHolder的資料和狀态的雙向同步工作,如果吸頂的ViewHolder業務比較複雜,這一定是一個讓人心力憔悴的活。
擴充能力弱,相似的功能複用成本很高,總是要修修補補才能複用。
也許你會問,如果真如你所說有這麼多問題,那為什麼還有這麼多人使用這種方案?呵呵,因為簡單啊,這個方案是最容易想到的不是嗎?說實話這個方案我也用過,否則我咋知道會有這麼多問題😄。
2. 使用ItemDecoration
class II extends RecyclerView.ItemDecoration{
@Override
public void getItemOffsets(@NonNull Rect outRect,
@NonNull View view,
@NonNull RecyclerView parent,
@NonNull RecyclerView.State state) {
super.getItemOffsets(outRect, view, parent, state);
}
@Override
public void onDrawOver(@NonNull Canvas c,
@NonNull RecyclerView parent,
@NonNull RecyclerView.State state) {
super.onDrawOver(c, parent, state);
}
}
ItemDecoration通常用來實作RecyclerView中item的分割線效果,利用其本身的一些特性也能做出吸頂效果來,大體思路如下:
通過ItemDecoration的getItemOffsets方法将吸頂區域空出來
通過View.getDrawingCache()拿到需要吸頂ViewHolder的bitmap
通過ItemDecoration的onDrawOver将吸頂ViewHolder的bitmap繪制在吸頂區域中
該方案跟上面的使用組合布局的方案比起來,通用性要好很多,複用起來也比較友善,但是該方案也有一個緻命的缺點,那就是吸頂的ViewHolder不能響應事件,如果需要吸頂的ViewHolder中有動态的内容如Gif或視訊等,也不能做到很好的相容。
3. 自定義LayoutManager
除了這兩種方案還有沒有别的方案?答案肯定是有的,使用LayoutManager!對,沒錯!我肯定我不是第一個想到這個方案的人,稍微對RecyclerView有點了解的人都會想到這個解決方案,目前我在網上還沒發現(可能有隻是我沒找到)使用LayoutManager解決這個問題的成熟方案。RecyclerView加LayoutManager大約有1萬多行代碼,要想從頭讀到尾确實需要費點時間,我覺得其實我們也沒必要從頭讀到尾把所有的技術細節都弄明白,隻要能達到自己的目的就可以了,就拿建立一個自定義LayoutManager這件事來說我們隻需要弄明白RecyclerView的緩存政策和布局流程,我覺得就可以了,如果你時間和精力充足要把它扒個底朝天那也很棒,下面我們就簡單分析閱讀下這兩部分的源碼。
真愛生命,遠離源碼☠️
3.1 緩存政策
RecyclerView的緩存政策一直是RecyclerView的熱門知識點,不管你是想斬offer還是吹牛*這個是必備。在RecyclerView中ViewHolder複用相關的邏輯都封裝在Recycler中,按照順訊分為四層:
mAttachedScrap 和 mChangedScrap
有人說這一級緩存是告訴緩存,我就有點納悶,“高速”是咋展現出來的?我是沒看出來!這四層緩存如果按照适用場景來劃分我覺得會更容易了解
mAttachedScrap -- 目前RecyclerView中已經有ViewHolder填充,RecyclerView又觸發onLayoutChildren的時候,目前正在顯示的這部分ViewHolder會被回收到mAttachedScrap中,在layoutChunk方法中被重新取出。
mChangedScrap -- 隻會被用在預布局中
mAttachedScrap 和 mChangedScrap 隻有在onLayoutChildren()方法調用的時候才會用到,在滾動的過程中沒用,隻有觸發requestLayout()的時候才會調用。
mCachedViews
在滾動過程中滾出螢幕區域而被回收的ViewHolder會被加入到該層緩存,緩存數量支援自定義預設為2,按照先進先出的規則溢出。
mViewCacheExtension
使用者自定義緩存
mRecyclerPool
該層緩存用于存儲從mCachedView緩存中溢出的ViewHolder。
RecyclerView緩存的通路順序存取是保持一緻的,回收部分的源碼:
private void scrapOrRecycleView(Recycler recycler, int index, View view) {
final ViewHolder viewHolder = getChildViewHolderInt(view);
if (viewHolder.shouldIgnore()) {
return;
}
if (viewHolder.isInvalid() && !viewHolder.isRemoved()
&& !mRecyclerView.mAdapter.hasStableIds()) {
removeViewAt(index);
//回收到mCachedViews或mRecyclerPool中
recycler.recycleViewHolderInternal(viewHolder);
} else {
detachViewAt(index);
//回收到mAttachedScrap 或 mChangedScrap中
recycler.scrapView(view);
mRecyclerView.mViewInfoStore.onViewDetached(viewHolder);
}
緩存複用最終會調用到tryGetViewHolderForPositionByDeadline方法,這個方法源碼巨長省略不相關源碼,核心源碼如下:
@NonNull
public View getViewForPosition(int position) {
return getViewForPosition(position, false);
}
View getViewForPosition(int position, boolean dryRun) {
return tryGetViewHolderForPositionByDeadline(position, dryRun, FOREVER_NS).itemView;
}
@Nullable
ViewHolder tryGetViewHolderForPositionByDeadline(int position,
boolean dryRun, long deadlineNs) {
...
boolean fromScrapOrHiddenOrCache = false;
ViewHolder holder = null;
// 0) If there is a changed scrap, try to find from there
if (mState.isPreLayout()) {
holder = getChangedScrapViewForPosition(position);
fromScrapOrHiddenOrCache = holder != null;
}
// 1) Find by position from scrap/hidden list/cache
if (holder == null) {
//從 1和2級緩存中取
holder = getScrapOrHiddenOrCachedHolderForPosition(position, dryRun);
if (holder != null) {
...
}
}
if (holder == null) {
...
if (holder == null && mViewCacheExtension != null) {
// We are NOT sending the offsetPosition because LayoutManager does not
// know it.
// 從三級自定義緩存中取
final View view = mViewCacheExtension
.getViewForPositionAndType(this, position, type);
....
}
if (holder == null) { // fallback to pool
if (DEBUG) {
Log.d(TAG, "tryGetViewHolderForPositionByDeadline("
+ position + ") fetching from shared pool");
}
//從四級緩存中取
holder = getRecycledViewPool().getRecycledView(type);
if (holder != null) {
holder.resetInternal();
if (FORCE_INVALIDATE_DISPLAY_LIST) {
invalidateDisplayListInt(holder);
}
}
}
if (holder == null) {
...
//通過Adapter重新建立新的ViewHolder執行個體
holder = mAdapter.createViewHolder(RecyclerView.this, type);
...
}
}
...
//綁定資料相關邏輯省略
return holder;
}
3.2 布局流程
RecyclerView的布局分為兩部分非别為初始布局和滾動過程中的布局,兩者的處理邏輯有所不同。初始布局相關業務邏輯主要由onLayoutChildren()方法承載,滾動過程中的布局相關邏輯主要由scrollVerticallyBy()承載。其中有一個比較核心的方法是fill()方法,該方法是ViewHolder布局的核心方法。
int fill(RecyclerView.Recycler recycler, LayoutState layoutState,
RecyclerView.State state, boolean stopOnFocusable) {
// max offset we should set is mFastScroll + available
final int start = layoutState.mAvailable;
//判斷是否産生有效滾動
if (layoutState.mScrollingOffset != LayoutState.SCROLLING_OFFSET_NaN) {
// TODO ugly bug fix. should not happen
if (layoutState.mAvailable < 0) {
layoutState.mScrollingOffset += layoutState.mAvailable;
}
//檢查時候有需要回收的ViewHolder
recycleByLayoutState(recycler, layoutState);
}
int remainingSpace = layoutState.mAvailable + layoutState.mExtraFillSpace;
LayoutChunkResult layoutChunkResult = mLayoutChunkResult;
while ((layoutState.mInfinite || remainingSpace > 0) && layoutState.hasMore(state)) {
layoutChunkResult.resetInternal();
if (RecyclerView.VERBOSE_TRACING) {
TraceCompat.beginSection("LLM LayoutChunk");
}
...
//布局ViewHolder
layoutChunk(recycler, state, layoutState, layoutChunkResult);
...
if (layoutState.mScrollingOffset != LayoutState.SCROLLING_OFFSET_NaN) {
layoutState.mScrollingOffset += layoutChunkResult.mConsumed;
if (layoutState.mAvailable < 0) {
layoutState.mScrollingOffset += layoutState.mAvailable;
}
recycleByLayoutState(recycler, layoutState);
}
if (stopOnFocusable && layoutChunkResult.mFocusable) {
break;
}
}
if (DEBUG) {
validateChildOrder();
}
return start - layoutState.mAvailable;
注意:RecyclerView在滾動布局過程中如果沒有新的ViewHolder産生的時候是不會掉用fill()方法的。
3.3 實作方案
有了上面那西基礎做鋪墊我們就可以開始動手寫一個LayoutManager了,整體思路如下:
在RecyclerView現有的四層緩存之上,再建立一層緩存,用于緩存吸頂的ViewHolder
篩選出需要吸頂的ViewHolder加入自定義緩存
向上滾動(手指上滑)的過程中,在目标ViewHolder到達上邊緣的位置的吸頂位置時候阻止其繼續滾動,将目标ViewHolder強制繪制在螢幕的上部,并将其加入吸頂ViewHolder緩存(止其進入RecyclerView的内部回收機制)。
向下滾動(手指下滑)的過程中,在目标ViewHolder離開吸頂區域後,将其從吸頂緩存中移除,并将其重新放回到RecyclerView内部的緩存中。
總結起來就兩句話:吸頂的ViewHolder加到新增的自定義緩存中,将LinearLayoutManager排完的ViewHolder重新排列一下。
3.3.1 吸頂協定
整體的開發思路我們已經确定,首先我們要解決的問題就是如何将要吸頂的ViewHolder篩選出來呢?這裡我的方案是定義一個協定接口Section,通過檢測該ViewHolder是否實作該接口判斷該ViewHolder是否需要被吸頂。
public interface Section {
}
public class SectionViewHolder extends RecyclerView.ViewHolder implements Section {
public TextView tv;
public SectionViewHolder(@NonNull View v) {
super(v);
}
}
3.3.2 自定義緩存
因為一次隻有一個ViewHolder吸頂,當清單中有多個可以吸頂的ViewHolder的時候,在向上滾動的時候新出現的吸頂ViewHolder會将目前正在吸頂的ViewHolder頂上去,我們需要将這些被頂上去的ViewHolder儲存起來(阻止進入系統緩存),這樣在向下滾動的時候這些ViewHolder重新顯示的時候才會保持之前的狀态,否則會進入系統緩存被重新綁定資料,導緻之前的狀态丢失。是以我們需要建立一個緩存棧(後進先出)用于儲存吸頂的ViewHolder,在清單向上滾動的過程中,有符合條件的ViewHolder出現的時候我們就将其入棧,在清單向下滾動的過程中如果吸頂ViewHolder離開吸頂位置的時候我們就将其出棧。這個緩存棧就是我們新加的自定義緩存,棧頂的ViewHolder就是目前吸頂的ViewHolder,代碼如下:
public class SectionCache extends Stack {
private Map
filterMap = new HashMap<>(16, 64);
@Override
public RecyclerView.ViewHolder push(RecyclerView.ViewHolder item) {
if (item == null) {
return null;
}
int position = item.getLayoutPosition();
//避免存在重複的Value
if (filterMap.containsKey(position)) {
//傳回null說明沒有添加成功
return null;
}
filterMap.put(position, item);
return super.push(item);
}
@Override
public synchronized RecyclerView.ViewHolder peek() {
if (size() == 0) {
return null;
}
return super.peek();
}
public List clearTop(int layoutPosition) {
List removedViewHolders = new LinkedList<>();
Iterator it = iterator();
while (it.hasNext()) {
RecyclerView.ViewHolder top = it.next();
if (top.getLayoutPosition() > layoutPosition) {
it.remove();
filterMap.remove(top.getLayoutPosition());
removedViewHolders.add(top);
}
}
return removedViewHolders;
}
}
3.3.3 過濾ViewHolder
這裡我們需要把目前正在顯示的目标ViewHolder過濾出來并根據目前的dy判斷是否會滾動到吸頂位置,不幸的是LayoutManager并沒有提供擷取ViewHolder的api,隻提供了擷取childView()的方法。查閱源碼發現ViewHolder中有這樣一個api

getChildViewHolderInt
childView對應的ViewHolder會儲存在其LayoutParams.mViewHolder中,通過這個方案我們可以把目前正在顯示的ViewHolder過濾出來。
for (int i = 0; i < getChildCount(); i++) {
View itemView = getChildAt(i);
RecyclerView.ViewHolder vh = getViewHolderByView(itemView);
if (!(vh instanceof Section) || sectionCache.peek() == vh) {
continue;
}
if (dy > 0 && vh.itemView.getTop() < dy) {
sectionCache.push(vh);
} else {
break;
}
}
注意并不是說所有顯示出來的需要吸頂的ViewHolder都需要立即加入到我們的自定義緩存中,隻有向上滾動到吸頂位置的吸頂ViewHolder加入緩存棧。

image.png
假設A和E是兩個可以吸頂的ViewHolder,目前螢幕正在向上滾動,此時A需要加入緩存棧,但是E不需要加入緩存隊列。E隻有持續向上滾動到A所在的位置的時候才會被加入我們自定義的緩存棧。
3.3.4 攔截
在清單的滾動過程中我們除了要将這些需要吸頂的ViewHolder加入到我們自定義的緩存棧中,我們還要阻止其進入RecylverView的緩存中,否則清單繼續向上滾動ViewHolder A就會滾出螢幕,如下圖所示,這個時候ViewHolder A就會被Recycler回收,放入第二層緩存(mCachedViews)中,再有吸頂ViewHolder滾動出來的時候之前回收的RecyclerView就會被複用和重新綁定資料,之前的ViewHolder A的狀态就會丢失。

圖二
在清單上滑過程中圖三是我們所期望的結果,ViewHolder A在上滑到頂部的時候我們需要将其固定在RecyclerView的頂部。

圖三
RecyclerView滾動相關的業務邏輯主要是在scrollVerticallyBy(int dy, RecyclerView.Recycler recycler, RecyclerView.State state)方法中,該方法有三個參數作用如下:
dy -- 本次滾動的距離,dy > 0是向上滾動(手指上滑),反之下滑
recycler -- 緩存器,定義了四層緩存政策
state -- 用于傳遞資料資訊,例如是否是預布局等
在LinearLayoutManager中該方法的源碼如下
@Override
public int scrollVerticallyBy(int dy, RecyclerView.Recycler recycler,
RecyclerView.State state) {
if (mOrientation == HORIZONTAL) {
return 0;
}
return scrollBy(dy, recycler, state);
}
這裡我們要做的就是在scollBy()之前插入我們的回收代碼,在之後加入我們的重新布局代碼。因為要相容LienarLayoutManager是以我們不對scrollBy内部的内容進行修改,這樣我們就可以保證相容性。
我們分析RecyclerView四層緩存的時候我們已經了解了内部實作的一些細節問題緩存複用和滾動處理等等,scrollBy()是包通路權限,我們無法對其進行重載,是以我們隻能從scrollVerticallyBy()方法下手了,其實我們也沒必要關心scrollBy()方法内被
for (RecyclerView.ViewHolder viewHolder : sectionCache) {
removeView(viewHolder.itemView);
}
在scrollBy()方法調用之前我們把吸頂的ViewHolder remove掉就可以阻止其進入Recycler的緩存中,因為ViewHolder相關的資訊儲存在itemView.layoutParams中,移除View就可以阻止其回收。就這麼簡單?對就這麼簡單!
3.3.5 重新布局
如果現在使用我們自定義的LayoutManager應該是 圖四 這種效果,當吸頂ViewHodler進入吸頂位置後就會變成空白。

image-20201123101155909.png
我們需要将Remove掉的ViewHolder重新加回到RecyclerView中并将其布局在合适的位置,這裡有幾個關鍵點需要注意下:
dy可能大于一個ViewHolder的高度
如果目前吸頂位置已經有吸頂ViewHolder占據的時候,後來的吸頂ViewHolder需要将其頂上去
在向下滾動(手指下滑)的時候,由于吸頂的ViewHolder都沒有進入Recycler的緩存,是以在向下滾動的時候RecyclerView會重新建立ViewHolder執行個體,我們需要将其替換為我們自定義緩存中儲存的執行個體。
具體實作代碼如下:
//檢查棧頂
RecyclerView.ViewHolder vh = getViewHolderByView(getChildAt(0));
RecyclerView.ViewHolder attachedSection = sectionCache.peek();
if ((vh instanceof Section)
&& attachedSection != null
&& attachedSection.getLayoutPosition() == vh.getLayoutPosition()) {
removeViewAt(0);
}
// 處理向下滾動
for (RecyclerView.ViewHolder removedViewHolder : sectionCache.clearTop(findFirstVisibleItemPosition())) {
Log.i(tag, "移除ViewHolder:" + removedViewHolder.toString());
for (int i = 0; i < getChildCount(); i++) {
RecyclerView.ViewHolder attachedViewHolder = getViewHolderByView(getChildAt(i));
if (removedViewHolder.getLayoutPosition() == attachedViewHolder.getLayoutPosition()) {
View attachedItemView = attachedViewHolder.itemView;
int left = attachedItemView.getLeft();
int top = attachedItemView.getTop();
int bottom = attachedItemView.getBottom();
int right = attachedItemView.getRight();
//這裡的remvoe 和 add 是為了重新布局
removeView(attachedItemView);
addView(removedViewHolder.itemView, i);
removedViewHolder.itemView.layout(left, top, right, bottom);
break;
}
}
}
//重新布局
RecyclerView.ViewHolder section = sectionCache.peek();
if (section != null) {
View itemView = section.itemView;
if (!itemView.isAttachedToWindow()) {
addView(itemView);
}
View subItem = getChildAt(1);
if (getViewHolderByView(subItem) instanceof Section) {
int h = itemView.getMeasuredHeight();
int top = Math.min(0, -(h - subItem.getTop()));
int bottom = Math.min(h, subItem.getTop());
itemView.layout(0, top, itemView.getMeasuredWidth(), bottom);
} else {
itemView.layout(0, 0, itemView.getMeasuredWidth(), itemView.getMeasuredHeight());
}
}
每段代碼的作用已經用注釋描述,這裡不再贅述,效果如下:

未命名.gif
源碼位址
如有錯誤或意見歡迎在評論區讨論。
作為一個碼農,腦袋偷懶身體受苦 --- 但是上司總是喜歡那些不動腦筋拼命加班的人。。。