基於 Multitype 開源庫封裝更好用的RecyclerView.Adapter

蘆葦科技App技術團隊發表於2018-11-29

前言

MultiType 這個專案,至今 v3.x 穩定多時,考慮得非常多,但也做得非常剋制。原則一直是 直觀、靈活、可靠、簡單純粹(其中直觀和靈活是非常看重的)。

這是 MultiType 框架作者給出的專案簡述。

作為一個 RecyclerView 的 Adapter 框架,感覺這專案的設計非常的優雅,而且可以滿足很多常用的需求,而且像作者所說,該專案非常剋制,沒有因為便利而加入一些會導致專案臃腫的功能,它只提供了資料的繫結,其他的功能我們只需要稍微加以封裝就可以實現。

為什麼要封裝

如果還沒用過這個庫的先去看看作者的文件

我們先來看看框架的原始用法:

Step 1. 建立一個 class,它將是你的資料型別或 Java bean / model. 對這個類的內容沒有任何限制。示例如下:

public class Category {

    @NonNull public final String text;

    public Category(@NonNull String text) {
        this.text = text;
    }
}
複製程式碼

Step 2. 建立一個 class 繼承 ItemViewBinder.

ItemViewBinder 是個抽象類,其中 onCreateViewHolder 方法用於生產你的 item view holder, onBindViewHolder 用於繫結資料到 Views. 一般一個 ItemViewBinder 類在記憶體中只會有一個例項物件,MultiType 內部將複用這個 binder 物件來生產所有相關的 item views 和繫結資料。示例:

public class CategoryViewBinder extends ItemViewBinder<Category, CategoryViewBinder.ViewHolder> {

    @NonNull @Override
    protected ViewHolder onCreateViewHolder(@NonNull LayoutInflater inflater, @NonNull ViewGroup parent) {
        View root = inflater.inflate(R.layout.item_category, parent, false);
        return new ViewHolder(root);
    }

    @Override
    protected void onBindViewHolder(@NonNull ViewHolder holder, @NonNull Category category) {
        holder.category.setText(category.text);
    }

    static class ViewHolder extends RecyclerView.ViewHolder {

        @NonNull private final TextView category;

        ViewHolder(@NonNull View itemView) {
            super(itemView);
            this.category = (TextView) itemView.findViewById(R.id.category);
        }
    }
}
複製程式碼

Step 3. 在 Activity 中加入 RecyclerView 和 List 並註冊你的型別,示例:

public class MainActivity extends AppCompatActivity {

    private MultiTypeAdapter adapter;

    /* Items 等同於 ArrayList<Object> */
    private Items items;

    @Override 
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        RecyclerView recyclerView = (RecyclerView) findViewById(R.id.list);
        /* 注意:我們已經在 XML 佈局中通過 app:layoutManager="LinearLayoutManager"
         * 給這個 RecyclerView 指定了 LayoutManager,因此此處無需再設定 */

        adapter = new MultiTypeAdapter();

        /* 註冊型別和 View 的對應關係 */
        adapter.register(Category.class, new CategoryViewBinder());
        adapter.register(Song.class, new SongViewBinder());
        recyclerView.setAdapter(adapter);

        /* 模擬載入資料,也可以稍後再載入,然後使用
         * adapter.notifyDataSetChanged() 重新整理列表 */
        items = new Items();
        for (int i = 0; i < 20; i++) {
            items.add(new Category("Songs"));
            items.add(new Song("drakeet", R.drawable.avatar_dakeet));
            items.add(new Song("許岑", R.drawable.avatar_cen));
        }
        adapter.setItems(items);
        adapter.notifyDataSetChanged();
    }
}
複製程式碼

我把作者文件中的事例搬了過來,可以看到,使用還是非常簡易的,沿用了原生 ViewHolder 的用法,上手很快。

  • 但是這也是一個非常不便的問題,因為作者沒有進一步的封裝,所以我們還需要為每個 Binder 去配置一個 ViewHolder ,所以我們還是做了很多重複性的工作。
  • 並且在 Adapter 或 Binder 中沒有為我們提供 Item 的點選反饋介面,這樣就導致我們的點選萬一依賴到 Activity 或者 Fragment 的一些變數的話,又需要我們去寫一個 Callback 。

所以我們的封裝就是為了解決上面的兩個問題。

封裝

問題

上面說到我們封裝就是要解決上面提到的兩個問題,讓其更好用:

  1. 封裝 ViewHolder
  2. 新增點選事件
  3. 新增 Sample Binder
  4. 新增Header、Footer

第三點是隨便新增上去的,用於只有一個 TextView 的 Item。

