Adapter最佳實踐

天之界線2010發表於2017-02-05

本文會不定期更新,推薦watch下專案

如果喜歡請star,如果覺得有紕漏請提交issue,如果你有更好的點子可以提交pull request。

本文的示例程式碼主要是基於CommonAdapter這個庫編寫的,若你有其他的技巧和方法可以參與進來一起完善這篇文章。

本文固定連線:github.com/tianzhijiex…


一、背景

  • 從維護角度看,大量的專案中的adapter都是雜亂不堪的
  • 從擴充套件角度看,在多個type的情況下,無論是維護還是擴充套件都變得十分複雜
  • 從設計角度看,我們無法明確定義adapter屬於的層級
  • 從效能角度看,adapter的好壞對於list頁面的效能有著關鍵的作用

為了降低專案程式碼的複雜度,讓大家能專注於業務而不是考慮效能,我們必須要對adapter進行一個封裝。

二、需求

基礎:

  1. item必須是高內聚的,能處理自己的點選事件,它獨立於adapter
  2. item本身可以獲得當前頁面的activity
  3. adapter不應是一個獨立的類,它更合適作沒有複用價值的內部類
  4. adapter能支援多種item型別,僅改動兩行程式碼即可新增一個新的item
  5. adapter能對自身的item進行自動複用,無需手動判斷

效能:

  1. adapter對findviewById()應有自動的優化策略,類似於ViewHolder
  2. item自身的setListener應僅設定一次,不在getView時new出多餘的listener
  3. adapter應提供item的區域性重新整理功能
  4. 如果一個item過於複雜,可以將其拆分成多個小的item
  5. 如果item中要載入網路或本地圖片,先線上程中載入,載入好後切回主執行緒顯示
  6. 在快速滑動時不載入網路圖片或停止gif圖和視訊的播放
  7. 如果item中文字過多,可以採用textview的預渲染方案
  8. 如果發現item因為measure任務過重,則要通過自定義view來優化此item
  9. 通過判斷已經顯示的內容和需要顯示的新內容是否不同來決定要不要重新渲染view
  10. 適當的使用RecycledViewPool來快取item物件
  11. 使用recycleView的預取(Prefetch)

擴充套件:

  1. listview的adapter應在修改一兩行程式碼後支援recyclerView
  2. 一個adapter中的不同item可以接收不同的資料物件
  3. adapter應支援資料繫結,資料變了後介面應自動重新整理

設計:

  1. adapter應該有明確的層級定位,資料不應知道adapter和view的存在

其他:

  1. 根據專案的結構封裝一個統一的item的基類,它可以減少大量的基礎程式碼
  2. 多個type的時候item通常都可以再抽出一個父類,佈局也可以用include標籤
  3. 能知道當前RecycleView的滑動距離和滑動方向
  4. adapter能支援新增hearder和footer,對於有/無header時的空態有不同的處理
  5. 允許用viewpager的notifyDataSetChanged()來更新介面

三、實現

本篇會大量利用CommonAdapter這個庫和其餘的工具類進行實現,下文會直接使用CommonAdapter的api。

基礎

item高內聚

item要能獨立的處理自身的邏輯和事件,讓自身成為一個獨立的ui模組。假設你的item就是一個textView:

<?xml version="1.0" encoding="utf-8"?>
<TextView xmlns:android="http://schemas.android.com/apk/res/android"
    android:id="@+id/text"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    />複製程式碼

現在只需要這麼寫:

public class TextItem implements AdapterItem<JsonModel> {

    private TextView text;

    public int getLayoutResId() {
        return R.layout.demo_item_text;
    }

    public void bindViews(View root) {
        text = (TextView) root.findViewById(R.id.textView);
    }

    public void setViews() {}

    public void handleData(JsonModel model, int position) {
        text.setText(model.content);
    }

}複製程式碼

現在,你可以將它放入不同的介面,只需要給他同樣的資料模型即可。在一個item被多個頁面用的情形中還可以做更多的優化,比如設定全域性的快取池等等。
分離後的item可以更加易於維護,並且我們可以針對listview和item二者進行獨立的效能優化,比如做一個通用的list頁面元件,item通過插拔的方式接入,item自身的資料進行diff優化等等。

我強烈建議不要用ItemOnListener做點選的判斷,而是在每個item中做判斷。在item中可以通過root.getContext()來得到當前頁面的activity,這樣就可以處理各種頁面的跳轉了。

    private Activity mActivity;

    @Override
    public void bindViews(View root) {
        mActivity = (Activity) root.getContext();
    }

    public Activity getActivity() {
        return mActivity;
    }複製程式碼

好處:
item自身能知道自己的所有操作,而ListView僅僅做個容器。現在RecyclerView的設計思路也是如此的,讓item獨立性增加。而且如果要帶資料到別的頁面,也可以直接拿到資料。
壞處:
外部對內部完全不知情,對於統一的事件沒辦法做到很好的統一處理。

將adapter變成內部類

為了說明,我建立了一個資料模型:

public class DemoModel {
    public String content;
    public String type;
}複製程式碼

它就是一個POJO,沒有任何特別之處,它完全不知道其他物件的存在。

adapter做的事情是將資料和ui進行繫結,不同頁面的adapter基本是不可複用的狀態,而且現在主要的事情在item中處理了,所以adapter就通常是以一個內部類的形式出現,如:

listView.setAdapter(new CommonAdapter<DemoModel>(data) {
    @Override
    public AdapterItem<DemoModel> createItem(Object itemType) {
        return new Item();
    }
});複製程式碼

