在自定義Adapter時,我們常常會重寫Adapter的getView方法,該方法的簽名如下所示:
1 |
<span class="hljs-keyword">public</span> <span class="hljs-keyword">abstract</span> View <span class="hljs-title">getView</span> (<span class="hljs-keyword">int</span> position, View convertView, ViewGroup parent) |
此處會傳入一個convertView變數,它的值有可能是null,也有可能不是null,如果不為null,我們就可以複用該convertView,對convertView裡面的一些控制元件賦值後可以將convertView作為getView的返回值返回,這麼做的目的是減少LayoutInflater.inflate()的呼叫次數,從而提升了效能(LayoutInflater.inflate()比較消耗效能)。
本文將介紹ListView中的RecycleBin機制,讓大家對ListView中的優化機制有個概括的瞭解,同時也說明convertView的來龍去脈。
首先,我們知道,Adapter是資料來源,AdapterView是展示資料來源的UI控制元件,Adapter是給AdapterView使用的,通過呼叫AdapterView的setAdapter方法就可以讓一個AdapterView繫結Adapter物件,從而AdapterView會將Adapter中的資料展示出來。
AdapterView的子類有AbsListView和AbsSpinner等,其中AbsListView的子類又有ListView、GridView等,所以ListView繼承自AdapterView。
如果Adapter中有10000條資料,將這個Adapter物件賦給ListView,如果ListView建立10000個子View,那麼App肯定崩潰了,因為Android沒有能力同時繪製這麼多的子View。而且,即便能同時繪製這10000個子View也沒什麼意義,因為手機的螢幕大小是有限的,有可能ListView的高度只能最多顯示10個子View。基於此,Android在設計ListView這個類的時候,引入了RecycleBin機制—–對子View進行回收利用,RecycleBin直譯過來就是回收站的意思。
RecycleBin基本原理
下面先簡要說一下RecycleBin中的工作原理,後面會結合原始碼詳細說明。
在某一時刻,我們看到ListView中有許多View呈現在UI上,這些View對我們來說是可見的,這些可見的View可以稱作OnScreen的View,即在螢幕中能看到的View,也可以叫做ActiveView,因為它們是在UI上可操作的。
當觸控ListView並向上滑動時,ListView上部的一些OnScreen的View位置上移,並移除了ListView的螢幕範圍,此時這些OnScreen的View就變得不可見了,不可見的View叫做OffScreen的View,即這些View已經不在螢幕可見範圍內了,也可以叫做ScrapView,Scrap表示廢棄的意思,ScrapView的意思是這些OffScreen的View不再處於可以互動的Active狀態了。ListView會把那些ScrapView(即OffScreen的View)刪除,這樣就不用繪製這些本來就不可見的View了,同時,ListView會把這些刪除的ScrapView放入到RecycleBin中存起來,就像把暫時無用的資源放到回收站一樣。
當ListView的底部需要顯示新的View的時候,會從RecycleBin中取出一個ScrapView,將其作為convertView引數傳遞給Adapter的getView方法,從而達到View複用的目的,這樣就不必在Adapter的getView方法中執行LayoutInflater.inflate()方法了。
RecycleBin中有兩個重要的View陣列,分別是mActiveViews和mScrapViews。這兩個陣列中所儲存的View都是用來複用的,只不過mActiveViews中儲存的是OnScreen的View,這些View很有可能被直接複用;而mScrapViews中儲存的是OffScreen的View,這些View主要是用來間接複用的。
上面對mActiveViews和mScrapViews的說明比較籠統,其實在細節上還牽扯到Adapter的資料來源發生變化的情況,具體細節後面會講解。
原始碼解析
AdapterView是繼承自ViewGroup的,ViewGroup中有addView方法可以向ViewGroup中新增子View,但是AdapterView重寫了addView方法,如下所示:
1 2 3 4 5 6 7 8 9 |
@Override public void addView(View child) { throw new UnsupportedOperationException("addView(View) is not supported in AdapterView"); } @Override public void addView(View child, int index) { throw new UnsupportedOperationException("addView(View, int) is not supported in AdapterView"); } |
在AdapterView的addView方法中會丟擲異常,也就是說AdapterView禁用了addView方法。
在具體講解之前,我們還是先花一點時間簡要說一下View的每一幀的顯示流程,當然,ListView也肯定遵循此流程。一個View要想在介面上呈現出來,需要經過三個階段:measure->layout->draw。
View是一幀一幀繪製的,每一幀繪製都經歷了measure->layout->draw這三個階段,繪製完一幀之後,如果UI需要更新,比如使用者滾動了ListView,那麼又會繪製下一幀,再次經歷measure->layout->draw方法,如果對此不瞭解,可以參見另一篇博文《 Android中View的量算、佈局及繪圖機制》。
我們上面說了,AdapterView把addView方法給禁用了,那麼ListView怎麼向其中新增child呢?奧祕就在layout中,在佈局的時候,ListView會執行layoutChildren方法,該方法是ListView對View進行新增以及回收的關鍵方法,RecycleBin的很多方法都在layoutChildren方法中被呼叫。在layoutChildren方法中實現對子View的增刪,經過layoutChildren方法之後,ListView中所有的子View都是在螢幕中可見的,也就是說layoutChildren方法為接下來的幀繪製把子View準備完善了,這就保證了在後面的draw方法的執行過程中能夠正確繪製ListView。
ListView的layoutChildren方法程式碼比較多,我們只研究和View增刪相關的關鍵程式碼,主要分以下三個階段:
- ListView的children->RecycleBin
- ListView清空children
- RecycleBin->ListView的children
在layout這個方法剛剛開始執行的時候,ListView中的children其實還是上一幀中需要繪製的子View的集合,在layout這個方法執行完成的時候,ListView中的children就變成了當前幀馬上要進行繪製的子View的集合。
下面對以上這三個階段分別說明。
- ListView的children->RecycleBin
該階段的關鍵程式碼如下所示:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
//mFirstPosition是ListView的成員變數,儲存著第一個顯示的child所對應的adapter的position final int firstPosition = mFirstPosition; final RecycleBin recycleBin = mRecycler; if (dataChanged) { //如果資料發生了變化,那麼就把ListView的所有子View都放入到RecycleBin的mScrapViews陣列中 for (int i = 0; i < childCount; i++) { //addScrapView方法會傳入一個View,以及這個View所對應的position recycleBin.addScrapView(getChildAt(i), firstPosition+i); } } else { //如果資料沒發生變化,那麼把ListView的所有子View都放入到RecycleBin的mActiveViews陣列中 recycleBin.fillActiveViews(childCount, firstPosition); } |
- 再次強調一下,在上面的程式碼剛開始的時候,ListView的中的children還是上一幀需要繪製的子View。
- 如果Adapter呼叫了notifyDataSetChanged方法,那麼AdapterView就會知道Adapter的資料來源發生了變化,此時dataChanged變數就為true,這種情況下,ListView會認為children中的View都是不合格的了,這時候會用getChildAt方法遍歷children中所有的child,並把這些child通過RecycleBin的addScrapView方法將其放入RecycleBin的mScrapViews陣列中。
- 如果adapter的資料沒有發生變化,那麼會呼叫RecycleBin的fillActiveViews方法將所有的children都放入到RecycleBin的mActiveViews陣列中。
經過上面的操作之後,ListView所有的子View都放入到了RecycleBin中,這就實現了ListView的children->RecycleBin的遷移過程,放到RecycleBin的目的是為了分類快取ListView中的children,以便在後續過程中對這些View進行復用。
- ListView清空children
然後呼叫ViewGroup的detachAllViewsFromParent方法,該方法將所有的子View從ListView中分離,也就是清空了children,該方法原始碼如下所示:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
protected void detachAllViewsFromParent() { final int count = mChildrenCount; if (count <= 0) { return; } final View[] children = mChildren; mChildrenCount = 0; for (int i = count - 1; i >= 0; i--) { children[i].mParent = null; children[i] = null; } } |
3. RecycleBin->ListView的children
然後ListView會根據mLayoutMode進行判斷,原始碼如下所示:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 |
switch (mLayoutMode) { case LAYOUT_SET_SELECTION: if (newSel != null) { sel = fillFromSelection(newSel.getTop(), childrenTop, childrenBottom); } else { sel = fillFromMiddle(childrenTop, childrenBottom); } break; case LAYOUT_SYNC: sel = fillSpecific(mSyncPosition, mSpecificTop); break; case LAYOUT_FORCE_BOTTOM: sel = fillUp(mItemCount - 1, childrenBottom); adjustViewsUpOrDown(); break; case LAYOUT_FORCE_TOP: mFirstPosition = 0; sel = fillFromTop(childrenTop); adjustViewsUpOrDown(); break; case LAYOUT_SPECIFIC: sel = fillSpecific(reconcileSelectedPosition(), mSpecificTop); break; case LAYOUT_MOVE_SELECTION: sel = moveSelection(oldSel, newSel, delta, childrenTop, childrenBottom); break; default: if (childCount == 0) { if (!mStackFromBottom) { final int position = lookForSelectablePosition(0, true); setSelectedPositionInt(position); sel = fillFromTop(childrenTop); } else { final int position = lookForSelectablePosition(mItemCount - 1, false); setSelectedPositionInt(position); sel = fillUp(mItemCount - 1, childrenBottom); } } else { if (mSelectedPosition >= 0 && mSelectedPosition < mItemCount) { sel = fillSpecific(mSelectedPosition, oldSel == null ? childrenTop : oldSel.getTop()); } else if (mFirstPosition < mItemCount) { sel = fillSpecific(mFirstPosition, oldFirst == null ? childrenTop : oldFirst.getTop()); } else { sel = fillSpecific(0, childrenTop); } } break; } |
在該switch程式碼段中,會根據不同情況增刪子View,這些方法的程式碼邏輯大部分最終呼叫了fillDown、fillUp等方法。
fillDown用子View從指定的position自上而下填充ListView,fillUp則是自下而上填充,我們以fillDown方法為例詳細說明。
fillDown方法的原始碼如下所示:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
private View fillDown(int pos, int nextTop) { View selectedView = null; //end表示ListView的高度 int end = (mBottom - mTop); if ((mGroupFlags & CLIP_TO_PADDING_MASK) == CLIP_TO_PADDING_MASK) { end -= mListPadding.bottom; } //nextTop < end確保了我們只要將新增的子View能夠覆蓋ListView的介面就可以了 //pos < mItemCount確保了我們新增的子View在Adapter中都有對應的資料來源item while (nextTop < end && pos < mItemCount) { // is this the selected item? boolean selected = pos == mSelectedPosition; View child = makeAndAddView(pos, nextTop, true, mListPadding.left, selected); //將最新child的bottom值作為下一個child的top值,儲存在nextTop中 nextTop = child.getBottom() + mDividerHeight; if (selected) { selectedView = child; } //position自增 pos++; } setVisibleRangeHint(mFirstPosition, mFirstPosition + getChildCount() - 1); return selectedView; } |
-
- fillDown接收兩個引數,pos表示列表中第一個要繪製的item的position,其對應著Adapter中的索引,nextTop表示第一個要繪製的item在ListView中實際的位置, 即該item所對應的子View的頂部到ListView的頂部的畫素數。
- 首先將mBottom – mTop的值作為end,end表示ListView的高度。
- 然後在while迴圈中新增子View,我們先不看while迴圈的具體條件,先看一下迴圈體。在迴圈體中,將pos和nextTop傳遞給makeAndAddView方法,該方法返回一個View作為child,該方法會建立View,並把該View作為child新增到ListView的children陣列中。
- 然後執行nextTop = child.getBottom() + mDividerHeight,child的bottom值表示的是該child的底部到ListView頂部的距離,將該child的bottom作為下一個child的top,也就是說nextTop一直儲存著下一個child的top值。
- 最後呼叫pos++實現position指標下移。現在我們回過頭來看一下while迴圈的條件while (nextTop < end && pos < mItemCount)。
- nextTop < end確保了我們只要將新增的子View能夠覆蓋ListView的介面就可以了,比如ListView的高度最多顯示10個子View,我們沒必要向ListView中加入11個子View。
- pos < mItemCount確保了我們新增的子View在Adapter中都有對應的資料來源item,比如ListView的高度最多顯示10個子View,但是我們Adapter中一共才有5條資料,這種情況下只能向ListView中加入5個子View,從而不能填充滿ListView的全部高度。
經過了上面的while迴圈之後,ListView對子View的增刪就完成了,即children中存放的就是要在後面繪圖過程中即將渲染的子View的集合。
上面while迴圈的方法體中呼叫了makeAndAddView方法,通過該方法會獲得一個子View,並把該子View新增到ListView的children中。該方法的方法簽名如下所示:
1 2 |
private View makeAndAddView(int position, int y, boolean flow, int childrenLeft, boolean selected) |
其原始碼如下所示:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
private View makeAndAddView(int position, int y, boolean flow, int childrenLeft, boolean selected) { View child; if (!mDataChanged) { // 如果資料來源沒發生變化,那麼嘗試用該position從RecycleBin的mActiveViews中獲取可複用的View child = mRecycler.getActiveView(position); if (child != null) { // 如果child 不為空,說明我們找到了一個已經存在的child,這樣mActiveViews中儲存的View就被直接複用了 // 呼叫setupChild,對child進行定位 setupChild(child, position, y, flow, childrenLeft, selected, true); return child; } } // 如果沒能夠從mActivieViews中直接複用View,那麼就要呼叫obtainView方法獲取View,該方法嘗試間接複用RecycleBin中的mScrapViews中的View,如果不能間接複用,則建立新的View child = obtainView(position, mIsScrap); // 呼叫setupChild方法,進行定位和量算 setupChild(child, position, y, flow, childrenLeft, selected, mIsScrap[0]); return child; } |
我們重點說一下前兩個引數position和y,position表示的是資料來源item在Adapter中的索引,y表示要生成的View的top值或bottom值。如果第三個引數flow是true,那麼y表示top值,否則表示bottom值。
- 如果資料來源沒發生變化,那麼嘗試用該position從RecycleBin的mActiveViews中獲取可複用的View。RecycleBin的getActiveView方法接收一個position引數,可以在RecycleBin的mActiveViews陣列中查詢有沒有對應position的View,如果能找到就可以直接複用該View作為child了。舉一個例子,假設在某一時刻ListView中顯示了10個子View,position依次為從0到9。然後我們手指向上滑動,且向上滑動了一個子View的高度,ListView需要繪製下一幀。這時候ListView在layoutChildren方法中把這10個子View都放入到了RecycleBin的mActiveViews陣列中了,然後清空了children陣列,然後呼叫fillDown方法,向ListView中依次新增position1到10的子View,在新增position為1的子View的時候,由於在上一幀中position為1的子View已經被放到mActiveViews陣列中了,這次直接可以將其從mActiveViews陣列中取出來,這樣就是直接複用子View,所以說RecycleBin的mActiveViews陣列主要是用於直接複用的。在直接複用了子View後,我們需要呼叫setupChild方法,該方法會將child新增到ListView的children陣列中,並對child進行定位。
- 如果沒能夠從mActivieViews中直接複用View,那麼就要呼叫obtainView方法獲取View,該方法嘗試間接複用RecycleBin中的mScrapViews中的View,如果不能間接複用,則建立新的View。在通過obtainView獲取了View之後,呼叫setupChild方法,該方法會將child新增到ListView的children陣列中,並對child進行定位和量算。
下面我們再來看一下obtainView方法,該方法的方法簽名如下所示:
1 |
View obtainView(int position, boolean[] isScrap) |
該方法接收position引數,其關鍵的原始碼有以下兩行:
1 2 |
final View scrapView = mRecycler.getScrapView(position); final View child = mAdapter.getView(position, scrapView, this); |
通過呼叫RecycleBin的getScrapView方法,從mScrapViews陣列中獲取一個View,該View是用來間接複用的,該View可能為null,也可能不為null,將其作為我們熟悉的convertView傳遞給Adapter的getView方法,這樣我們就可以在AdapterView的getView方法中通過判斷convertView是否為空進行間接複用了。
希望本文對大家理解ListView的RecycleBin機制有所幫助!