Android复杂列表的实现

RecyclerView控件从2014发布以来,目前已经普遍用于项目中,来承载各种列表内容。同时,列表样式也随着项目变的越来越复杂,从简单统一的列表,变化成头部、脚部、不同类型的Item互相组合。本文将通过一些开源库来学习一下如何实现各种复杂类型的列表,分析了viewType应该如何与视图、数据相绑定,并将业务逻辑单独分离。

初步实现

问题的开始是这样的:项目里有个页面,整个列表采用ListView实现,除了常规的列表项外,还有两个自定义的View也要随着页面滑动。Ok,listView支持addHead,而且还是多head,自定义view通过addHead方法添加到listview中,就一切ok。然而ListView毕竟渐渐过时了,打算采用RecyclerView来重构一下。虽然RecyclerView不支持addHead这种方法,但是可以通过getItemViewType方法来实现返回多种类型。

@Override
public int getItemViewType(int position) {
    switch (position) {
        case 0:
            return TYPE_HEAD1;
        case 1:
            return TYPE_HEAD2;
        case 2:
            return TYPE_ITEM;
        default:
            return TYPE_ITEM;
        }
    }

即根据业务需求,返回不同的类型的值,那么下一步,我们同时需要在onCreateViewHolder中针对不同的viewType来创建不同的ViewHolder,同样的,在onBindViewHolder中,也要处理不同的类型,特别的,如果不同类型的viewholder具有不同的方法的情况,还需要针对viewholder做一次类型转换。类似这样:

@Override
public void onBindViewHolder(RecyclerView.ViewHolder holder, int position) {
    if (getItemViewType(position) == TYPE_HEAD1) {
        ((Head1VH) holder).bindData();
    } else if (getItemViewType(position) == TYPE_HEAD2) {
        ((Head2VH) holder).bindData();
    } else if (getItemViewType(position) == TYPE_ITEM) {
        ((Item) holder).bindData();
    }
}

以上就是一般RecyclerView中实现多类型Item的方法,相应的变化一下,把头部和脚部当作特定类型的ItemType,并提供public方法共外部setHead即可支持添加头部。

问题进阶

上述的方法,是解决了特定业务情景下的问题,但是很明细不利于扩展和维护。首先,当列表除了头部外的部分依然会出现不同类型时,并且实际情况中,不同类型应该都是由服务器回传的数据来决定的,我们就不能在getItemViewType中简单的定义类型值来判断。
一个可能的做法是,在数据层里添加type字段,通过type字段来

@Override
public int getItemViewType(int position) {
    return datas.get(position).type;
} 

然而在数据层包裹展示层需要的type字段并不是一个优雅的做法,它破坏了单一职责。同时,这么做也无法解决另一个问题:扩展性。
所谓扩展性就是Adapter最好能在数据类型变化时候,内部实现逻辑不需要改变,只是外部添加新的功能即可。那么这就要求Adapter对数据层是解耦的,不能显式的持有外部的数据。Adapter设计之初,是为了兼容千变万化的数据结构,并不是千变万化的类型结构,因此,应该考虑把不同类型的变化从Adapter内部隔离开。

1.jpg
2.jpg

GitHub上关于多类型Item的RecyclerView的实现有很多库,基本的思路是通过一个Manager类来管理多种类型中:数据和视图的对应关系。实际上,都是围绕如何解决viewType、数据、视图的对应关系来进行一系列的封装。
下面介绍两个实现的比较简洁而灵活的库:

AdapterDelegates的思路是使用自定义的Adapter来“hook”原来的RecyclerView的Adapter,主要的Adapter方法如onBindViewHolder和onCreateViewHolder方法都被劫持使用adpter内部的一个Manager类来实现,参看下面的类图会更加容易理解。

3.jpg

上图是这个库的基本类图,省略了两个非必要的类,其中只列出了一些典型的方法和对象。以onBindViewHolder()为例,可以看到从最顶层开始,这个方法会一步步往下调用,一直到AdapterDelegate这层,这一层也是最终面向使用者需要关心的层次,通过继承抽象类AdapterDelegate,实现其中的方法,来完成业务逻辑和UI表现,代码如下,和普通的RV.Adapter方法没有区别:

public class NormalDelegate extends AbsListItemAdapterDelegate<NormalItem, Item, NormalDelegate.NormalItemVH> {


    @NonNull
    @Override
    protected NormalItemVH onCreateViewHolder(@NonNull ViewGroup parent) {
        return new NormalItemVH(inflater.inflate(R.layout.normal_item, parent, false));
    }