支援多種item型別

listView.setAdapter(new CommonAdapter<DemoModel>(data) {
    @Override
    public Object getItemType(DemoModel demoModel) {
        // 返回item的型別,強烈建議是string,int,float之類的基礎型別,也允許class型別
        return demoModel.type;
    }

    @Override
    public AdapterItem<DemoModel> createItem(Object type) {
        switch ((String) type) {
            case "text":
                return new TextItem();
            case "button":
                return new ButtonItem();
            case "image":
                return new ImageItem();
        }
    }
});複製程式碼

現在如果加了新的需求,要多支援一個item型別,你只需要在switch-case語句塊中新增一個case就行,簡單且安全。

自動複用內部的item

我們之前對adapter的優化經常是需要在getView中判斷convertView是否為null,如果不為空就不new出新的view,這樣來實現item複用。先來看看上面已經出現多次的AdapterItem是個什麼。

public interface AdapterItem<T> {

    /**
     * @return item佈局檔案的layoutId
     */
    @LayoutRes
    int getLayoutResId();

    /**
     * 初始化views
     */
    void bindViews(final View root);

    /**
     * 設定view
     */
    void setViews();

    /**
     * 根據資料來設定item的內部views
     *
     * @param model    資料list內部的model
     * @param position 當前adapter呼叫item的位置
     */
    void handleData(T model, int position);

}複製程式碼
方法 描述 做的工作
getLayoutResId 你這個item的佈局檔案是什麼 返回一個R.layout.xxx
bindViews 在這裡做findviewById的工作 btn = findViewById(R.id.xx)
setViews 在這裡初始化view各個引數 setcolor ,setOnClickListener...
handleData 資料更新時會呼叫(類似getView) button.setText(model.text)

其實這裡就是view的幾個過程,首先初始化佈局檔案,然後繫結佈局檔案中的各個view,接著進行各個view的初始化操作,最後在資料更新時進行更新的工作。

分析完畢後,我去原始碼裡面翻了一下,發現了這個庫對item複用的優化:

LayoutInflater mInflater;

@Override
public View getView(int position, View convertView, ViewGroup parent) {
    // 不重複建立inflater物件,無論你有多少item,我都僅僅建立一次
    if (mInflater == null) {
        mInflater = LayoutInflater.from(parent.getContext());
    }

    AdapterItem<T> item;
    if (convertView == null) {
        // 當convertView為null,說明沒有複用的item,那麼就new出來
        item = getItemView(mType);
        convertView = mInflater.inflate(item.getLayoutResId(), parent, false);
        convertView.setTag(R.id.tag_item, item);
        // 呼叫bindView進行view的findview,僅僅是新new出來的view才會呼叫一次
        item.onBindViews(convertView); 
        // findview後開始setView。將繫結和設定分離,方便整理程式碼結構
        item.onSetViews(); 
    } else {
        // 如果這個item是可以複用的,那麼直接返回
        item = (AdapterItem<T>) convertView.getTag(R.id.tag_item);
    }
    // 無論你是不是複用的item,都會在getView時觸發updateViews方法,更新資料
    item.onUpdateViews(mDataList.get(position), position);
    return convertView;
}複製程式碼

關鍵程式碼就是這一段,所以只需要明白這一段程式碼做的事情,無論在使用這個庫時遇到了什麼問題,你都可以不必驚慌,因為你掌握了它的原理。

明白了第三方庫的原理,才可以放心大膽的使用

效能

對findviewById方法的優化

通過上述對原始碼的分析,現在只需要在bindViews中寫findview的程式碼即可讓這個庫自動實現優化。如果你用了databinding,一行程式碼解決問題:

private DemoItemImageBinding b;

@Override
public void bindViews(View root) {
    b = DataBindingUtil.bind(root);
}複製程式碼

傳統做法:

TextView textView;

@Override
public void bindViews(View root) {
    textView = (TextView) root.findViewById(R.id.textView);
}複製程式碼

item自身的setListener應僅設定一次

我們之前會圖省事在listview的getView中隨便寫監聽器,以至於出現了new很多多餘listener的現象。

public View getView(int positon, View convertView, ViewGroup parent){
    if(null == convertView){
        convertView = LayoutInflater.from(context).inflate(R.layout.item, null);
    }

    Button button = ABViewUtil.obtainView(convertView, R.id.item_btn);
    button.setOnClickListener(new View.OnClickListener(){ // 每次getView都會new一個listener
        @Override
        public void onClick(View v){
            Toast.makeText(context, "position: " + position, Toast.LENGTH_SHORT).show();
        }
    });

}複製程式碼

現在,我們在setViews中寫上監聽器就行。

public class ButtonItem implements AdapterItem<DemoModel> {

    /**
     * 因為這個方法僅僅在item建立時才呼叫,所以不會重複建立監聽器。
     */
    @Override
    public void setViews() {
        btn.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                // ...
            }
        });
    }

    @Override
    public void handleData(DemoModel model, int position) {
        // 這裡避免做耗時的操作
    }

}複製程式碼

這樣setViews()保證了item只會new一次監聽器,在handleData()中如果要載入圖片,請線上程中載入,載入好了後切回主執行緒顯示(一般圖片庫都做了這樣的處理)。

如果我們要在每次點選的時候得到當前item中的data和positon就比較麻煩了,所以只能寫幾個getXxxx()。

    private T data;

    private int pos;

    @Override
    public void handleData(T t, int position) {
        data = t;
        pos = position;   
    }

    public T getData() {
        return data;
    }

    public int getPos() {
        return pos;
    }複製程式碼

