原始碼解析ListView中的RecycleBin機制

孫群發表於2016-04-05

在自定義Adapter時,我們常常會重寫Adapter的getView方法,該方法的簽名如下所示:

public abstract View getView (int 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方法,如下所示:

@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增刪相關的關鍵程式碼,主要分以下三個階段:

  1. ListView的children->RecycleBin
  2. ListView清空children
  3. RecycleBin->ListView的children

在layout這個方法剛剛開始執行的時候,ListView中的children其實還是上一幀中需要繪製的子View的集合,在layout這個方法執行完成的時候,ListView中的children就變成了當前幀馬上要進行繪製的子View的集合。

下面對以上這三個階段分別說明。

  1. ListView的children->RecycleBin
    該階段的關鍵程式碼如下所示:

    //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進行復用。

  2. ListView清空children
    然後呼叫ViewGroup的detachAllViewsFromParent方法,該方法將所有的子View從ListView中分離,也就是清空了children,該方法原始碼如下所示:

    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進行判斷,原始碼如下所示:

    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方法的原始碼如下所示:

    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中。該方法的方法簽名如下所示:

private View makeAndAddView(int position, int y, boolean flow, int childrenLeft,
            boolean selected)

其原始碼如下所示:

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方法,該方法的方法簽名如下所示:

View obtainView(int position, boolean[] isScrap)

該方法接收position引數,其關鍵的原始碼有以下兩行:

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機制有所幫助!

相關閱讀:
我的Android博文整理彙總
使用詳解及原始碼解析Android中的Adapter、BaseAdapter、ArrayAdapter、SimpleAdapter和SimpleCursorAdapter

相關文章