    @Override
    protected void onBindViewHolder(@NonNull NormalItem item, @NonNull final NormalItemVH viewHolder, @NonNull List<Object> payloads) {
        viewHolder.imageView.setImageResource(item.resId);
        viewHolder.imageView.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View view) {
                DetailsActivity.startActivity(view.getContext());
            }
        });
        viewHolder.textView.setText(item.content);
        viewHolder.textView.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View view) {
                String old = viewHolder.textView.getText().toString();
                viewHolder.textView.setText(old + " " + (int) (10 * Math.random()));

            }
        });
    }
}

但是通过这一层的封装,成功的把多类型的情况分隔开,每种类型只需要在各种的AdapterDelegate中去编写业务逻辑就可以,Adapter中的职业就非常简单,只需要持有AdapterDelegateManager,由这个Manager类来维护每种类型具体对应的AdapterDelegate,而由AdapterDelegate维护UI和数据的绑定关系。

4.jpg

如此,面对多类型的情况或者在已有的业务基础上增加了新的类型,都不再用去修改Adapter的基本实现,只要做两件事:

  • 编写类型的AdapterDelegate来实现UI展示、数据绑定、点击事件等工作
  • 通过AdapterDelegateManager注册新的AdapterDelegate

下面是一个demo例子(gif画质比较渣,将就着看。。)

5.jpg

整个列表是一个RecyclerView,包含了两种不同类型的头部,简单的Item类型和可横向滑动展示的Item类型共计4种。来看看这个RecyclerView的Adapter实现:

    class ItemList2Adapter extends ListDelegationAdapter<List<Item>> {
        Activity activity;
        List<Item> datas;

        public ItemList2Adapter(Activity activity, List<Item> datas) {
            this.activity = activity;
            this.datas = datas;
            delegatesManager.addDelegate(new Head1Delegate(activity))
                    .addDelegate(new Head2Delegate(activity))
                    .addDelegate(new NormalDelegate(activity))
                    .addDelegate(new HorizontalItemDelegate(activity));
            setItems(datas);
        }
    }

从代码里可以看到,整个Adapter是非常简洁和清晰的,业务逻辑归于Delegate当中解决,viewType和类型的映射关系放到delegateManager中处理。具体Delegate的代码就不贴了,和常规单类型Adapter的写法一致。下面再看看另一个库的思路:MuliTypeAdapter.
这里就不自己画类图了,从其作者的文档中引用一幅图,如下:

6.jpg

从上文所说的基本原则来分析,我们应重点关注其如何实现viewType字段和类型的映射,以及如何和RV.Adaper交互。从类名和继承关系来看,我们可以知道,MultiTypeAdapter应该是充当之前所说的Manage的角色,同时,这个类实现了两个接口:

  • TypePool
  • FlatTypeAdapter

因此,维护viewType和类型映射关系就必然会体现在其中。而类Items是一个继承ArrayList<Object>的空类,表明了这个类将是所有数据结构的基类。最后,唯一单独没有联系的ItemViewProvider<C,V>则可以推断为用来实现业务逻辑和UI展示。如此,基本要素都一一对应上,接下来看看它是如何实现其中的功能。

public class MultiTypeAdapter extends RecyclerView.Adapter<ViewHolder>{
    @Override
    public int getItemViewType(int position) {
        Object item = items.get(position);
        return indexOf(flattenClass(item));
    }


    @Override public ViewHolder onCreateViewHolder(ViewGroup parent, int indexViewType) {
        if (inflater == null) {
            inflater = LayoutInflater.from(parent.getContext());
        }
        ItemViewProvider provider = getProviderByIndex(indexViewType);
        provider.adapter = MultiTypeAdapter.this;
        return provider.onCreateViewHolder(inflater, parent);
    }


    @SuppressWarnings("unchecked") @Override
    public void onBindViewHolder(ViewHolder holder, int position) {
        Object item = items.get(position);
        ItemViewProvider provider = getProviderByClass(flattenClass(item));
        provider.onBindViewHolder(holder, flattenItem(item));
    }
}

从MuliTypeAdapter的几个重点方法可以看出,其调用的方法几乎都是接口或者抽象类的空方法,这侧面体现出来此库的高度可定制性,所有的方法实现都可以由具体的实现类来决定。