建議:這塊的程式碼建議抽到baseItem中去寫。

提供區域性重新整理功能

這個功能在recyclerView中就已經提供了,我就不廢話了。網上流傳比較多的是用下面的程式碼做listview的單條重新整理:

private void updateSingleRow(ListView listView, long id) {  
        if (listView != null) {  
            int start = listView.getFirstVisiblePosition();  
            for (int i = start, j = listView.getLastVisiblePosition(); i <= j; i++)  
                if (id == ((Messages) listView.getItemAtPosition(i)).getId()) {  
                    View view = listView.getChildAt(i - start);  
                    getView(i, view, listView);  
                    break;  
                }  
        }  
    }複製程式碼

其實就是手動呼叫了對應position的item的getView方法,個人覺得不是很好,現在直接使用recyclerView的notifyItemChanged(index)就行。

    /**
     * Notify any registered observers that the item at <code>position</code> has changed.
     * Equivalent to calling <code>notifyItemChanged(position, null);</code>.
     *
     * <p>This is an item change event, not a structural change event. It indicates that any
     * reflection of the data at <code>position</code> is out of date and should be updated.
     * The item at <code>position</code> retains the same identity.</p>
     *
     * @param position Position of the item that has changed
     *
     * @see #notifyItemRangeChanged(int, int)
     */
    public final void notifyItemChanged(int position) {
        mObservable.notifyItemRangeChanged(position, 1);
    }複製程式碼

上面提到的是對區域性的某個item進行重新整理,但是如果我們需要對某個item中的某個view進行重新整理呢?

    /**
     * Notify any registered observers that the item at <code>position</code> has changed with an
     * optional payload object.
     *
     * <p>This is an item change event, not a structural change event. It indicates that any
     * reflection of the data at <code>position</code> is out of date and should be updated.
     * The item at <code>position</code> retains the same identity.
     * </p>
     *
     * <p>
     * Client can optionally pass a payload for partial change. These payloads will be merged
     * and may be passed to adapter's {@link #onBindViewHolder(ViewHolder, int, List)} if the
     * item is already represented by a ViewHolder and it will be rebound to the same
     * ViewHolder. A notifyItemRangeChanged() with null payload will clear all existing
     * payloads on that item and prevent future payload until
     * {@link #onBindViewHolder(ViewHolder, int, List)} is called. Adapter should not assume
     * that the payload will always be passed to onBindViewHolder(), e.g. when the view is not
     * attached, the payload will be simply dropped.
     *
     * @param position Position of the item that has changed
     * @param payload Optional parameter, use null to identify a "full" update
     *
     * @see #notifyItemRangeChanged(int, int)
     */
    public final void notifyItemChanged(int position, Object payload) {
        mObservable.notifyItemRangeChanged(position, 1, payload);
    }複製程式碼

notifyItemChanged(index, obj)這個方法的第一個引數用來確定重新整理的item位置,第二個引數通常用來傳遞一個標誌,來告訴item需要重新整理的東西。

《RecyclerView animations done right》一文中通過點贊動畫舉出了一個很不錯的例子。

Adapter最佳實踐

整個的item是巨大且複雜的,但我們點贊後只需要對一個view進行動畫的操作,處理方式就需要重新考慮了。

  • item自己處理點選事件,被點選後findview找到那個view,然後進行動畫的操作
  • 通過外部adapter的notifyItemChanged(index, obj)來通知item當前是否要做動畫

通常情況下我們都會選擇方案一,但是如果要用第二種方式呢?

  1. 外部進行notify
    notifyItemChanged(adapterPosition, ACTION_LIKE_BUTTON_CLICKED);複製程式碼
  2. 判斷payload
    @Override
    public void onBindViewHolder(ViewHolder holder, int position, List<Object> payloads) {
     if (payloads.isEmpty()) {
         // payloads為空,說明是更新整個viewHolder
         onBindViewHolder(holder, position);
     } else {
         // payloads 不為空,這隻更新需要更新的view即可
         if(payloas.get(0).equals(ACTION_LIKE_BUTTON_CLICKED)) {
             // ...
         }
     }
    }複製程式碼

這裡的關鍵點在於payloads這個引數,往大里說你可以通知某個item產生了某個事件,至於接收到事件後做什麼就看你了。
這個的關鍵思路是外部不應該知道內部的資料,而是產生一個事件,比如“點讚了”,而item內部是根據這個事件來進行自己的操作的,是物件導向的思路。

如果一個item過於複雜,可以將其拆分成多個小的item

關於這點是facebook提出的android優化技巧,後來我瞭解到ios本身也可以這麼做。

Adapter最佳實踐

Adapter最佳實踐

如圖所示,這個item很複雜,而且很大。當你的item佔據三分之二螢幕的時候就可以考慮這樣的優化方案了。右圖說明了將一個整體的item變成多個小item的效果。在這種拆分後,你會發現原來拆分後的小的item可能在別的介面或別的type中也用到了,這就出現了item模組化的思想,總之是一個挺有意思的優化思路。

詳細的文章(中文)請參考《facebook新聞頁ListView的優化方案》,十分感謝作者的分享和翻譯!

坑!!!

