RecyclerView老树开新花——ConcatAdapter

2,829 阅读10分钟

MergeAdapterConceptDetail.webp 盗了一张图如上

背景

组里大佬曾经说过,RecyclerView已经是老掉牙的东西了。在我听来有2层意思

  • RecyclerView已经不是新鲜的技术了
  • 必须要熟练掌握RecyclerView的使用和原理

平时我也是做产品需求居多,可能达不到tony老师他们那样的高度,那么如何在完成产品需求的同时,推陈出新,将产品需求做到一个新的高度。

首先我们要坚信,对于产品需求,我们一样也可以做的有技术含量。那么同样实现产品功能最能体现我们技术含量的地方就是我们的实现方案,我觉得至少要保证以下两点

  1. 代码可读性
  2. 不给后来人挖坑

人家说前人栽树,后人乘凉,然而对于我们RD而言,很多时候是前人挖坑,后人趟雷,甚至有些需求的大部分时间都在填坑

多数据源的列表

分享一个控件的使用或者原理往往是枯燥的,你说ContactAdapter多么好,那么它能为我们解决什么问题,带来那些收益呢,首先我从我们熟悉的业务场景入手

列表是App中非常常见的场景,对于普通的列表,我们一个adapter就可以搞定,那么一些复杂的业务呢,我们看首页和个人中心

image.png

看首页里面,上面icon部分和下面瀑布流部分,实际上是2个不同的接口返回的数据,我们用一个adapter可以搞定吗,也可以,哈哈。但是会存在一些问题

  1. 强行拼凑数据,通常我们会封装一个数据bean的基类,那么不同的数据源,bean是完全不一样的
  2. itemType会非常多
  3. 可能要考虑接口返回的先后顺序来拼接数据

那么有没有办法来避免这种情况呢?之前是没有办法的,但是我们升级了jetpack库后就有了新的选择

2021年4月7日Android团队正式发布了RecyclerView 1.2.0版本,增加了ConcatAdapter,这个Adapter方便地让我们在一个RecyclerView中连接多个Adapters 用法也很简单

//topAdapter---icon区域
//staggeredAdapter--瀑布流区域
recyleview.adapter = ConcatAdapter(topAdapter, staggeredAdapter)

可以合并不同的Adapter,管理各自的数据源

这里是体现了面向对象编程中非常经典的一条设计原则

组合优于继承,多用组合少用继承

不同场景下的模板复用

yy:新首页上线后,发现瀑布流效果不错,PM提了一个新需求,要把首页的瀑布流模板迁移到个人中心,表面看来so easy,不就是模板copy一下吗,未经他人苦,莫劝他人善

copy一下有什么问题呢

  • 首页和个人中心的列表实现,完全是两套框架,ViewHolder和BaseBean没法直接套进去
  • 如果模板后续要改动的话,需要同时修改2份代码,可维护性差

这个时候ConcatAdapter又出场了

userAdapter--个人中心Adapter
homeAdapter--首页瀑布流Adapter
recyleview.adapter = ConcatAdapter(userAdapter, homeAdapter)

当然adapter能复用的前提是数据源要保持一致

其它适用场景

header和footer可以单独写一个adapter实现,ConcatAdapter支持动态增删adapter,这里不再展开

另外,ConcatAdapter还有一些其他用法,本文没有全部涉及,有兴趣的同学可以自主学习

ConcatAdapter概述

ConcatAdapter能实现单一职责,每个adapter负责自身任务,头、中、尾视图都有自己的adapter去负责管理对应的ViewHolder,扩展的同时也进行了解耦,解决以前一个RecyclerView只能对应一个Adapter的尴尬场景

ConcatAdapter是如何运作的

image.png

贴代码容易让读的人烦躁,我也一样,一看到有大段代码的文章,就想快速跳过,那么为什么我还要贴代码呢

  • ConcatAdapter代码比较少
  • 我只取关键代码,更详细的部分,大家可以自己查阅

说一句题外话,我们看源码的时候要遵循的原则:抽丝剥茧、点到即止

理清我们的主脉络,千万不要陷进去