从getViewType方法中可以看到,其返回值由indexOf方法确定,而这个方法定义在TypePool接口中,由MultiTypePool实现,当然我们也可以自己实现然后替换掉。从MultiTypePool的源码中分析:

    private ArrayList<Class<?>> contents;
    private ArrayList<ItemViewProvider> providers;
    
    public void register(Class<?> clazz,ItemViewProvider provider) {
        if (!contents.contains(clazz)) {
            contents.add(clazz);
            providers.add(provider);
        } else {
            int index = contents.indexOf(clazz);
            providers.set(index, provider);
            Log.w(TAG, "You have registered the " + clazz.getSimpleName() + " type. " +
                "It will override the original provider.");
        }
    }

    @Override
    public int indexOf(Class<?> clazz) {
        int index = contents.indexOf(clazz);
        if (index >= 0) {
            return index;
        }
        for (int i = 0; i < contents.size(); i++) {
            if (contents.get(i).isAssignableFrom(clazz)) {
                return i;
            }
        }
        return index;
    }

可以看到,不同于AdapteDelegate中绑定viewType和Delegate,在这里,它将数据类Class和ItemViewProvider进行了绑定,分别用两个ArrayList来存储对象,用index索引作为viewType的值。如下图示意:

7.jpg

当Adapter中注册类型时,将两者绑定;getViewType时,则首先通过position拿到数据类型,再通过数据类型拿到对应的UI类型;onBindViewHolder时,同样通过position拿到数据类型,拿到ItemViewProvider,继而调用ItemViewProvider的onBindViewHolder方法去交由实现类处理。以上应该可以基本明白该库是如何维护viewType、数据类型和UI类型的映射关系的。

而在编写Adapter的过程中,特别是多类型的Adapter过程中,常常会发现自己不得不在onBindVieHolder方法中,对holder转型来调用其内部方法,或者对数据转型来使用其字段值,大量的类型转换既显得臃肿又影响速度。既然我们已经把不同类型的情况已经独立成一个个ItemViewProvider(或者AdapterDelegate,另一个库中的称呼),那么在相应的实现类中,我们也希望能正确的分发数据类型和视图类型。
在AdatperDelegates库中,如果我们的业务实现类直接继承与AdapterDelegate来编写,是这样的:

public class Head1Delegate extends AdapterDelegate<List<Item>> {

@Override
protected void onBindViewHolder(@NonNull List<Item> items, 
int position, @NonNull RecyclerView.ViewHolder holder, 
@NonNull List<Object> payloads) {
   
((Head1VH) holder).imageView.
setImageResource(((Head1) items.get(position)).getResId());
   }
}

可以看到还是没有避免类型转换。作者其实也意识到这点,因此提供了一个AbsListItemAdapterDelegate类来供我们继承,其内部通过泛型预先帮我们做好类型转换,再分发下去:

public abstract class AbsListItemAdapterDelegate<I extends T, T, 
VH extends RecyclerView.ViewHolder>
   extends AdapterDelegate<List<T>> {
   
@Override 
protected final void onBindViewHolder(@NonNull List<T> items, int position,
     @NonNull RecyclerView.ViewHolder holder, @NonNull List<Object> payloads) {
     
   onBindViewHolder((I) items.get(position), (VH) holder, payloads);
 }

MuliTypeAdapter则干脆的多,在定义ItemViewProvider的抽象方法时就已经考虑了这个问题,解决方案和上述一致,但是写法上看起来更为优雅:

  protected abstract void onBindViewHolder(@NonNull V holder, @NonNull T t);

当然,这样做本质是在内层做好转型再分发,如果要真正意思上的避免转型,可以采用访问者模式(参见:Writing Better Adapter)

关于MuliTypeAdapter的Demo就不做了,其官方上例子已经很详尽。并且,除了之前提到的核心逻辑外,其还提供了全局类型池设计、数据二次分发设计(即没有讨论的FlatTypeAdapter接口),感兴趣的可以继续了解。

上述两个库,都做到了对不同类型Item的分离,每次组装一个列表时,只需要把数据源正确的组装好,adapter内部会通过各自实现的Manager来定位对应的UI来展示。在实际开发中,可能的问题或许是不同Item之间的关联性,比如一个头部类型的带有联动其他Item的交互的话,就需要打破这种独立性(此时需要通过构造函数等方法传入其他对象的实例)。另外,对于常见的头部、列表、脚部的需求来说,实际上在此都是当作三种类型来处理,那么对于服务器回传的列表数据,我们需要自行包裹上头部、脚部的数据类型,这样才能正确的被处理,也是相对麻烦之处。

参考文章:

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

推荐阅读更多精彩内容