如果你是做論壇的專案,會有各種樓層或者回復巢狀的情況,你可以考慮用這種方式,但肯定會遇到很多坑。下面是《Android ListView中複雜資料流的高效渲染》中提到的一些坑。

  • item的拆分和拼湊是需要自己進行實現的,具體的type肯定和json中的type不同,需要做邏輯遮蔽。很可能會加大同事之間的閱讀程式碼的難度。
  • 由於優化的需求,把邏輯上的一個Item拆分為了多個item,因此每個item上都要設定ItemClick事件。具體實現時可以寫一個基類,在基類中對item click進行處理。
  • 在item 點選時,一般需要有按壓效果,此時邏輯上的item已經進行了拆分,需要策略實現邏輯上item的整體按壓,而不是隻有某個拆分後的item被按壓。
  • 我們知道listview的item之間是有divider的,此時需要設定divider為null,我們通過新增item的方式來實現divider效果。

在快速滑動時不載入網路圖片或停止gif圖的播放

這個在QQ空間和微信朋友圈詳情頁中很常見,目前的小視訊列表也是大圖加文字的形式。滾動時自動停止的功能我希望交給圖片框架做,而不是手動處理,如果你要手動處理,那麼你還得考慮不同頁面的不同情況,感覺價效比太低。
如果你的圖片庫沒有做這樣的處理,可以參考Android-Universal-Image-Loader中的實現方法。

@Override
    public void onScrollStateChanged(AbsListView view, int scrollState) {
        switch (scrollState) {
            case OnScrollListener.SCROLL_STATE_IDLE:
                imageLoader.resume();
                break;
            case OnScrollListener.SCROLL_STATE_TOUCH_SCROLL:
                if (pauseOnScroll) {
                    imageLoader.pause();
                }
                break;
            case OnScrollListener.SCROLL_STATE_FLING:
                if (pauseOnFling) {
                    imageLoader.pause();
                }
                break;
        }
        if (externalListener != null) {
            externalListener.onScrollStateChanged(view, scrollState);
        }
    }複製程式碼

採用textview的預渲染方案

如果你是做bbs或者做新聞的,你會發現item中會有大量的文字,而文字過多或者有著大量的表情和特殊符號的時候,列表肯定會卡頓。textview其實是一個很基本但不簡單的view,裡面做了大量的判斷和處理,所以並非十分高效。

Instagram(現已在facebook旗下)分享了他們是如何優化他們的TextView渲染的效率的,在國內有作者也專門寫了一篇文章來說明其原理的。

Adapter最佳實踐

當你有心想要優化textview的時候,你會發現在我們知道這個item中textview的寬度和文字大小的情況下可以把初始化的配置做個快取,每個textview只需要用這個配置好的東西進行文字的渲染即可。下面是通過優化得到的結果:

Adapter最佳實踐

這裡測試的機器是MX3,左側是直接使用StaticLayout的方案,右側是系統的預設方案,Y軸是FPS,可以看出來,使用優化之後的方案,幀率提升了許多。
我只推薦在measure成為瓶頸的時候才去使用這樣的優化策略,不要過度優化

原理

textview支援上下左右的drawable,而且支援超鏈和emoji表情,每次繪製的時候都會進行檢查,效率自然不會十分出眾。在Android中,文字的渲染是很慢的。即使在一個像Nexus 5這樣的新裝置上,一段有十幾行復雜文字的圖片說明的初始繪製時間可能會達到50ms,而其文字的measure階段就需要30ms。這些都發生在UI執行緒,在滾動時會導致app跳幀。

textview的繪製本質是layout的繪製,setText()被呼叫後,就會選擇合適的layout進行繪製工作。textview的onDraw()中可以看到如下方法:

void onDraw() {
        // ...
        if (mLayout == null) {
            assumeLayout();
        }
        Layout layout = mLayout;

        // ....

}複製程式碼
 /**
     * Make a new Layout based on the already-measured size of the view,
     * on the assumption that it was measured correctly at some point.
     */
    private void assumeLayout() {
        int width = mRight - mLeft - getCompoundPaddingLeft() - getCompoundPaddingRight();
        if (width < 1) {
            width = 0;
        }
        int physicalWidth = width;
        if (mHorizontallyScrolling) {
            width = VERY_WIDE;
        }
        makeNewLayout(width, physicalWidth, UNKNOWN_BORING, UNKNOWN_BORING,
                      physicalWidth, false);
    }複製程式碼

makeNewLayout(...)是個很長的方法,就補貼出來了。總之我們可以通過自己定義一個layoutView來進行文字的繪製,再配合Android在ICS中引入了TextLayoutCache實現text的預渲染。令人欣喜的是,目前facebook開源了一個相當不錯的layout的build,有了它就可以幫助我們快速建立一個高效能的textview了,感興趣的同學可以用起來了。

Adapter最佳實踐

擴充套件閱讀:

《Instagram是如何提升TextView渲染效能的》

《TextView預渲染研究》

6. 通過自定義viewGroup來減少重複的measure

Adapter最佳實踐

fb的的人發現目前專案中有很多穩定的item的繪製效率不高,所以就開始研究measure的耗時。

用linearlayout的時候:

> LinearLayout [horizontal]       [w: 1080  exactly,       h: 1557  exactly    ]
    > ProfilePhoto                [w: 120   exactly,       h: 120   exactly    ]
    > LinearLayout [vertical]     [w: 0     unspecified,   h: 0     unspecified]
        > Title                   [w: 0     unspecified,   h: 0     unspecified]
        > Subtitle                [w: 0     unspecified,   h: 0     unspecified]
        > Title                   [w: 222   exactly,       h: 57    exactly    ]
        > Subtitle                [w: 222   exactly,       h: 57    exactly    ]
    > Menu                        [w: 60    exactly,       h: 60    exactly    ]
    > LinearLayout [vertical]     [w: 900   exactly,       h: 1557  at_most    ]
        > Title                   [w: 900   exactly,       h: 1557  at_most    ]
        > Subtitle                [w: 900   exactly,       h: 1500  at_most    ]複製程式碼

