仿B站Android客户端系列(二)基于MultiType的RecyclerView.Adapter封装

上接👉👉👉仿B站Android客户端系列(一)项目环境搭建

前言

任何项目几乎都会用RecyclerView作为列表的实现。在传统的开发方式中,简单的单一类型数据列表非常容易实现,当需要支持多种数据类型及布局时,我们的代码往往会堆积在Adapter中,当Adapter封装了一些通用操作时,该类更会显得臃肿不堪,不便于维护。好的封装会大大节省开发效率,增强代码的易读性,这样在开发以及修改的过程中可以节省不少时间。

在浏览b站App时可以看到各个页面的列表基本都有如下特性:支持下拉刷新、Loading、加载失败、加载以及存在加载的各种状态。这篇文章主要说一下在FakeBiliBili项目的开发过程中,对于Adapter封装的思路和一些想法。

MultiType

这里先安利一个解决多类型问题的Adapter库:

MultiType

这是一个直观、灵活、可靠、简单纯粹的库,其中设计思想非常值得学习。

基础的用法如下:

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);
        mRecyclerView = (RecyclerView) findViewById(R.id.list);
        mAdapter = new MultiTypeAdapter();
        //注册,数据类型对应ViewBinder
        mAdapter.register(TextBean.class, new TextViewBinder());
        mAdapter.register(ImageBean.class, new ImageViewBinder());
        mAdapter.register(VideoBean.class, new VideoViewBinder());
        mRecyclerView.setAdapter(mAdapter);
        //设置列表数据
        Items<Object> items = new Items<>();
        items.add(new ImageBean(R.drawable.image1));
        items.add(new ImageBean(R.drawable.image2));
        items.add(new TextBean("text1"));
        items.add(new TextBean("text2"));
        items.add(new TextBean("text3"));
        items.add(new VideoBean(url));
        mAdapter.setItems(items);
        mAdapter.notifyDataSetChanged();
        }
}
//ItemViewBinder示例
public class TextViewBinder extends ItemViewBinder<TextBean, TextViewBinder.ViewHolder> {

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

    @Override
    protected void onBindViewHolder(@NonNull ViewHolder holder, @NonNull TextBean textBean) {
        holder.category.setText(textBean.tv_text);
    }

    static class ViewHolder extends RecyclerView.ViewHolder {

        @NonNull private TextView tv_text;

        ViewHolder(@NonNull View itemView) {
            super(itemView);
            tv_text = (TextView) itemView.findViewById(R.id.tv_text);
        }
    }
}

这个库的核心是通过类型池TypePool来管理注册 binder 与 class 来实现不同类型布局的策略模式,MultiType内部将复用这个 binder 对象来生产所有相关的 item、views 和绑定数据。用法就是注册绑定数据类型和 ItemViewBinder,然后向内置的数据集合对象中添加数据然后通知刷新,Item便会根据集合中的顺序依次显示。

MultiType提供的 ItemViewBinder 沿袭了 RecyclerView.Adapter 的接口命名,很容易理解,这样我们可以轻松的实现多种类型列表,而且代码清晰、直观,方便修改,相比传统的写法可以说是省了不少时间和维护精力。详细用法请移步该库Wiki

扩展

通过MultiType的支持,现在没有复杂类型列表的问题了,但MultiType并不能满足上拉加载、显示Loading、加载失败或是需要添加Header、Footer等其他需求,这时候需要我们自己扩展来实现,下面就来聊聊我的实现思路。

一.关于加载更多的扩展

首先就是封装加载更多这样的常用功能,通常的写法是

    @Override
    public RecyclerView.ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
        if (viewType == TYPE_LOAD_MORE) {
            return ...
        }
        return ...;
    }

    @Override
    public void onBindViewHolder(RecyclerView.ViewHolder holder, int position) {
        if (getItemViewType(position) == TYPE_LOAD_MORE) {
            ...      
        } else {
            ...
        }
    }

    @Override
    public int getItemCount() {
        return mDatas.size() + 1;//给出加载更多的位置
    }
    
    @Override
    public int getItemViewType(int position) {
        if (position == getItemCount()) {
            return TYPE_LOAD_MORE;//当显示最后一个Item,使其为加载更多类型
        } else {
            return ...
        }
    } 

那么基本思路就是继承MultiTypeAdapter并重写这几个方法,当需要显示我们定制的通用Item时,在继承类中实现,当需要显示MultiType职责内的Item时,归还给父类MultiTypeAdapter实现。但是当我尝试继承MultiTypeAdapter重写这些方法时,发现作者给这些方法都加上了final,那么便无法继承扩展。开始不是很理解,还提了issue给作者,得到的回复是这样的:

使用 final 意在避免用户自定义破坏了封装并且归结认为是 MultiType 的问题。如果你需要覆写这些 final 方法,你应该考虑采用组合而非继承,即创建一个 Adapter 包含 MultiTypeAdapter 而不是继承 MultiTypeAdapter。

MultiTypeAdapter 并不是为继承而设计的类。《Effective Java》一书中指出:使类和成员的可访问性最小化,并且要么为继承而设计,并提供文档说明,要么就禁止继承。在 Kotlin 语言设计中,也是遵循了这个原则,所有类默认都是 final,所有方法默认都是 final,除非特意标注 open.

看完豁然开朗!其实《Effective Java》也读过,但平常写代码时没有注意,导致做了很多过度设计。

