Android 踩坑記錄(一)- Recyclerview的快取機制
起因
上週因為業務需要,要完成一個展示優惠券資訊的列表,列表內每張券都有詳細資訊,點選詳細資訊或者右面向下的箭頭,可以展開相應優惠券的詳細資訊。展開的同時新增兩個動畫,展開的佈局需要做緩慢展開的動畫,向下展開的箭頭需要做順時針180度旋轉變成向上收縮的狀態。
當時看到這覺得沒問題,一個RecyclerView就搞定了,在Adapter內對Item佈局內的View做一個屬性動畫,簡單省事。於是就開始愉快的敲著鍵盤寫了起來,等寫好一測試,Perfect!
展開收起展開毫無問題,重新整理一下,(⊙o⊙)…問題來了,怎麼箭頭是向上的,我記得在onBindViewHolder裡已經設定Item中箭頭的狀態是向下的。趕緊Debug一下,的確是設定了向下的圖片。後來又分別展開了幾個Item,重新整理了一次列表,發現每次箭頭方向錯亂的位置還不固定。立馬反應過來,估計是條目複用出的問題。立馬開始查RecyclerView的Item快取機制。
RecyclerView條目快取機制
看了原始碼才發現,RecyclerView快取基本上是通過三個內部類管理的,Recycler、RecycledViewPool和ViewCacheExtension。
** Recycler:**
Recycler用於管理已經廢棄或者與RecyclerView分離的ViewHolder,為了方便理解這個類,整理了下面的資料,請結合Recycler的程式碼分析:
內部類的成員變數和他們的含義:
變數 | 作用 |
---|---|
mChangedScrap | 與RecyclerView分離的ViewHolder列表 |
mAttachedScrap | 未與RecyclerView分離的ViewHolder列表 |
mCachedViews | ViewHolder快取列表 |
mViewCacheExtension | 開發者可以控制的ViewHolder快取的幫助類 |
mRecyclerPool | ViewHolder快取池 |
程式碼裡面有個關鍵的方法,註釋來自引文:
ViewHolder getScrapViewForPosition(int position, int type, boolean dryRun) {
final int scrapCount = mAttachedScrap.size();
// 在還未detach的廢棄檢視中查找出來一個型別匹配(無效型別)的view.
for (int i = 0 ; i < scrapCount; i++) {
final ViewHolder holder = mAttachedScrap.get(i);
if (!holder.wasReturnedFromScrap() && holder.getLayoutPosition() == position && !holder.isInvalid() && (mState.mInPreLayout || !holder.isRemoved())) {
if (type != INVALID_TYPE && holder.getItemViewType() != type) {
break;
}
// 表明這個ViewHolder是從廢棄的View集合中取出來的,可用於itemView的返回值。
holder.addFlags(ViewHolder.FLAG_RETURNED_FROM_SCRAP);
return holder;
}
}
if (!dryRun) {
// 找到已經隱藏,但是未被刪除的view,然後將其detach掉,detach scrap中。
View view = mChildHelper.findHiddenNonRemovedView(position, type);
if (view != null) {
final ViewHolder vh = getChildViewHolderInt(view);
mChildHelper.unhide(view);
int layoutIndex = mChildHelper.indexOfChild(view);
mChildHelper.detachViewFromParent(layoutIndex);
scrapView(view);
vh.addFlags(ViewHolder.FLAG_RETURNED_FROM_SCRAP | ViewHolder.FLAG_BOUNCED_FROM_HIDDEN_LIST);
return vh;
}
}
// 在第一級檢視快取中查詢.
final int cacheSize = mCachedViews.size();
for (int i = 0; i < cacheSize; i++) {
final ViewHolder holder = mCachedViews.get(i);
// invalid view holders may be in cache if adapter has stable ids as they can be
// retrieved via getScrapViewForId
if (!holder.isInvalid() && holder.getLayoutPosition() == position) {
if (!dryRun) {
mCachedViews.remove(i);
}
if (DEBUG) {
Log.d(TAG, "getScrapViewForPosition(" + position + ", " + type +
") found match in cache: " + holder);
}
return holder;
}
}
return null;
}
RecycledViewPool:
RecycledViewPool類是用來快取Item用,是一個ViewHolder的快取池,如果多個RecyclerView之間用setRecycledViewPool(RecycledViewPool)
設定同一個RecycledViewPool,他們就可以共享Item。其實RecycledViewPool的內部維護了一個Map,裡面以不同的viewType為Key儲存了各自對應的ViewHolder集合。可以通過提供的方法來修改內部快取的Viewholder。
下面來看下這個類的程式碼:
public static class RecycledViewPool {
private SparseArray<ArrayList<ViewHolder>> mScrap =
new SparseArray<ArrayList<ViewHolder>>();
private SparseIntArray mMaxScrap = new SparseIntArray();
private int mAttachCount = 0;
private static final int DEFAULT_MAX_SCRAP = 5;
public void clear() {
mScrap.clear();
}
public void setMaxRecycledViews(int viewType, int max) {
mMaxScrap.put(viewType, max);
final ArrayList<ViewHolder> scrapHeap = mScrap.get(viewType);
if (scrapHeap != null) {
while (scrapHeap.size() > max) {
scrapHeap.remove(scrapHeap.size() - 1);
}
}
}
public ViewHolder getRecycledView(int viewType) {
final ArrayList<ViewHolder> scrapHeap = mScrap.get(viewType);
if (scrapHeap != null && !scrapHeap.isEmpty()) {
final int index = scrapHeap.size() - 1;
final ViewHolder scrap = scrapHeap.get(index);
scrapHeap.remove(index);
return scrap;
}
return null;
}
int size() {
int count = 0;
for (int i = 0; i < mScrap.size(); i ++) {
ArrayList<ViewHolder> viewHolders = mScrap.valueAt(i);
if (viewHolders != null) {
count += viewHolders.size();
}
}
return count;
}
public void putRecycledView(ViewHolder scrap) {
final int viewType = scrap.getItemViewType();
final ArrayList scrapHeap = getScrapHeapForType(viewType);
if (mMaxScrap.get(viewType) <= scrapHeap.size()) {
return;
}
if (DEBUG && scrapHeap.contains(scrap)) {
throw new IllegalArgumentException("this scrap item already exists");
}
scrap.resetInternal();
scrapHeap.add(scrap);
}
void attach(Adapter adapter) {
mAttachCount++;
}
void detach() {
mAttachCount--;
}
/**
* Detaches the old adapter and attaches the new one.
* <p>
* RecycledViewPool will clear its cache if it has only one adapter attached and the new
* adapter uses a different ViewHolder than the oldAdapter.
*
* @param oldAdapter The previous adapter instance. Will be detached.
* @param newAdapter The new adapter instance. Will be attached.
* @param compatibleWithPrevious True if both oldAdapter and newAdapter are using the same
* ViewHolder and view types.
*/
void onAdapterChanged(Adapter oldAdapter, Adapter newAdapter,
boolean compatibleWithPrevious) {
if (oldAdapter != null) {
detach();
}
if (!compatibleWithPrevious && mAttachCount == 0) {
clear();
}
if (newAdapter != null) {
attach(newAdapter);
}
}
private ArrayList<ViewHolder> getScrapHeapForType(int viewType) {
ArrayList<ViewHolder> scrap = mScrap.get(viewType);
if (scrap == null) {
scrap = new ArrayList<ViewHolder>();
mScrap.put(viewType, scrap);
if (mMaxScrap.indexOfKey(viewType) < 0) {
mMaxScrap.put(viewType, DEFAULT_MAX_SCRAP);
}
}
return scrap;
}
}
這個類提供了四個公共方法:
返回值 | 方法 | 作用 |
---|---|---|
void | clear() | 清空快取池 |
RecyclerView.ViewHolder | getRecycledView(int viewType) | 得到一個viewType型別的Item |
void | putRecycledView(RecyclerView.ViewHolder scrap) | 把viewType型別的Item放入快取池 |
void | setMaxRecycledViews(int viewType, int max) | 設定對應viewType型別的Item的最大快取數量 |
ViewCacheExtension:
我們先來看下程式碼:
public abstract static class ViewCacheExtension {
/**
* Returns a View that can be binded to the given Adapter position.
* <p>
* This method should <b>not</b> create a new View. Instead, it is expected to return
* an already created View that can be re-used for the given type and position.
* If the View is marked as ignored, it should first call
* {@link LayoutManager#stopIgnoringView(View)} before returning the View.
* <p>
* RecyclerView will re-bind the returned View to the position if necessary.
*
* @param recycler The Recycler that can be used to bind the View
* @param position The adapter position
* @param type The type of the View, defined by adapter
* @return A View that is bound to the given position or NULL if there is no View to re-use
* @see LayoutManager#ignoreView(View)
*/
abstract public View getViewForPositionAndType(Recycler recycler, int position, int type);
}
ViewCacheExtension的程式碼一看什麼都沒有,沒錯這是一個需要開發者重寫的類。上面Recycler裡呼叫Recycler.getViewForPosition(int)
方法獲取View時,Recycler先檢查自己內部的attached
scrap
和一級快取,再檢查ViewCacheExtension.getViewForPositionAndType(Recycler, int, int)
,最後檢查RecyclerViewPool,從上面三個任何一個只要拿到View就不會呼叫下一個方法。所以我們可以重寫getViewForPositionAndType(Recycler recycler, int position, int type)
,在方法裡通過Recycler類控制View快取。注意:如果你重寫了這個類,Recycler不會在這個類中做快取View的操作,是否快取View完全由開發者控制。
總結
經過上面的分析,發現被屬性動畫修改過的ImageView在holder裡,被RecyclerView快取了之後,在別的Item又拿出來複用,雖然你設定了向下的背景圖片,但是這個ImageView是做過180旋轉的,所以設定一個向下的箭頭圖片還是向上的樣子。
看來以後像旋轉一類的簡單的動畫還是用View動畫就可以了,複雜的動畫再用屬性動畫。也可以重寫Adapter裡的void onViewDetachedFromWindow(VH holder)
方法,在裡面拿到holder找到修改過的ImageView,恢復他原來的屬性,特別是有View被快取複用的時候一定記得恢復原來的屬性,否則就會出現這種混亂的情況。