用RelativeLayout的時候:

> RelativeLayout                  [w: 1080  exactly,   h: 1557  exactly]
    > Menu                        [w: 60    exactly,   h: 1557  at_most]
    > ProfilePhoto                [w: 120   exactly,   h: 1557  at_most]
    > Title                       [w: 900   exactly,   h: 1557  at_most]
    > Subtitle                    [w: 900   exactly,   h: 1557  at_most]
    > Title                       [w: 900   exactly,   h: 1557  at_most]
    > Subtitle                    [w: 900   exactly,   h: 1500  at_most]
    > Menu                        [w: 60    exactly,   h: 60    exactly]
    > ProfilePhoto                [w: 120   exactly,   h: 120   exactly]複製程式碼

我們都發現了對於menu,title,subtitle的重複測量。fb的工程師最終用自定義的viewgroup手動控制了佈局和測量引數,最終實現了每個view僅僅測量一次的優秀結果。優化過後,facebook的工程師講解了他們對上面這個佈局的優化策略,內容翔實,是個很好的分享。

擴充套件閱讀:

原文:《Custom ViewGroups》
中文:《聽FackBook工程師講Custom ViewGroups》

使用RecycledViewPool來快取item

Recycled view pools allow multiple RecyclerViews to share a common pool of scrap views. This can be useful if you have multiple RecyclerViews with adapters that use the same view types, for example if you have several data sets with the same kinds of item views displayed by a ViewPager.
RecyclerView automatically creates a pool for itself if you don’t provide one.

正如上文所說RecycledViewPool的主要作用是多個頁面的item共享,比如是可以滑動的tab頁面,每個頁面的vh都是一樣的,在這種情況下用它就很合適了。

Adapter最佳實踐

Adapter最佳實踐

鬥魚的個tab的頁面裡面的item都是完全一樣的,對於首頁這個多fragment的結構來說,採用viewpool會大大提效能。

Tips:

  • 因為commonAdapter幫助你將各種型別的type都轉換為int知了,所以需要採用自定義的RecyclePool來做這樣的操作。
RecycledViewPool pool = new RecycledViewPool();

// ...

recyclerView.setRecycledViewPool(pool);
adapter.setTypePool(pool.getTypePool());複製程式碼
  • RecycledViewPool是依據ItemViewType來索引ViewHolder的,所以不同頁面的相同的item的type必須是一樣的值才能被準確的複用。

  • RecycledViewPool也可以通過mPool.setMaxRecycledViews(itemViewType, number)來設定快取數目。

  • RecyclerView可以通過recyclerView.setItemViewCacheSize(number)設定自己所需要的ViewHolder數量,只有超過這個數量的detached ViewHolder才會丟進ViewPool中與別的RecyclerView共享。也就說每個頁面可以設定自己不想和別的頁面共用的viewholder數目。

  • 在合適的時機,RecycledViewPool會自我清除掉所持有的ViewHolder物件引用,當然你也可以在你認為合適的時機手動呼叫clear()。

判斷已有的資料和新資料的異同

如果是載入圖片,我還是希望你去看看你用的圖片框架有沒有做這樣的優化,如果有就請放心,如果沒有那就自己處理吧。如果你的item中文字很多,經常有幾百個文字。那麼也可以先判斷要顯示的文字和textview中已經有的文字是否一致,如果不一致再呼叫setText方法。

@Override
public void handleData(DemoModel model, int position) {
    if (b.imageView.getTag() != null) {
        mOldImageUrl = (int) b.imageView.getTag();
    }
    int imageUrl = Integer.parseInt(model.content);

    if (mOldImageUrl == 0 && mOldImageUrl != imageUrl) {
        b.imageView.setTag(imageUrl); // set tag
        b.imageView.setImageResource(imageUrl); // load local image
    }
}複製程式碼

使用Prefetch特性

在滾動和滑動的時候,RecyclerView需要顯示進入螢幕的新item,這些item需要被繫結資料(如果快取中沒有類似的item很可能還需要建立),然後把它們放入佈局並繪製。當所有這些工作慢吞吞進行的時候,UI執行緒會慢慢停下來等待其完成,然後渲染才能進行,滾動才能繼續。google的工程師看到在需要一個新的item時,我們花了太多時間去準備這個item,但同時UI執行緒卻早早的完成了前一幀的任務,休眠了大量時間,於是修改了建立vh和繪製的工作流程。

Adapter最佳實踐

Adapter最佳實踐

詳細內容請參考:RecyclerView的新機制:預取(Prefetch) - 泡在網上的日子

擴充套件

Listview無痛遷移至recyclerView

如今recyclerView大有接替listview的趨勢,要知道listview的介面卡和recyclerView的介面卡的寫法是不同的。
listview的寫法如下:

listView.setAdapter(new CommonAdapter<DemoModel>(data,1) {

    @Override
    public AdapterItem<DemoModel> getItemView(Object type) {
        return new TextItem();
    }
});複製程式碼

換成recyclerView的介面卡應該需要很多步吧?不,改一行足矣。

recyclerView.setAdapter(new CommonRcvAdapter<DemoModel>(data) {

    public AdapterItem<DemoModel> getItemView(Object type) {
        return new TextItem();
    }
});複製程式碼