ConcatAdappter的内部实现主要分为3层,分别如下:

1、ConcatAdapter层:ConcatAdappter实现了RecyclerView#Adapter的很多方法,它主要是面对于RecyclerView。

2、ConcatAdapterController层:ConcatAdapterController是ConcatAdapter的代理类,它接管了ConcatAdapter的很多方法,包括核心的onCreateViewHolder、onBindViewHolder和getItemCount等方法,所以各种处理逻辑是在ConcatAdapterController里面进行的。

3、Helper层:ConcatAdapterController里面用到的Helper类都应该属于Helper层,其中极具代表性的是两个类:ViewTypeStorage主要是用于处理ViewType相关的逻辑;StableIdStorage主要是用于处理stableId相关的逻辑。在这一层的类,主要的作用帮助ConcatAdapterController处理相关逻辑。

好了不废话了,先看构造方法,这只是其中一个,大部分都是重载的

public ConcatAdapter(
        @NonNull Config config,
        @NonNull List<? extends Adapter<? extends ViewHolder>> adapters) {
    mController = new ConcatAdapterController(this, config);
    for (Adapter<? extends ViewHolder> adapter : adapters) {
        //看这里,最终会调用addAdapter
        addAdapter(adapter);
    }
}

可以看到addAdapter很简单,就是调用mController来操作,有add就有remove,removeAdapter跟addAdapter如出一辙

public boolean addAdapter(@NonNull Adapter<? extends ViewHolder> adapter) {
    return mController.addAdapter((Adapter<ViewHolder>) adapter);
}

public boolean removeAdapter(@NonNull Adapter<? extends ViewHolder> adapter) {
    return mController.removeAdapter((Adapter<ViewHolder>) adapter);
}

ConcatAdapter使用了代理模式,把所有的逻辑操作放在在mController里面完成,所以我们着重看mController(ConcatAdapterController)就行了

ConcatAdapterController是什么

是ConcatAdapter的代理类,它接管了ConcatAdapter的很多方法,包括核心的onCreateViewHolder、onBindViewHolder和getItemCount等方法, ConcatAdapterController是操作多个Adapter聚合到一个RecyclerView的控制类。

主要包含

  • 存储多个Adapter的数据结构(NestedAdapterWrapper)
  • 查找不同Adapter的位置的数据结构(ViewTypeStorage)

继续我们的源码之旅

boolean addAdapter(int index, Adapter<ViewHolder> adapter) {
    
 //查找NestedAdapterWrapper,虽然传递进来的是Adapter,
 //但实际上会封装成NestedAdapterWrapper来进行核心逻辑交互
    NestedAdapterWrapper existing = findWrapperFor(adapter);
    if (existing != null) {
        return false;
    }
    NestedAdapterWrapper wrapper = new NestedAdapterWrapper(adapter, this,
            mViewTypeStorage, mStableIdStorage.createStableIdLookup());
    mWrappers.add(index, wrapper);
    // notify attach for all recyclerview
    //传递来的新的adapter要重新attach上已经和ConcatAdapter建立好连接的RecyclerView上
    for (WeakReference<RecyclerView> reference : mAttachedRecyclerViews) {
        RecyclerView recyclerView = reference.get();
        if (recyclerView != null) {
            adapter.onAttachedToRecyclerView(recyclerView);
        }
    }
    return true;
}

每个通过新添加的adapter最后都会被包装成一个NestedAdapterWrapper。NestedAdapterWrapper可以说是整个ConcatAdapter的核心

监听不同Adapter数据层变化

在实例化的时候,创建RecyclerView.AdapterDataObserver,给当前的adapter注册一个数据变化的回调,因为创建Wrapper后会把adapter attach到RecyclerView上,所以回调会在RecyclerView数据源发送变化时候回调相应接口,然后接口会通过callBack回调回到Controller去处理