方案

1. 封裝ViewHolder

思路其實很簡單,就是建立一個 BaseViewHolder 來代替我們之前需要頻繁建立的 ViewHolder.

廢話少說,看程式碼:

public class BaseViewHolder extends RecyclerView.ViewHolder {

        private View mView;
        private SparseArray<View> mViewMap = new SparseArray<>();   // 1

        public BaseViewHolder(View itemView) {
            super(itemView);
            mView = itemView;
        }

        //返回根View
        public View getView() {
            return mView;
        }

        /**
         * 根據View的id來返回view例項
         */
        public <T extends View> T getView(@IdRes int ResId) {
            View view = mViewMap.get(ResId);
            if (view == null) {
                view = mView.findViewById(ResId);
                mViewMap.put(ResId, view);
            }
            return (T) view;
        }
}

複製程式碼

整個類就一個方法 getView 的兩個過載,沒有引數的 那個返回我們 Item 的根 View ,有引數的那個可以根據控制元件的 Id 來返回相對應 View。

getView(@IdRes int ResId) 方法中,我們用 ResId 為鍵,View 為值的 SparseArray 來儲存當前 ViewHolder 的各種View,然後首次載入(即mViewMap 沒有對應的值)時就用 findViewById 方法來獲取相對View並存起來,然後複用的時候就可以直接重 mViewMap 中獲取相對於的值(View)來進行資料繫結。

接著,為了方便,我們可以新增一系列的方法在此類中,例如:

 public BaseViewHolder setText(@IdRes int viewId, @StringRes int strId) {
        TextView view = getView(viewId);
        view.setText(strId);
        return this;
    }

    
    public BaseViewHolder setImageResource(@IdRes int viewId, @DrawableRes int imageResId) {
        ImageView view = getView(viewId);
        view.setImageResource(imageResId);
        return this;
    }
    
複製程式碼

這樣一來,我們就可以在 Binder 類的onBindViewHolder中進行更加簡便的資料繫結,例如:

@Override
protected void onBindViewHolder(@NonNull BaseViewHolder holder, @NonNull T item) {
    holder.setText(R.id.name,“張三”);
    holder.setImageResource(R.id.avatar,R.mimap.icon_avatar);
}
複製程式碼

2. 封裝 ItemBinder

為了解決我們上面問題中的第2點,我們需要封裝一個 ItemBinder 來實現我們的功能。程式碼如下:

public abstract class LwItemBinder<T> extends ItemViewBinder<T, LwViewHolder> {

    private OnItemClickListener<T> mListener;
    private OnItemLongClickListener<T> mLongListener;
    private SparseArray<OnChildClickListener<T>> mChildListenerMap = new SparseArray<>();
    private SparseArray<OnChildLongClickListener<T>> mChildLongListenerMap = new SparseArray<>();

    protected abstract View getView(@NonNull LayoutInflater inflater, @NonNull ViewGroup parent);

    protected abstract void onBind(@NonNull LwViewHolder holder, @NonNull T item);

    @NonNull
    @Override
    protected final LwViewHolder onCreateViewHolder(@NonNull LayoutInflater inflater, @NonNull ViewGroup parent) {
        return new LwViewHolder(getView(inflater, parent));
    }

    @Override
    protected final void onBindViewHolder(@NonNull LwViewHolder holder, @NonNull T item) {
        bindRootViewListener(holder, item);
        bindChildViewListener(holder, item);
        onBind(holder, item);
    }

    /**
     * 繫結子View點選事件
     *
     * @param holder
     * @param item
     */
    private void bindChildViewListener(LwViewHolder holder, T item) {
        //點選事件
        for (int i = 0; i < mChildListenerMap.size(); i++) {
            int id = mChildListenerMap.keyAt(i);
            View view = holder.getView(id);
            if (view != null) {
                view.setOnClickListener(v -> {
                    OnChildClickListener<T> l = mChildListenerMap.get(id);
                    if (l!=null){
                        l.onChildClick(holder,view,item);
                    }
                });
            }
        }
        //長按點選
        for (int i = 0; i < mChildLongListenerMap.size(); i++) {
            int id = mChildLongListenerMap.keyAt(i);
            View view = holder.getView(id);
            if (view != null) {
                view.setOnClickListener(v -> {
                    OnChildLongClickListener<T> l = mChildLongListenerMap.get(id);
                    if (l != null) {
                        l.onChildLongClick(holder,view, item);
                    }
                });
            }
        }
    }