這裡換了一個介面卡的類名和容器名,其餘的都沒變。

同一個adapter的不同item可以接收不同的資料物件

我們的adapter是有一個泛型的,item也是有泛型,一般情況下adapter的泛型物件就是item的物件。

return new CommonAdapter<DemoModel>(data, 1) { // DemoModel
    public AdapterItem createItem(Object type) {
        // 如果就一種,那麼直接return一種型別的item即可。
        return new TextItem();
    }
};複製程式碼
public class TextItem implements AdapterItem<DemoModel> { // DemoModel
    // ...
}複製程式碼

但這並非是必須的,所以你可以通過adapter的getConvertedData(...)進行資料的轉換,讓adapter接收的資料和item的資料不同。

/**
 * 做資料的轉換,這裡算是資料的精細拆分
 */
public Object getConvertedData(DemoModel data, Object type) {
    // 這樣可以允許item自身的資料和list資料不同
    return data.content; // model -> string
}複製程式碼

支援資料繫結

CommonAdapter可以結合dataBinding中的ObservableList進行資料的自動繫結操作。原始碼如下:

protected CommonRcvAdapter(@NonNull ObservableList<T> data) {
        this((List<T>) data);
        data.addOnListChangedCallback(new ObservableList.OnListChangedCallback<ObservableList<T>>() {
            @Override
            public void onChanged(ObservableList<T> sender) {
                notifyDataSetChanged();
            }

            @Override
            public void onItemRangeChanged(ObservableList<T> sender, int positionStart, int itemCount) {
                notifyItemRangeChanged(positionStart, itemCount);
            }

            @Override
            public void onItemRangeInserted(ObservableList<T> sender, int positionStart, int itemCount) {
                notifyItemRangeInserted(positionStart, itemCount);
                notifyItemRangeChanged(positionStart, itemCount);
            }

            @Override
            public void onItemRangeRemoved(ObservableList<T> sender, int positionStart, int itemCount) {
                notifyItemRangeRemoved(positionStart, itemCount);
                notifyItemRangeChanged(positionStart, itemCount);
            }

            @Override
            public void onItemRangeMoved(ObservableList<T> sender, int fromPosition, int toPosition, int itemCount) {
                // Note:不支援一次性移動"多個"item的情況!!!!
                notifyItemMoved(fromPosition, toPosition);
                notifyDataSetChanged();
            }
        });
    }複製程式碼

現在只要我們對list物件進行操作,adapter就會自動去更新介面,再也不用去手動notify了。

我們可能還記得support中的一個新的工具類——diffUtil,它可以配合recycleview進行自動的notify操作,如果我們要用它就需要做一些處理了。

public static abstract class DiffRcvAdapter<T> extends CommonRcvAdapter<T> {

    DiffRcvAdapter(@Nullable List<T> data) {
        super(data);
    }

    @Override
    public void setData(@NonNull final List<T> data) {
        DiffUtil.calculateDiff(new DiffUtil.Callback() {
            @Override
            public int getOldListSize() {
                return getItemCount();
            }

            @Override
            public int getNewListSize() {
                return data.size();
            }

            /**
             * 檢測是否是相同的item,這裡暫時通過位置判斷
             */
            @Override
            public boolean areItemsTheSame(int oldItemPosition, int newItemPosition) {
                boolean result = oldItemPosition == newItemPosition;
                Log.d(TAG, "areItemsTheSame: " + result);
                return result;
            }

            /**
             * 檢測是否是相同的資料
             * 這個方法僅僅在areItemsTheSame()返回true時,才呼叫。
             */
            @Override
            public boolean areContentsTheSame(int oldItemPosition, int newItemPosition) {
                boolean result = isContentSame(getData().get(oldItemPosition), data.get(newItemPosition));
                Log.d(TAG, "areContentsTheSame: " + result);
                return result;
            }
        }).dispatchUpdatesTo(this); // 傳遞給adapter
        super.setData(data);

    }

    protected abstract boolean isContentSame(T oldItemData, T newItemData);
}複製程式碼
final DiffRcvAdapter<DemoModel> adapter = new DiffRcvAdapter<DemoModel>(DataManager.loadData(this, 3)) {
    @NonNull
    @Override
    public AdapterItem createItem(Object type) {
        return new TextItem();
    }

    @Override
    protected boolean isContentSame(DemoModel oldItemData, DemoModel newItemData) {
        return oldItemData.content.equals(newItemData.content);
    }
};複製程式碼

這裡需要多做的是手動判斷item的資料是否要更新,所以不如用ObservableArrayList比較簡單,而且是直接更新,不佔cpu。
需要注意的是,如果用diffutil,你的item必須是viewholder,因為它最終呼叫的是adapter.notifyItemRangeChanged(position, count, payload),所以就會呼叫adapter中的onBindViewHolder(VH holder, int position, List<Object> payloads)

[diffUtil]

public void dispatchUpdatesTo(final RecyclerView.Adapter adapter) {
    dispatchUpdatesTo(new ListUpdateCallback() {
        @Override
        public void onInserted(int position, int count) {
            adapter.notifyItemRangeInserted(position, count);
        }

        @Override
        public void onRemoved(int position, int count) {
            adapter.notifyItemRangeRemoved(position, count);
        }

        @Override
        public void onMoved(int fromPosition, int toPosition) {
            adapter.notifyItemMoved(fromPosition, toPosition);
        }

        @Override
        public void onChanged(int position, int count, Object payload) {
            adapter.notifyItemRangeChanged(position, count, payload);
        }
    });
}複製程式碼