NestedAdapterWrapper(
        Adapter<ViewHolder> adapter,
        final Callback callback,
        ViewTypeStorage viewTypeStorage,
        StableIdStorage.StableIdLookup stableIdLookup) {
    this.adapter = adapter;
    //Controller中的callback
    mCallback = callback;
    mViewTypeLookup = viewTypeStorage.createViewTypeWrapper(this);
    mStableIdLookup = stableIdLookup;
    mCachedItemCount = this.adapter.getItemCount();
    //给当前的adapter注册一个数据变化的回调
    this.adapter.registerAdapterDataObserver(mAdapterObserver);
}

ConcatAdapterController里面去查看某个adapter数据层面变化后,如何同步到整体的

//发送数据变化的adapter通过callBack回调回来的
@Override
    public void onItemRangeInserted(@NonNull NestedAdapterWrapper nestedAdapterWrapper,
            int positionStart, int itemCount) {
        //从Wrapper
        final int offset = countItemsBefore(nestedAdapterWrapper);
        mConcatAdapter.notifyItemRangeInserted(
                positionStart + offset,
                itemCount
        );
    }
//从当前Controller存储的Wrapper中查找当前发送数据变化的Wrapper(wrapper即adapter,只不过二次封装下)
//找到当前变化的adapter并且返回此adapter之前的holder个数
private int countItemsBefore(NestedAdapterWrapper wrapper) {
        int count = 0;
        for (NestedAdapterWrapper item : mWrappers) {
            if (item != wrapper) {
                count += item.getCachedItemCount();
            } else {
                break;
            }
        }
        return count;
    }

通过countItemsBefore函数获取发送变化的adapter之前有多少个ViewHolder,这个获取ViewHolder数量正是上面提过的mCachedItemCount,每个Wrapper都可以通过接口返回mCachedItemCount数值。

核心思想:

  • 总列表index = 当前变化adapter开始变化的index+获取排在adapter前面ViewHolder的数量
  • 调用adapter接口,告知RecyclerView刷新哪部分区域的视图。

ViewType的处理策略

如何从多个Adapter中返回各自ViewType

  • 通过全局的position去算出对应子Adapter的position,
  • 获取到对应的adapter和position后,通过不同策略生成与子Adapter的本地ViewType唯一映射关系的全局itemViewType,然后返回给RecyclerView。
 //globalPosition就是对于RecyclerView来说整体的位置
public int getItemViewType(int globalPosition) {
        //拿到了全局position对应的adapter信息
        WrapperAndLocalPosition wrapperAndPos = findWrapperAndLocalPosition(globalPosition);
        //根据子Adapter的viewType,递增生成唯一的globalType并且存储着对应的映射关系
        int itemViewType = wrapperAndPos.mWrapper.getItemViewType(wrapperAndPos.mLocalPosition);
        return itemViewType;
    }

隔离策略

多个子Adapter中元素的viewType相同,ConcatAdapter会将它们分隔成不同的viewType

共享策略

多个子Adapter中元素的viewType相同,那么它们将共用一个缓存池

public ConcatAdapter(Adapter<? extends ViewHolder>... adapters) {
    this(Config.DEFAULT, adapters);
}

public ConcatAdapter(Config config, Adapter<? extends ViewHolder>... adapters) {
    this(config, Arrays.asList(adapters));
}

Config(boolean isolateViewTypes, StableIdMode stableIdMode) {
    this.isolateViewTypes = isolateViewTypes;
    this.stableIdMode = stableIdMode;
}
public static final Config DEFAULT = new Config(true, NO_STABLE_IDS);

默认的Config,采用的是隔离策略,Config类的设计采用了策略者模式,这里源码就不再展开了,大家有兴趣可以自己查阅

踩坑记录

如何获取ViewHolder的position

通常来讲我们使用getAdapterPosition()获取viewholder在列表中的位置,用于埋点或者其他一些操作,但是现在getAdapterPosition()你会发现已经标记过时了,这是因为引入了ConcatAdapter后,这个方法已经产生了歧义。Google提供了2个新的方法

  1. getBindingAdapterPosition()——获取当前绑定Adapter中的位置
  2. getAbsoluteAdapterPosition()——获取总列表中的绝对位置