所以我需要用一个装饰模式来完成需求的扩展,这里修改一下:

    protected RecyclerView.Adapter innerAdapter;

    @Override
    public RecyclerView.ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
        if (viewType == TYPE_LOAD_MORE) {
            return ...
        }
        return innerAdapter.onCreateViewHolder(parent, viewType);//交给目标adapter处理
    }

    @Override
    public void onBindViewHolder(RecyclerView.ViewHolder holder, int position) {
        if (getItemViewType(position) == TYPE_LOAD_MORE) {
            ...
            return;    
        }
        innerAdapter.onBindViewHolder(holder, position);//交给目标adapter处理
    }

    @Override
    public int getItemCount() {
        return innerAdapter.getItemCount() + 1;//给出加载更多的位置
    }
    
    @Override
    public int getItemViewType(int position) {
        if (position == getItemCount() - 1) {
            return TYPE_LOAD_MORE;//当显示最后一个Item,使其为加载更多类型
        } 
         innerAdapter.getItemViewType(position);//交给目标adapter处理
    } 

这也就是很多开源库都会用到的装饰模式写法,比如鸿阳的baseAdapter,可惜有很多bug并且不维护了。

二.完善

基础的骨架有了,接下来就是进一步完善这个adapterWraaper,这里说一下写代码过程中值得注意的地方和一些坑。

1.兼容GridLayoutManager和StaggeredGridLayoutManager

当使用这两种LayoutManager时,需要对我们自己实现的ViewType在不同LayoutManager时做一些特殊处理,否则当列数大于1时,加载更多item便不能撑满一行。

    @Override
    public void onAttachedToRecyclerView(RecyclerView recyclerView) {
        recyclerView.addOnScrollListener(mOnScrollListener);
        if (recyclerView.getLayoutManager() instanceof GridLayoutManager) {
            final GridLayoutManager layoutManager = (GridLayoutManager) recyclerView.getLayoutManager();
            final GridLayoutManager.SpanSizeLookup oldSpanSizeLookup = layoutManager.getSpanSizeLookup();
            layoutManager.setSpanSizeLookup(new GridLayoutManager.SpanSizeLookup() {
                @Override
                public int getSpanSize(int position) {
                    if (getItemViewType(position) == ITEM_TYPE_LOAD_MORE) {
                        return layoutManager.getSpanCount();
                    } else {
                        return oldSpanSizeLookup.getSpanSize(position);
                    }
                }
            });
        }
        innerAdapter.onAttachedToRecyclerView(recyclerView);
    }
    
    @Override
    public void onViewAttachedToWindow(RecyclerView.ViewHolder holder) {
        if (isBaseViewHolder(holder)) {
            innerAdapter.onViewAttachedToWindow(holder);
        } else {
            ViewGroup.LayoutParams lp = holder.itemView.getLayoutParams();
            if (lp != null && lp instanceof StaggeredGridLayoutManager.LayoutParams) {
                StaggeredGridLayoutManager.LayoutParams p = (StaggeredGridLayoutManager.LayoutParams) lp;
                p.setFullSpan(true);
            }
        }
    }

2. ItemDecoration与SpanSizeLookup

由于我们运用装饰模式的处理了GridLayoutManager时带来的跨度问题,但此时作用于GridLayoutManager中的SpanSizeLookup是我们的装饰类,这可能会引发一些问题。例如我有时会在ItemDecoration中直接把SpanSizeLookup对象当做参数传进来,用于判断该项的跨度。

public class CustomItemDecoration extends RecyclerView.ItemDecoration {

    private GridLayoutManager.SpanSizeLookup spanSizeLookup;

    public CustomItemDecoration(GridLayoutManager.SpanSizeLookup spanSizeLookup) {
        this.spanSizeLookup = spanSizeLookup;
    }

    @Override
    public void getItemOffsets(Rect outRect, View view, RecyclerView parent, RecyclerView.State state) {
        ...
        int spanSize = spanSizeLookup.getSpanSize(position);
        ...
    }

这样显然会出现意料之外的问题,这时候我们不能直接把SpanSizeLookup传进来,而是通过recyclerView对象获得,像这样:

@Override
    public void getItemOffsets(Rect outRect, View view, RecyclerView parent, RecyclerView.State state) {
        ...
        GridLayoutManager.SpanSizeLookup spanSizeLookup = ((GridLayoutManager) parent.getLayoutManager()).getSpanSizeLookup();
        int spanSize = spanSizeLookup.getSpanSize(position);
        ...
    }

3.不要忘记其他重写方法的处理

详细代码见该项目中

DefaultAdapterWrapper.java

三.效果

加载更多
加载更多失败
点击重试
加载失败
Loading

四.其他

1.Header和Footer

关于Header和Footer的问题,MultiType作者给出了解决方案:

MultiType 其实本身就支持 HeaderView、FooterView,只要创建一个 Header.class - HeaderViewBinder 和 Footer.class - FooterViewBinder 即可,然后把 new Header() 添加到 items 第一个位置,把 new Footer() 添加到 items 最后一个位置。需要注意的是,如果使用了 Footer View,在底部插入数据的时候,需要添加到 最后位置 - 1,即倒二个位置,或者把 Footer remove 掉,再添加数据,最后再插入一个新的 Footer.

最后

仅仅是个人理解,不合理和不完善的地方还请留言指教,谢谢!

项目地址:FakeBiliBili

还可以的话就赏个star吧!(≧▽≦)/

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 194,088评论 5 459
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 81,715评论 2 371
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 141,361评论 0 319
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,099评论 1 263
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 60,987评论 4 355
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,063评论 1 272
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,486评论 3 381
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,175评论 0 253
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,440评论 1 290
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,518评论 2 309
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,305评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,190评论 3 312
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,550评论 3 298
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 28,880评论 0 17
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,152评论 1 250
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,451评论 2 341
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,637评论 2 335

推荐阅读更多精彩内容