設計

Adapter不屬於UI層

當我們讓adapter變成一個內部類的時候,剩下的問題就是adapter應該處於view層還是presenter或model層了。在實際的運用當中,我最終定義adapter是處於presenter層(mvp)或者model層(mvvm)。

是否放在p或vm層有一個簡單的原則就是不可複用,p或vm的程式碼複用性是極其低的,所以當你認為有程式碼是不可複用的時候,那麼你就可以放在裡面。況且ui層面有可能會出現複用的情況,而且adapter中還會出現和資料相關的一些操作,所以應該讓其與ui層隔離。

當你和ui隔離了,你完全可以實現一個list頁面統一的ui,進行空狀態等細節的處理,方便複用統一的ui,十分有用。

其他

封裝baseItem

item的介面化提供了更大的靈活性,但是就實際專案而言,我強烈推薦去做一個baseItem,這樣可以快速得到activity,position,context等等物件。

public abstract class BaseAdapterItem<Bind extends ViewDataBinding, Model> implements AdapterItem<Model> {

    private View root;

    private int pos;

    protected Bind b;

    private Activity activity;

    public BaseAdapterItem(Activity activity) {
        this.activity = activity;
    }

    public BaseAdapterItem() {
    }

    @CallSuper
    @Override
    public void bindViews(View view) {
        root = view;
        b = DBinding.bind(view);
        beforeSetViews();
    }

    protected void beforeSetViews() {

    }

    @CallSuper
    @Override
    public void handleData(Model t, int position) {
        pos = position;
    }

    public View getRoot() {
        return root;
    }

    public int getCurrentPosition() {
        return pos;
    }

    protected static void setVizOrInViz(View view, CharSequence str) {
        if (TextUtils.isEmpty(str)) {
            view.setVisibility(View.INVISIBLE);
        } else {
            view.setVisibility(View.VISIBLE);
        }
    }

    protected static void setVizOrGone(View view, CharSequence str) {
        if (TextUtils.isEmpty(str)) {
            view.setVisibility(View.GONE);
        } else {
            view.setVisibility(View.VISIBLE);
        }
    }

    protected int getColor(@ColorRes int colorResId) {
        return root.getResources().getColor(colorResId);
    }

    protected Context getContext() {
        return root.getContext();
    }

}複製程式碼

我通過上面的base和databinding結合後,快速的實現了findview的操作,十分簡潔。

多type的時候抽取父類

如果list頁面中有多個type,你肯定會發現不同type的item的有相同的邏輯,最常見的是點選跳轉的邏輯。對於這樣的情況我建議再抽取一個base來做,以後修改的時候你會發現十分方便。對於ui層面的相似,我也希望可以適當的使用include標籤進行復用。
我之前偷懶經常不抽取公共部分,因為覺得做基類複雜,公共部分的程式碼也不多,但是後面維護的時候到處都要改,所以就給出了這條實踐經驗。

監聽滑動的距離和方向

OnRcvScrollListener是我常用的一個監聽類,可以監聽滾動方向、滾動距離、是否混動到底。

/**
 * @author Jack Tony
 *         recyle view 滾動監聽器
 * @date 2015/4/6
 */
public class OnRcvScrollListener extends RecyclerView.OnScrollListener {

    private static final int TYPE_LINEAR = 0;

    private static final int TYPE_GRID = 1;

    private static final int TYPE_STAGGERED_GRID = 2;

    /**
     * 最後一個的位置
     */
    private int[] mLastPositions;

    /**
     * 最後一個可見的item的位置
     */
    private int mLastVisibleItemPosition;

    /**
     * 觸發在上下滑動監聽器的容差距離
     */
    private static final int HIDE_THRESHOLD = 20;

    /**
     * 滑動的距離
     */
    private int mDistance = 0;

    /**
     * 是否需要監聽控制
     */
    private boolean mIsScrollDown = true;

    /**
     * Y軸移動的實際距離(最頂部為0)
     */
    private int mScrolledYDistance = 0;

    /**
     * X軸移動的實際距離(最左側為0)
     */
    private int mScrolledXDistance = 0;

    private int mOffset = 0;

    /**
     * @param offset 設定:倒數幾個才判定為到底,預設是0
     */
    public OnRcvScrollListener(int offset) {
        mOffset = offset;
    }

    @Override
    public void onScrolled(RecyclerView recyclerView, int dx, int dy) {
        super.onScrolled(recyclerView, dx, dy);
        int firstVisibleItemPosition = 0;
        RecyclerView.LayoutManager layoutManager = recyclerView.getLayoutManager();
        // 判斷layout manager的型別
        int type = judgeLayoutManager(layoutManager);
        // 根據型別來計算出第一個可見的item的位置,由此判斷是否觸發到底部的監聽器
        firstVisibleItemPosition = calculateFirstVisibleItemPos(type, layoutManager, firstVisibleItemPosition);
        // 計算並判斷當前是向上滑動還是向下滑動
        calculateScrollUpOrDown(firstVisibleItemPosition, dy);
        // 移動距離超過一定的範圍,我們監聽就沒有啥實際的意義了
        mScrolledXDistance += dx;
        mScrolledYDistance += dy;
        mScrolledXDistance = (mScrolledXDistance < 0) ? 0 : mScrolledXDistance;
        mScrolledYDistance = (mScrolledYDistance < 0) ? 0 : mScrolledYDistance;
        onScrolled(mScrolledXDistance, mScrolledYDistance);
    }