数据更新方式

  • 使用子Adapter管理各自的数据源
  • 子Adapter避免使用notifyDataSetChanged,因为会造成整个列表刷新
@Override
public void onChanged(@NonNull NestedAdapterWrapper wrapper) {
    // TODO should we notify more cleverly, maybe in v2
    //子Adapter使用notifyDataSetChanged时,mConcatAdapter会整个刷新
    mConcatAdapter.notifyDataSetChanged();
    calculateAndUpdateStateRestorationPolicy();
}

插入数据时使用notifyItemRangeChanged更新无效

比如上拉加载插入数据,我们之前可以用notifyItemRangeChanged或者 notifyItemRangeInserted来更新列表,但是ConcatAdapter使用notifyItemRangeChanged无法更新列表,当时发现这问题非常诡异,怎么办呢,看源码 调用notifyItemRangeChanged之后会执行onLayout,我这里只摘取关键步骤

dispatchLayout——dispatchLayoutStep1——tryGetViewHolderForPositionByDeadline

获取item数量

private void dispatchLayoutStep1() { 
·····
mState.mItemCount=mAdapter.getItemCount(); 
·····
}

最终都会在这个方法里获取viewholder

ViewHolder tryGetViewHolderForPositionByDeadline(){
    根据mState.mItemCount创建viewholder
}

通过不断的debug发现mAdapter.getItemCount()居然没有变,而这个mAdapter是谁呢,没错,就是ConcatAdapter,那么ConcatAdapter如何计算item数量呢,往下看

public int getTotalCount() {
    // should we cache this as well ?
    int total = 0;
    for (NestedAdapterWrapper wrapper : mWrappers) {
        //叠加所有adapter的item数量,关键就在getCachedItemCount
        total += wrapper.getCachedItemCount();
    }
    return total;
}
//OK,继续往里进
int getCachedItemCount() {
    return mCachedItemCount;
}

那么mCachedItemCount什么时候发生变化呢

private RecyclerView.AdapterDataObserver mAdapterObserver =
        new RecyclerView.AdapterDataObserver() {
            @Override
            public void onChanged() {
                //获取初始值
                mCachedItemCount = adapter.getItemCount();
                mCallback.onChanged(NestedAdapterWrapper.this);
            }

            @Override
            public void onItemRangeChanged(int positionStart, int itemCount) {
                //无任何变化,原因所在
                mCallback.onItemRangeChanged(
                        NestedAdapterWrapper.this,
                        positionStart,
                        itemCount,
                        null
                );
            }

            @Override
            public void onItemRangeChanged(int positionStart, int itemCount, @Nullable Object payload) {
              //无任何变化,原因所在
                mCallback.onItemRangeChanged(
                        NestedAdapterWrapper.this,
                        positionStart,
                        itemCount,
                        payload
                );
            }

            @Override
            public void onItemRangeInserted(int positionStart, int itemCount) {    
                //插入时增加
                mCachedItemCount += itemCount;
                ······
                }
            }

            @Override
            public void onItemRangeRemoved(int positionStart, int itemCount) {
                //删除时减少
                mCachedItemCount -= itemCount;
                ·······
                }
            }
}

ConcatAdapter在onItemRangeChanged时getTotalCount的值是保持不变的,导致onLayout时新数据无法绘制到页面上

其实这个问题也不能把他定为bug,人家Google的设计思想就是

change就是change,insert就是insert,大家懂这个意思吧

总结

使用场景

  • 多数据源列表(最适合)
  • 列表头、中、尾视图拆分成独立的adapter,视需求而定

优势

  • ConcatAdapter的优势是Adapter可重用性高,更专注在业务上,不必考虑各种不同ItemType的场景,耦合度低
  • 动态的删除、添加Adapter
  • 大胆预测,ConcatAdapter之后肯定会成为主流列表开发方式,因为它可以把列表拆分成多个模块,这样无论对后期维护还是开发都是更为方便的

参考资料

mp.weixin.qq.com/s/QTaz45aLu…

juejin.cn/post/696275…

www.jianshu.com/p/61b2f3b44…

developersbreach.com/merge-multi…