    /**
     * 繫結根view
     *
     * @param holder
     * @param item
     */
    private void bindRootViewListener(LwViewHolder holder, T item) {
        //根View點選事件
        holder.getView().setOnClickListener(v -> {
            if (mListener != null) {
                mListener.onItemClick(holder, item);
            }
        });
        //根View長按事件
        holder.getView().setOnLongClickListener(v -> {
            boolean result = false;
            if (mLongListener != null) {
                result = mLongListener.onItemLongClick(holder, item);
            }
            return result;
        });
    }


    /**
     * 點選事件
     */
    public void setOnItemClickListener(OnItemClickListener<T> listener) {
        mListener = listener;
    }

    /**
     * 點選事件
     *
     * @param id 控制元件id,可傳入子view ID
     * @param listener
     */
    public void setOnChildClickListener(@IdRes int id, OnChildClickListener<T> listener){
        mChildListenerMap.put(id,listener);
    }

    public void setOnChildLongClickListener(@IdRes int id, OnChildLongClickListener<T> listener){
        mChildLongListenerMap.put(id,listener);
    }

    /**
     * 長按點選事件
     */
    public void setOnItemLongClickListener(OnItemLongClickListener<T> l) {
        mLongListener = l;
    }

    /**
     * 長按點選事件
     *
     * @param id 控制元件id,可傳入子view ID
     */
    public void removeChildClickListener(@IdRes int id){
        mChildListenerMap.remove(id);
    }

    public void removeChildLongClickListener(@IdRes int id){
        mChildLongListenerMap.remove(id);
    }

    /**
     * 移除點選事件
     */
    public void removeItemClickListener() {
        mListener = null;
    }



    public void removeItemLongClickListener() {
        mLongListener = null;
    }


    public interface OnItemLongClickListener<T> {
        boolean onItemLongClick(LwViewHolder holder, T item);
    }

    public interface OnItemClickListener<T> {
        void onItemClick(LwViewHolder holder, T item);
    }

    public interface OnChildClickListener<T> {
        void onChildClick(LwViewHolder holder, View child, T item);
    }

    public interface OnChildLongClickListener<T> {
        void onChildLongClick(LwViewHolder holder, View child, T item);
    }

}

複製程式碼

程式碼也很簡單,提供了Click以及LongClick的監聽,並且在 onCreateViewHolder()方法中將我們剛剛封裝的 BaseViewHolder 給傳進去,然後提供兩個抽象方法:

  • getView(@NonNull LayoutInflater inflater,@NonNull ViewGroup parent)
    • 需要返回Item的View例項
  • onBind(@NonNull BaseViewHolder holder, @NonNull T item)
    • 在此方法內進行資料繫結

以後我們就不必為每個 Binder 都設定一套ViewHolder了,例項如下:

public class RankItemBinder extends LwItemBinder<Rank> {

    private final int[] RANK_IMG = {
            R.drawable.no_4,
            R.drawable.no_5,
            R.drawable.no_6,
            R.drawable.no_7,
            R.drawable.no_8,
            R.drawable.no_9,
            R.drawable.no_10
    };

    @Override
    protected View getView(@NonNull LayoutInflater inflater, @NonNull ViewGroup parent) {
        return inflater.inflate(R.layout.item_rank, parent, false);
    }

    @Override
    protected void onBind(@NonNull BaseViewHolder holder, @NonNull Rank item) {
        Context context = holder.getView().getContext();
        holder.setText(R.id.tv_name, item.getUserNickname());
        holder.setText(R.id.tv_num, context.getString(R.string.text_caught_doll_num, item.getCaughtNum()));
        loadCircleImage(context,item.getUserIconUrl(),0,0,holder.getView(R.id.iv_avatar));
        if (holder.getAdapterPosition() < 7) {
            holder.setImageResource(R.id.iv_rank, RANK_IMG[holder.getAdapterPosition()]);
        }
    }

    public void loadCircleImage(final Context context, String url, int placeholderRes, int errorRes, final ImageView imageView) {
        RequestOptions requestOptions = new RequestOptions()
                .circleCrop();
        if (placeholderRes != 0) requestOptions.placeholder(placeholderRes);
        if (errorRes != 0) requestOptions.error(errorRes);
        Glide.with(context).load(url).apply(requestOptions).into(imageView);
    }
}

複製程式碼

可以看到,非常的簡潔,並且可以在 Activity 或 Fragment 中新增監聽事件:

RankItemBinder binder = new RankItemBinder();
binder.setOnItemClickListener(new BaseItemBinder.OnItemClickListener<Rank>() {
    @Override
    public void onItemClick(BaseViewHolder holder, Rank item) {
        ToastUtils.showShort("點選了"+item.getUserNickname());
    }
});

複製程式碼

如果使用 lambda 表示式,則可以更簡潔:

binder.setOnItemClickListener((holder, item) -> 
    ToastUtils.showShort("點選了"+item.getUserNickname()));
複製程式碼

以上就是整套的封裝了,很簡單,但是也很實用,可以在日常開發中省下不少程式碼。

3. 封裝Sample

上面說了,我們還可以通過繼承這個 BaseItemBinder 來實現一個只有一個 TextView 的Sample:

public class SampleBinder extends LwItemBinder<Object> {

    public static final int DEFAULT_TEXT_SIZE = 15; //sp
    public static final int DEFAULT_HEIGHT = 50;  //dp
    public static final int DEFAULT_PADDING_HORIZONTAL = 6; //dp
    public static final int DEFAULT_PADDING_VERTICAL = 4; //dp

    @Override
    protected View getView(@NonNull LayoutInflater inflater, @NonNull ViewGroup parent) {
        Context context = parent.getContext();
        DisplayMetrics metrics = context.getResources().getDisplayMetrics();
        float density = metrics.density;
        int heightPx = dp2px(density, DEFAULT_HEIGHT);
        int paddingHorizontal = dp2px(density, DEFAULT_PADDING_HORIZONTAL);
        TextView textView = new TextView(context);
        textView.setTextSize(DEFAULT_TEXT_SIZE);
        textView.setGravity(Gravity.CENTER_VERTICAL);
        textView.setPadding(paddingHorizontal, 0, paddingHorizontal, 0);
        ViewGroup.LayoutParams params =
                new ViewGroup.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, heightPx);
        textView.setLayoutParams(params);
        custom(textView, parent);
        return textView;
    }

    @Override
    protected void onBind(@NonNull LwViewHolder holder, @NonNull Object item) {
        TextView textView = holder.getView();
        textView.setText(item.toString());
    }

    private int dp2px(float density, float dp) {
        return (int) (density * dp + 0.5f);
    }

    protected void custom(TextView textView, ViewGroup parent) {

    }
}

複製程式碼

很簡單的一個擴充套件,根 View 就是一個 TextView,然後提供了一些屬性的設定修改,如果不滿足預設樣式還可以重寫 custom(TextView textView, ViewGroup parent)方法對 TextView 進行樣式的修改,或者重寫 custom(TextView textView, ViewGroup parent)方法在進行繫結的時候進行控制元件的屬性修改等邏輯。

4. 新增Header、Footer

MultiType 其實本身就支援 HeaderViewFooterView,只要建立一個 Header.class - HeaderViewBinderFooter.class - FooterViewBinder 即可,然後把 new Header() 新增到 items 第一個位置,把 new Footer() 新增到 items 最後一個位置。需要注意的是,如果使用了 Footer View,在底部插入資料的時候,需要新增到 最後位置 - 1,即倒二個位置,或者把 Footer remove 掉,再新增資料,最後再插入一個新的 Footer.

這個是作者文件裡面說的,簡單,但是繁瑣,既然我們要封裝,肯定就不能容忍這麼繁瑣的事情。

先理一下要實現的點:

  • 一行程式碼新增 Header/Footer
  • 源資料的更改更新與 Header/Footer 無關

接下來看看具體實現:

public class LwAdapter extends MultiTypeAdapter {

    //...省略部分程式碼
    
    private HeaderExtension mHeader;
    private FooterExtension mFooter;
    
    /**
     * 新增Footer
     *
     * @param o Header item
     */
    public LwAdapter addHeader(Object o) {
        createHeader();
        mHeader.add(o);
        notifyItemRangeInserted(getHeaderSize() - 1, 1);
        return this;
    }

    /**
     * 新增Footer
     *
     * @param o Footer item
     */
    public LwAdapter addFooter(Object o) {
        createFooter();
        mFooter.add(o);
        notifyItemInserted(getItemCount() + getHeaderSize() + getFooterSize() - 1);
        return this;
    }

    /**
     * 增加Footer資料集
     *
     * @param items Footer 的資料集
     */
    public LwAdapter addFooter(Items items) {
        createFooter();
        mFooter.addAll(items);
        notifyItemRangeInserted(getFooterSize() - 1, items.size());
        return this;
    }

    private void createHeader() {
        if (mHeader == null) {
            mHeader = new HeaderExtension();
        }
    }

    private void createFooter() {
        if (mFooter == null) {
            mFooter = new FooterExtension();
        }
    }
}

複製程式碼