    /**
     * 判斷layoutManager的型別
     */
    private int judgeLayoutManager(RecyclerView.LayoutManager layoutManager) {
        if (layoutManager instanceof GridLayoutManager) {
            return TYPE_GRID;
        } else if (layoutManager instanceof LinearLayoutManager) {
            return TYPE_LINEAR;
        } else if (layoutManager instanceof StaggeredGridLayoutManager) {
            return TYPE_STAGGERED_GRID;
        } else {
            throw new RuntimeException("Unsupported LayoutManager used. Valid ones are "
                    + "LinearLayoutManager, GridLayoutManager and StaggeredGridLayoutManager");
        }
    }

    /**
     * 計算第一個元素的位置
     */
    private int calculateFirstVisibleItemPos(int type, RecyclerView.LayoutManager layoutManager, int firstVisibleItemPosition) {
        switch (type) {
            case TYPE_LINEAR:
                mLastVisibleItemPosition = ((LinearLayoutManager) layoutManager).findLastVisibleItemPosition();
                firstVisibleItemPosition = ((LinearLayoutManager) layoutManager).findFirstVisibleItemPosition();
                break;
            case TYPE_GRID:
                mLastVisibleItemPosition = ((GridLayoutManager) layoutManager).findLastVisibleItemPosition();
                firstVisibleItemPosition = ((GridLayoutManager) layoutManager).findFirstVisibleItemPosition();
                break;
            case TYPE_STAGGERED_GRID:
                StaggeredGridLayoutManager staggeredGridLayoutManager = (StaggeredGridLayoutManager) layoutManager;
                if (mLastPositions == null) {
                    mLastPositions = new int[staggeredGridLayoutManager.getSpanCount()];
                }
                mLastPositions = staggeredGridLayoutManager.findLastVisibleItemPositions(mLastPositions);
                mLastVisibleItemPosition = findMax(mLastPositions);
                staggeredGridLayoutManager.findFirstCompletelyVisibleItemPositions(mLastPositions);
                firstVisibleItemPosition = findMax(mLastPositions);
                break;
        }
        return firstVisibleItemPosition;
    }

    /**
     * 計算當前是向上滑動還是向下滑動
     */
    private void calculateScrollUpOrDown(int firstVisibleItemPosition, int dy) {
        if (firstVisibleItemPosition == 0) {
            if (!mIsScrollDown) {
                onScrollDown();
                mIsScrollDown = true;
            }
        } else {
            if (mDistance > HIDE_THRESHOLD && mIsScrollDown) {
                onScrollUp();
                mIsScrollDown = false;
                mDistance = 0;
            } else if (mDistance < -HIDE_THRESHOLD && !mIsScrollDown) {
                onScrollDown();
                mIsScrollDown = true;
                mDistance = 0;
            }
        }
        if ((mIsScrollDown && dy > 0) || (!mIsScrollDown && dy < 0)) {
            mDistance += dy;
        }
    }

    @Override
    public void onScrollStateChanged(RecyclerView recyclerView, int newState) {
        super.onScrollStateChanged(recyclerView, newState);
        RecyclerView.LayoutManager layoutManager = recyclerView.getLayoutManager();
        int visibleItemCount = layoutManager.getChildCount();
        int totalItemCount = layoutManager.getItemCount();

        int bottomCount = totalItemCount - 1 - mOffset;
        if (bottomCount < 0) {
            bottomCount = totalItemCount - 1;
        }

        if (visibleItemCount > 0 && newState == RecyclerView.SCROLL_STATE_IDLE
                && mLastVisibleItemPosition >= bottomCount && !mIsScrollDown) {
            onBottom();
        }
    }

    protected void onScrollUp() {

    }

    protected void onScrollDown() {

    }

    protected void onBottom() {
    }

    protected void onScrolled(int distanceX, int distanceY) {
    }

    private int findMax(int[] lastPositions) {
        int max = lastPositions[0];
        for (int value : lastPositions) {
            max = Math.max(max, value);
        }
        return max;
    }
}複製程式碼

支援新增頭/底和空狀態

CommonAdapter中提供了RcvAdapterWrapper來做頭部、底部、空狀態的處理,方法也就是setXxx()。值得一提的是,當有頭部的時候,空狀態的view會自動佔用螢幕-頭部的空間,不會阻礙到頭部的顯示。

四、尾聲

用不用一個第三方庫我有下面的幾點建議:

  1. 如果你不瞭解其內部的實現,那麼儘可能少用,因為出了問題無從查詢。
  2. 如果你遇到一個很好的庫,不妨看下內部的實現,既能學到東西,又可以在以後出問題的時候快速定位問題。
  3. 如果遇到複雜的庫,比如網路和圖片庫。全部知道其原理是很難的,也需要成本,而你自己寫也是不現實的,所以需要挑選很有名氣的庫來用。這樣即使遇到了問題,也會有很多資料可以搜到。
  4. 不要牴觸國人的庫,國人的庫更加接地氣,說不定還更好,還可以更加方便的提出issue。

探索無止境,優化沒底線,我還是希望能有庫在庫中做好很多的優化操作,降低對程式設計師的要求,最終希望誰都可以寫程式碼。簡單程式設計,快樂生活。本文的完成離不開朋友們的支援和幫助,感謝:MingleArch、豪哥的批評和建議。

Adapter最佳實踐
developer-kale@foxmail.com

Adapter最佳實踐
微博:@天之界線2010

參考文章: