百行以內——超輕量級的多型別列表檢視框架

林鹿發表於2019-07-06

名字有點唬人,其實就是組合了幾個封裝類能夠方便實現RecyclerView的多檢視,畢竟“框架”這個詞在我看來還是指具有一定規模量級及重點技術的程式碼體系,但僅就解決特定問題而言也不妨被冠以這個名號。同時它真的是“超輕量”總共不過4個類,不超過130行程式碼~

檢視抽象

我們已經有了一個無需型別強轉的通用ViewHolder(ItemViewHolder),一個ViewHolder物件可以找到所有檢視例項。而且它是完全獨立的, 沒有引入任何自定義類或者任何第三方依賴;即使沒有這個“框架”,也完全可以拆出來用在其他地方。

控制元件介面卡

介面卡(Adapter)是與控制元件關聯的, 是控制元件對其子檢視列表的一種抽象。抽象了什麼?由具體定義決定。比如列表控制元件的介面卡(無論是以前的ListView, 現在RecyclerView, 以及其它的如ViewPager)一般抽象了三個屬性:

  1. 數量 getItemCount()
  2. 操作 onBindView(ViewHolder holder, int position),onCreateView
  3. 型別 getViewType(int position)

控制元件適配是SDK關聯的,框架的ItemAdapter也是基於RecyclerView.Adapter

元素抽象

介面卡(Adapter) 是容器控制元件對子控制元件的整體抽象,相應位置的元素沒有作出任何限制,position對應的元素可以是介面返回的一個具體資料,也可以是從本地獲取的應用資料。框架要做的一個工作就是對元素資料型別進行抽象, 但是資料型別千差萬別,無法對資料元素本身的屬性做統一操作,結果就是變成像MultiType庫那樣,用範型抽象所有的資料元素,然後通過註冊資料型別(.class)到資料繫結器型別(ItemViewBinder.class)的對映,反射得到繫結器例項,其中有大量的物件型別強轉。

框架不對資料元素做抽象,而是針對操作作抽象,即adapter對每個position元素的操作作抽象;用一個簡單的List資料結構持有抽象例項;因為同樣有繫結操作,所以姑且也叫做繫結器ItemBinder。ViewHolder就用我們之前的通用ViewHolder(ItemViewHolder),結合前面說到adapter有三個重要屬性,於是有:

public interface ItemBinder {
    void onBindViewHolder(ItemViewHolder holder, int position);
    int getViewType();
}

public class ItemAdapter extends RecyclerView.Adapter<ItemViewHolder> {
    private final List<ItemBinder> mBinders = new ArrayList<>(10);

    @Override
    public void onBindViewHolder(@NonNull ItemViewHolder holder, int position) {
        ItemBinder binder = mBinders.get(position);
        binder.onBindViewHolder(holder, position);
    }

    @Override
    public int getItemCount() {
        return mBinders.size();
    }

    @Override
    public int getItemViewType(int position) {
        return mBinders.get(position).getViewType();
    }

    public void setBinders(List<ItemBinder> binders) {
        mBinders.clear();
        mBinders.addAll(binders);
    }
}
複製程式碼

對於Adapter而言,元素僅僅是ItemBinder,它不關心ItemBinder是用哪種資料型別,又是怎樣把資料填充到ViewHolder中。

檢視型別

RecyclerView通過RecyclerView.AdaptergetItemViewType介面返回的數值來標識一個檢視型別。與ListView不同的是這個viewType可以不是連續的RecyclerView可以自己感知設定了多少種viewType(內部其實就是用了SparseArray)。通過viewType的標識, RecyclerView.AdapteronCreateViewHolder來建立相應的檢視型別。通常我們不得不自己建立viewTypeRecyclerView.ViewHolder的對映關係,除了稍有點煩瑣之外並沒有多大的問題。

注意:我們走到了到框架的一個關鍵點,就是建立viewType和檢視例項建立之間的關係。

已經找不到是在哪個庫裡,當看到把檢視資源id(layoutId)直接作為viewType返回的時候,被這種天才想法折服了。首先就是用資源id本身就可以建立檢視;其次是充分利用了viewType可以不連續的性質;再次是不同的資源id天然的對應不同的檢視型別,也就是說,本身就是多檢視型別的;最後的最後就是這種實現提供了巨大的靈活性,包括程式碼複用和資源的複用,這點後面專門說一下。於是有:

public interface ItemBinder {
    void onBindViewHolder(ItemViewHolder holder, int position);

    @LayoutRes
    int getLayoutId();
}

public class ItemAdapter extends RecyclerView.Adapter<ItemViewHolder> {
    private final List<ItemBinder> mBinders = new ArrayList<>(10);

    @NonNull
    @Override
    public ItemViewHolder onCreateViewHolder(@NonNull ViewGroup container, int viewType) {
        return new ItemViewHolder(LayoutInflater.from(container.getContext()).inflate(
                viewType, container, false));
    }

    @Override
    public void onBindViewHolder(@NonNull ItemViewHolder holder, int position) {
        ItemBinder binder = mBinders.get(position);
        binder.onBindViewHolder(holder, position);
    }

    @Override
    public int getItemCount() {
        return mBinders.size();
    }

    @Override
    public int getItemViewType(int position) {
        return mBinders.get(position).getLayoutId();
    }

    public void setBinders(List<ItemBinder> binders) {
        mBinders.clear();
        mBinders.addAll(binders);
    }
}
複製程式碼

我們之前被getItemViewType的預設值0給誤導了,思維慣性讓我們認為viewType可以和ViewHolder是割裂的,但其實它們可以是統一的!

剩下的工作簡單明瞭,實現具體的ItemBinder型別,將具體的資料填充到檢視,比如:

public HomeBannerBinder implements ItemBinder {
    private final HomeBanner mItem;
    HomeBannerBinder(HomeBanner banner) {
        mItem = banner;
    }

    void onBinderViewHolder(ItemViewHolder holder, int position) {
        ImageView bg = holder.findViewById(R.id.background);
        if (bg != null) {
            ImageManager.load(bg, mItem.bg_url);
        }
    }
}
複製程式碼

靈活複用

這裡的複用不是recyclerView對檢視記憶體物件的複用,而是程式碼層面的複用,包括宣告資源的xml程式碼。

把layoutId作為viewType到底帶來怎樣的靈活複用呢?

可以先舉例常見的微信朋友圈列表:顯然,很多朋友圈內容都是不同的,有視訊有圖片有文字,或者它們的結合,處理2張圖片的佈局和處理9張圖片的佈局顯示也是不同的;但是每一條朋友圈佈局有很多相同的地方:都有頂部的使用者頭像與使用者名稱稱,都有底部點贊和評論佈局。那麼問題來了:怎樣宣告不同的檢視型別,但不必重複書寫這些一樣的地方?

這當然不是難事,比如一個視訊朋友圈佈局可寫成這樣circle_item_video.xml

<RelativeLayout>
     <include layout="@layout/circle_item_top" />
     <include layout="@layout/circle_item_layer_video" />
     <include layout="@layout/circle_item_bottom" />
</RelativeLayout>
複製程式碼

音訊朋友圈佈局circle_item_audio.xml就把@layout/circle_item_layer_video換成@layout/circle_item_layer_audio,依次類推。

這麼做完全可以實現,隨著型別的增多,佈局檔案相應增加即可;然而一旦發生變更呢?只要涉及相同佈局的部分都必須改一遍!(比如把RelativeLayout變成android.support.constraint.ConstraintLayout)而且實際的情況不一定這麼簡單,可能因為各種原因檢視的層次比較深,並且都沒辦法放在include中,一旦檢視物件變多,檢視層次變深, 這種冗餘就讓人難以忍受了,對一個有追求的碼畜來說,肯定希望只更改一處地方即可。

檢視複用

如果layoutId作為viewType要如何實現剛才的複用呢?顯然他們必須是不同的viewType(如果一樣會發生什麼?),那麼他們當然是不同的layoutId,但不同的layoutId就無法避免上面那樣的問題,這時候就用到android的匿名資源(anonymous),就是對一個資源宣告一個引用,而這個引用本身作為一個資源,即<item name="def" type="drawable">@drawable/abc</item>,結合以上的例子就是 circle_item.xml:

<RelativeLayout>
     <include layout="@layout/circle_item_top" />
     <ViewStub />
     <include layout="@layout/circle_item_bottom" />
</RelativeLayout>
複製程式碼

中間的部分可通過延遲載入的方式設定成不同的View,甚至所有不同的部分都可以以ViewStub的形式嵌在佈局當中。 refs.xml:

<resources>
    <item name="circle_item_video" type="layout">@layout/circle_item</item>
    <item name="circle_item_audio" type="layout">@layout/circle_item</item>
    <item name="circle_item_pic_1" type="layout">@layout/circle_item</item>
    <item name="circle_item_pic_9" type="layout">@layout/circle_item</item>
</resources>
複製程式碼