先看上面的實現,用 addHeader(Object o)新增 Header,新增 Footer 同理,一行程式碼就實現,但是這個 addHeader(Object o) 方法裡面的邏輯是怎樣的呢,首先是呼叫了 createHeader(),即建立一個 HeaderExtension物件並把引用賦值給 mHeader,然後再呼叫mHeader.add(o)將我們傳過來的 item 例項給新增進去,最後呼叫AdapternotifyItemInserted方法重新整理一下列表就OK了。邏輯很簡單,但是這樣為什麼就可以實現了新增 Header 的功能呢,HeaderExtension又是什麼鬼呢?

接下來看看 HeaderExtension是什麼?

public class HeaderExtension implements Extension {

    private Items mItems;

    public HeaderExtension(Items items) {
        this.mItems = items;
    }

    public HeaderExtension(){
        this.mItems = new Items();
    }

    @Override
    public Object getItem(int position) {
        return mItems.get(position);
    }

    @Override
    public boolean isInRange(int adapterSize, int adapterPos) {
        return adapterPos < getItemSize();
    }

    @Override
    public int getItemSize() {
        return mItems.size();
    }

    @Override
    public void add(Object o) {
        mItems.add(o);
    }

    @Override
    public void remove(Object o) {
        mItems.add(o);
    }
    
    //...省略部分程式碼
}
複製程式碼

該類實現了Extension介面,我們呼叫add()方法就是將傳過來的物件儲存起來而已。整個類最主要的方法就是 isInRange(int adapterSize, int adapterPos) 方法,看到這個方法的實現相信你也能明白他的作用了,就是用來判斷 Adapter裡面傳過來的 position 對應的 Item 是否是 Header.接下來看一下這個方法在 Adapter 內的使用在哪裡:

#LwAdapter.java

 @Override
    public final int getItemViewType(int position) {
        Object item = null;
        int headerSize = getHeaderSize();
        int mainSize = getItems().size();
        if (mHeader != null) {
            if (mHeader.isInRange(getItemCount(), position)) {
                item = mHeader.getItem(position);
                return indexInTypesOf(position, item);
            }
        }
        if (mFooter != null) {
            if (mFooter.isInRange(getItemCount(), position)) {
                int relativePos = position - headerSize - mainSize;
                item = mFooter.getItem(relativePos);
                return indexInTypesOf(relativePos, item);
            }
        }
        int relativePos = position - headerSize;
        return super.getItemViewType(relativePos);
    }
複製程式碼

第一次的呼叫在這裡,到這裡我們應該就恍然大悟了,原來就是根據 position 來判斷是否用於 Header/Footer ,然後再用 父類裡面的 indexInTypesOf(int,Object)來獲取對應的型別。接著在 onCreateViewHolder(ViewGroup parent, int indexViewType)會自動建立我們對應的 ViewHolder,最後在onBindViewHolder()中再進行相應的繫結即可:

 @SuppressWarnings("unchecked")
    @Override
    public final void onBindViewHolder(RecyclerView.ViewHolder holder, int position,
                                       @NonNull List<Object> payloads) {
        Object item = null;
        int headerSize = getHeaderSize();
        int mainSize = getItems().size();
        ItemViewBinder binder = getTypePool().getItemViewBinder(holder.getItemViewType());
        if (mHeader != null) {
            if (mHeader.isInRange(getItemCount(), position)) {
                item = mHeader.getItem(position);
            }
        }
        if (mFooter != null) {
            if (mFooter.isInRange(getItemCount(), position)) {
                int relativePos = position - headerSize - mainSize;
                item = mFooter.getItem(relativePos);
            }
        }
        if (item != null) {
            binder.onBindViewHolder(holder, item);
            return;
        }
        super.onBindViewHolder(holder, position - headerSize, payloads);
    }
複製程式碼

onBindViewHoldergetItemViewType的實現思想類似,判斷是否是 Header/Footer 拿到相應的實體類,然後進行繫結。整個流程就是這樣,當然別忘了也要在 getItemCount方法中將我們的 Header 與 Footer 的數量加進入,如:

@Override
public final int getItemCount() {
    int extensionSize = getHeaderSize() + getFooterSize();
    return super.getItemCount() + extensionSize;
}
複製程式碼

這樣的封裝可以讓我們的 Header/Footer 裡面的資料集與原本的資料集分離,我們的主資料再怎麼增刪查改都不會影響到Header/Footer 的正確性。

這樣的實現目前有個比較蛋疼的點,我們呼叫ViewHoldergetAdapterPosition()時候會返回實際的 position,即包含了 Header 的數量,目前這點還沒解決,需要手動把該 position 減去 Header 的數量才能得到原始資料集的相對位置。

以上,就完成了本次的小封裝,趕緊去程式碼中實戰吧。

相關文章