也就是說都引用同一份的佈局資源!可他們因為不同的layoutId進而可以被recyclerView當作不同的viewType

程式碼複用

按照之前的思路也必然希望只在一處更改點贊和評論功能。所以有一個基類:

public class CircleItemBinder implements ItemBinder {
    @Override
    public getLayoutId() {
        return R.layout.circle_item;
    }

    @Override
    void onBindViewHolder(ItemViewHolder holder, int position) {
        bindComment(holder);
        bindLike(holder);
    }

    private void bindComment(ItemViewHolder holder) {
    }

    private void bindLike(ItemViewHolder holder) {
    }
}
複製程式碼

各型別的binder類似:

public class CircleVideoBinder extends CircleItemBinder {
    private final YourVideoData mItem;

    public CircleVideoBinder(YourVideoData data) {
        mItem = data;
    }

    @Override
    public getLayoutId() {
        return R.layout.circle_item_video;
    }

    @Override
    void onBindViewHolder(ItemViewHolder holder, int position) {
        super.onBindViewHolder(holder, position);
        TextView title = holder.findViewById(R.id.video_title);
        if (title != null) {
            title.setText(mItem.title);
        }
        ...
    }
}

public class CircleAudioBinder extends CircleItemBinder {
    private final YourAudioData mItem;

    public CircleAudioBinder(YourAudioData data) {
        mItem = data;
    }

    @Override
    public getLayoutId() {
        return R.layout.circle_item_audio;
    }

    @Override
    void onBindViewHolder(ItemViewHolder holder, int position) {
        super.onBindViewHolder(holder, position);
        ImageView album = holder.findViewById(R.id.audio_album);
        if (album != null) {
            ImageLoader.load(album, mItem.album_background);
        }
        ...
    }
}
複製程式碼

點贊和評論功能的程式碼就可完全複用!這一切只是用了layoutId作為了viewType! 至此,框架的全貌已呈現:

public interface ItemBinder {
    @LayoutRes
    int getLayoutId();

    void onBindViewHolder(ItemViewHolder holder, int position);
}

public class ItemAdapter extends RecyclerView.Adapter<ItemViewHolder> {
    private final List<ItemBinder> mBinders = new ArrayList<>(10);

    @NonNull
    @Override
    public ItemViewHolder onCreateViewHolder(@NonNull ViewGroup container, int viewType) {
        return new ItemViewHolder(LayoutInflater.from(container.getContext()).inflate(
                viewType, container, false));
    }

    @Override
    public void onBindViewHolder(@NonNull ItemViewHolder holder, int position) {
        ItemBinder binder = mBinders.get(position);
        binder.onBindViewHolder(holder, position);
    }

    @Override
    public int getItemCount() {
        return mBinders.size();
    }

    @Override
    public int getItemViewType(int position) {
        return mBinders.get(position).getLayoutId();
    }

    public void setBinders(List<ItemBinder> binders) {
        mBinders.clear();
        appendBinders(binders);
    }
}
複製程式碼

我們之前的通用ViewHolder也羅列在這裡:

public class ItemViewHolder extends RecyclerView.ViewHolder {
    private final SparseArrayCompat<View> mCached = new SparseArrayCompat<>(10);

    public ItemViewHolder(View itemView) {
        super(itemView);
    }

    public <T extends View> T findViewById(@IdRes int resId) {
        int pos = mCached.indexOfKey(resId);
        View v;
        if (pos < 0) {
            v = itemView.findViewById(resId);
            mCached.put(resId, v);
        } else {
            v = mCached.valueAt(pos);
        }
        @SuppressWarnings("unchecked")
        T t = (T) v;
        return t;
    }
}
複製程式碼

一般都還要定義一個基礎類ItemBaseBinder,所有派生類的可能會共享某個操作, 這個基礎類接收資源id作為建構函式引數:


public class ItemBaseBinder implements ItemBinder {
    private final int mLayoutId;

    public ItemBaseBinder(@layoutRes int layoutId) {
        mLayoutId = layoutId;
    }

    @Override
    public void onBindViewHolder(ItemViewHolder holder, int position) {
    }

    @Override
    public int getLayoutId() {
        return mLayoutId;
    }
}
複製程式碼

其餘的工作就只是派生具體的業務類了,就像之前舉例那樣!這一切不過130行程式碼!

MutiType的差異

例項一對一

MutiType庫同樣有繫結器ItemViewBinder但注意他的繫結是隻有一個例項,而我們的ItemAdapter是把繫結器作為元素物件,一個資料對應一個繫結器所以他有多個例項,實際上這個繫結器是對資料的抽象。

無型別轉換無反射操作

真的,MutiType把這一切搞的太複雜了!可悲的是還有很多人在用……

結語

有了這個框架,靈活性不僅一點沒有損失,而且更加簡潔,MutiType那坨型別強轉和反射操作可以進博物館了。

一大篇說下來有點累贅,直接上程式碼就能看明白的,關鍵是思考的過程與解決問題的思路。所有的框架到底解決了什麼問題,這才是最需要了解和學習的,否則框架是學不完的。而一旦我們有了思路與目標,實現一個框架也並不是難事。這套小框架實踐已經很長時間了,可以覆蓋絕大多數情況,效果出奇的好,比MutiType那坨“不知道高到哪裡去了”。

需要注意的有2點

  1. onBindViewHolder方法只做資料填充不應該做資料處理 這點其實和框架沒有關係,照樣還是有許多人在Adapter的onBindViewHolder做著資料處理
  2. 動態的更換檢視型別 因為方法getLayoutId是介面,意味著在執行時可以返回不同的layoutId,從而動態的更改檢視型別,不過需要與Adatper的notifyItemChanged配合使用
  3. 外部通知更新 ItemAdapter.setBinders的方法實現體在更新了例項後沒有呼叫notifyDataSetChanged, 這個操作應該由外部決定,雖然此處是必要的,但很容易造成冗餘的更新。

擴充套件

框架也非常容易根據具體的需要和場景進行擴充套件。

巢狀

列表巢狀列表的情況下,要如何抽象呢,其實只要對應檢視就行。最外層的列表(一級列表)有一個特殊ItemBinder型別,這個型別本身也可以持有多個ItemBinder提供給內層列表(二級列表):

public class ItemContainerBinder extends ItemBaseBinder {
    private final ItemAdapter mAdapter = new ItemAdapter();

    @Override
    public void onBinderViewHolder(ItemViewHolder holder, int position) {
        RecyclerView secondary = holder.findViewById(R.id.secondary);
        if (secondary != null) {
            if (secondary.getAdapter() != mAdapter) {
                secondary.setAdapter(mAdapter);
            }
            if (secondary.getLayoutManager() == null) {
                secondary.setLayoutManager(new LinearLayoutManager(secondary.getContext());
            }
        }
    }

    public void setBinders(List<ItemBinder> binders) {
        mAdapter.setBinders(binders);
    }
...
}
複製程式碼

在這裡還可以利用以前提過的重用LayoutManager

區域性更新

在執行過程中只需要更新列表某一項的情況其實非常常見,很多時候不能只通過呼叫檢視物件的方法來直接更新檢視,還要呼叫Adapter.notifyItemChanged(像前文所提的動態更新列表檢視型別)。也就是Adapter持有ItemBinder,而ItemBinder需要再呼叫Adapter的方法,如果再讓ItemBinder去引用Adapter,這種強耦合必然不是一個好的設計。

針對這個框架的實現,這時候首先需要將ItemBinder內部的變化通知出來,但是通知的時機應該由ItemBinder實現體來決定,外部去被動響應。這當然是最簡單的觀察者模式了,於是有:

public interface ItemBinder {
...
    void setOnChangeListener(OnChangeListener listener);

    interface OnChangeListener {
        void onItemChanged(ItemBinder item, int payload);
    }
}

public class ItemBaseBinder implements ItemBinder {
...
    private OnChangeListener mChangeListener;

    @Override
    publi final void setChangeListener(OnChangeListener listener) {
        mChangeListener = listener;
    }

    public final void notifyItemChange(int payload) {
        if (mChangeListener != null) {
            mChangeListener.onItemChanged(this, payload);
        }
    }
}
複製程式碼

這裡的payload借鑑了RecyclerView.Adapter,只不過型別由Object變成了int,代表了區域性更新需要攜帶的資訊。在ItemBinder實現體內部,因為某項資料變更需要通知到外部就只需呼叫notifyItemChange方法,將變更傳遞出去,由外部作出具體響應:

List<ItemBinder> binders = new ArrayList<>();
...
ItemBinder special = new XXXYYYBinder(...);
specail.setChangeListener(new ItemBinder.OnChangeListener() {
    @Override
    public void onItemChanged(ItemBinder item, int payload) {
        int pos = mAdapter.indexOf(item);
        if (pos >= 0) {
            mAdapter.notifyItemChanged(pos,...);
        }
    }
});
binders.add(special);
...
mAdapter.setBinders(binders);
複製程式碼

相關文章