RecyclerView 的 ConcatAdapter,不同布局类型的合并实践

RecyclerView 的 ConcatAdapter,不同布局类型的合并实践

RecyclerView 的 ConcatAdapter,不同布局类型的合并实践


RecyclerView 的 ConcatAdapter,不同布局类型的合并实践


RecyclerView 的多类型布局一直是个老话题。从早期在单个 Adapter 里写一堆 getItemViewType() 的 switch 逻辑,到后来尝试用 Epoxy、AdapterDelegates 这类库做拆分,再到 2020 年 Jetpack 推出 ConcatAdapter,Google 给出的方案是:别在一个 Adapter 里硬塞多种类型了,直接把多个 Adapter 拼在一起。


听起来很干净。但真到项目里用,尤其是当拼接的 Adapter 各自有不同的布局类型、不同的数据更新逻辑、不同的空状态处理时,事情没那么顺。这篇文章记录我在一个内容聚合页里的实践, ConcatAdapter 到底解决了什么、没解决什么,以及几个让我调试到凌晨的坑。


项目背景:一个典型的内容聚合页


场景很常见。首页往下拉,依次是:Banner 轮播、快捷入口 Grid、正在直播的横向列表、推荐视频的双列瀑布流。四种布局,四种数据来源,四种交互逻辑。


之前的实现是一个 HomeAdaptergetItemViewType() 返回 0 到 3,onCreateViewHolder 里 switch 创建四种 ViewHolder。数据更新用 DiffUtil,但问题是:Banner 和快捷入口的数据来自配置中心,直播列表来自另一个接口,推荐视频是分页加载。任何一个子模块刷新,都要触发整个 HomeAdapter 的 DiffUtil 计算,虽然 DiffUtil 能算出最小更新,但 List 的比对开销在数据量大时不可忽视。


更烦的是空状态。直播模块可能为空,这时候要隐藏整个模块还是显示占位图?在一个 Adapter 里处理,逻辑越写越脏。


ConcatAdapter 的卖点正好对症:每个模块独立 Adapter,各自管理数据和刷新,ConcatAdapter 只做拼接。2020 年 1.2.0-alpha01 引入,1.2.0 稳定版随 Jetpack 发布,API 已经稳定。


ConcatAdapter 的基础用法和隐藏成本


基础 API 很简单:


val concatAdapter = ConcatAdapter(
    ConcatAdapter.Config.Builder()
        .setIsolateViewTypes(true)  // 默认就是 true
        .build(),
    bannerAdapter,
    quickEntryAdapter,
    liveAdapter,
    feedAdapter
)
recyclerView.adapter = concatAdapter

setIsolateViewTypes(true) 是关键。每个子 Adapter 的 viewType 在 ConcatAdapter 内部会被隔离,即使两个子 Adapter 都返回 viewType 0,ConcatAdapter 也会把它们映射成不同的内部 viewType,避免 ViewHolder 类型冲突。这是 ConcatAdapter 能工作的前提。


但这里有个我踩过的坑:ViewHolder 的复用池(RecycledViewPool)默认是共享的。ConcatAdapter 并没有为每个子 Adapter 创建独立的 pool。如果你的四个子 Adapter 都有各自不同的 ViewHolder 类型,这没问题。但如果其中两个子 Adapter 碰巧创建了相同类型的 ViewHolder(比如都是纯文本布局,但业务逻辑不同),或者你手动在子 Adapter 之间复用 viewType,就会出问题。


我遇到过的情况是:快捷入口 Grid 和推荐瀑布流的"加载更多" footer 用了同一个布局文件 R.layout.item_footer,两个子 Adapter 的 onCreateViewHolder 都 inflate 这个布局。因为 viewType 隔离,ConcatAdapter 给它们分配了不同的内部 viewType,但 RecycledViewPool 是按 viewType 分桶的。如果 footer 的 ViewHolder 被回收到 pool,下次可能被另一个子 Adapter 取出来用。由于布局文件相同,不会崩溃,但业务逻辑不同:一个 footer 有点击重试,一个没有。结果就是点击事件时有时无,调试半天才发现是 ViewHolder 被错配了。


解决方式有两种。一是确保不同子 Adapter 的 ViewHolder 类完全不同,即使布局相同也包装成不同的 ViewHolder 类。二是用 setRecycledViewPool() 为特定子 Adapter 设置独立 pool,但 ConcatAdapter 的 API 并不直接支持按子 Adapter 设置 pool,需要你在子 Adapter 的 onAttachedToRecyclerView 里做文章,或者干脆避免共享布局。


数据刷新: notifyItemRangeChanged 的坐标噩梦


这是 ConcatAdapter 最反直觉的地方。每个子 Adapter 独立调用 notifyXxx() 方法,但 ConcatAdapter 对外暴露的是合并后的统一位置。子 Adapter 内部的位置和 ConcatAdapter 的全局位置是两套坐标系。


比如 liveAdapter 内部有 3 条数据,在 ConcatAdapter 里的全局位置可能是 5、6、7(前面有 bannerAdapter 和 quickEntryAdapter)。如果你在 liveAdapter 里调用 notifyItemChanged(1),ConcatAdapter 会自动转换成全局位置 6 通知 RecyclerView。这没问题。


问题出在当你需要跨 Adapter 操作时。比如直播模块为空,我要把 liveAdapter 从 ConcatAdapter 里移除:


concatAdapter.removeAdapter(liveAdapter)

这没问题,ConcatAdapter 会处理位置偏移。但如果我想保留 liveAdapter 实例,只是清空数据呢?


liveAdapter.submitList(emptyList())  // 内部用 ListAdapter + DiffUtil

这时候 liveAdapter 会计算 DiffUtil 结果,发出 notifyItemRangeRemoved(0, 3)。ConcatAdapter 转换成全局位置,通知 RecyclerView 移除 5、6、7。看起来也没问题。


但接下来如果 feedAdapter 加载更多,调用 submitList(newList),DiffUtil 计算时,feedAdapter 内部的位置是从 0 开始的,它不知道前面少了一个 Adapter。这没问题,因为 DiffUtil 只算内部变化。


真正出问题的是:如果 liveAdapter 清空后,我又想加回一条数据。liveAdapter.submitList(listOf(newItem)),发出 notifyItemRangeInserted(0, 1)。ConcatAdapter 转换成全局位置 5。但如果此时 feedAdapter 也在同时刷新(比如分页加载完成),两个子 Adapter 几乎同时发出通知,RecyclerView 的预布局和动画计算会交错,偶尔出现 Inconsistency detected. Invalid view holder adapter position 的崩溃。


这个崩溃不是必现,但在快速滑动、网络波动导致多个模块同时刷新时,概率不低。StackOverflow 上有不少类似报告,Google Issue Tracker 上也有相关 issue,比如 https://issuetracker.google.com/issues/181677873,描述的是 ConcatAdapter 在子 Adapter 频繁增删时的状态不一致。


我的 workaround 是:对会频繁切换空/非空状态的模块,不用 submitList(emptyList()) 来隐藏,而是改用 concatAdapter.removeAdapter() / addAdapter()。彻底移除 Adapter 比清空数据更干净,ConcatAdapter 的内部位置计算更稳定。代价是移除和添加时有默认的淡入淡出动画,如果不需要,可以:


concatAdapter.removeAdapter(liveAdapter)
// 或者添加时
concatAdapter.addAdapter(2, liveAdapter)

addAdapter 支持指定索引,控制插入位置。


嵌套滑动和横向 RecyclerView 的坑


我的页面里,直播模块是横向滑动的 RecyclerView,嵌套在竖向的主 RecyclerView 里。这个直播模块本身是一个子 Adapter,它的 item 布局里包含一个横向 RecyclerView。


ConcatAdapter 的文档没有特别提及嵌套滑动,但实际测试发现,横向 RecyclerView 的滑动体验和主 RecyclerView 的滑动冲突,比单个 Adapter 实现时更明显。


原因是 ConcatAdapter 的 canScrollVertically()canScrollHorizontally() 委托给各个子 Adapter 的汇总。当手指落在横向直播 item 上,系统要判断是横向滑动还是竖向滑动。如果横向 RecyclerView 的 setNestedScrollingEnabled(true),它会尝试把未消费的滑动事件传给父 RecyclerView。但 ConcatAdapter 作为中间层,对滑动事件的传递和单个 Adapter 有细微差别。


具体表现是:在直播模块上快速斜向滑动(既有横向又有竖向分量),偶尔会出现主 RecyclerView 不响应,横向 RecyclerView 也不流畅的情况。Android 10 以上的滑动嵌套机制(NestedScrollingParent3)本应该处理这个,但 ConcatAdapter 作为 RecyclerView.Adapter,并不直接参与 NestedScrolling 的回调,问题出在 RecyclerView 的 onInterceptTouchEvent 对滑动方向的判断。


我的解决方案是:给横向直播 RecyclerView 设置 setNestedScrollingEnabled(false),完全由它自己消费滑动事件,不向上传递。这样竖向滑动只在手指落在直播模块的 non-scrollable 区域时才被主 RecyclerView 接收。代价是直播模块边缘的竖向滑动响应区域变小,但体验更稳定。


另外,横向 RecyclerView 的 ViewHolder 复用也要注意。如果直播模块有 20 条数据,但一屏只显示 3 个,RecyclerView 默认会创建 3 个 ViewHolder 加几个缓存。但当你快速滑动主 RecyclerView,直播模块划出屏幕再划回来,横向 RecyclerView 的 Adapter 会被重新绑定,内部状态(比如当前滚动位置)会丢失。


我试过在 onViewRecycled 里保存滚动位置,在 onBindViewHolder 里恢复:


override fun onBindViewHolder(holder: LiveViewHolder, position: Int) {
    holder.bind(data[position])
    holder.recyclerView.scrollToPosition(
        scrollStateMap.getOrDefault(data[position].categoryId, 0)
    )
}

override fun onViewRecycled(holder: LiveViewHolder) {
    scrollStateMap[holder.currentCategoryId] = 
        holder.recyclerView.computeHorizontalScrollOffset()
    super.onViewRecycled(holder)
}

computeHorizontalScrollOffset() 返回的是像素偏移,不是 position,恢复时用 scrollBy() 而不是 scrollToPosition()。而且如果布局方向改变(RTL),偏移方向要取反。这部分逻辑写起来很烦,最后我换成了 LinearLayoutManager.findFirstCompletelyVisibleItemPosition() 存 position,恢复时用 scrollToPosition,虽然不够精确,但够用。


分页加载:LoadStateAdapter 的拼接顺序


推荐视频模块是双列瀑布流,用 StaggeredGridLayoutManager。这个模块本身用 Paging 3 的 PagingDataAdapter,需要配一个 LoadStateAdapter 显示加载中和加载失败状态。


Paging 3 的推荐用法是:


val adapter = FeedPagingAdapter()
val concatAdapter = adapter.withLoadStateHeaderAndFooter(
    header = LoadStateAdapter(),
    footer = LoadStateAdapter()
)

withLoadStateHeaderAndFooter 返回的是 ConcatAdapter,不是普通的 Adapter。现在我要把这个分页模块和其他模块再拼一次,变成:


val mainConcat = ConcatAdapter(
    bannerAdapter,
    quickEntryAdapter,
    liveAdapter,
    feedPagingAdapter.withLoadStateHeaderAndFooter(...)  // 这又是一个 ConcatAdapter
)

ConcatAdapter 支持嵌套:子 Adapter 可以是另一个 ConcatAdapter。但这里有个细节:withLoadStateHeaderAndFooter 创建的 ConcatAdapter 是 ConcatAdapter.Config.DEFAULT(isolateViewTypes = true),而我外层 mainConcat 也是 isolate。嵌套两层 isolate 理论上没问题,但 StaggeredGridLayoutManager 的 span lookup 会出问题。


StaggeredGridLayoutManager 设置 span size:


layoutManager.spanSizeLookup = object : StaggeredGridLayoutManager.SpanSizeLookup() {
    override fun getSpanSize(position: Int): Int {
        return when (concatAdapter.getItemViewType(position)) {
            FEED_TYPE -> 1
            else -> 2  // 占满两列
        }
    }
}

getSpanSize 的参数 position 是全局 position。如果 feed 模块有 loadStateHeader,它的全局位置在 feed 数据之前。而这个 header 应该占满两列(显示"正在加载"横跨屏幕)。但 getItemViewType(position) 返回的是 ConcatAdapter 的内部 viewType,我需要判断这个位置是不是属于 feed 模块的 header。


ConcatAdapter 没有直接提供 getAdapterAndPosition() 的公开 API(内部有,但 hide 了)。我只能自己维护一个映射,或者换种方式:不用 withLoadStateHeaderAndFooter,而是手动把 loadState 状态合并到 FeedPagingAdapter 内部,作为一个普通 item type。


但 Paging 3 的 LoadState 回调是在 CombinedLoadStates 里,合并到单个 Adapter 里要写不少胶水代码。我最后的选择是:外层不用 ConcatAdapter 嵌套,而是把 feedPagingAdapter.withLoadStateFooter()(只加 footer,不加 header)作为 mainConcat 的最后一个子 Adapter。footer 的"加载更多"不需要占满两列,它就是 feed item 的宽度,视觉上和 feed 卡片对齐,用 StaggeredGrid 的默认布局即可。


header 则完全去掉,因为 feed 前面已经有 banner、快捷入口、直播三个模块,用户划到 feed 时数据已经加载过了,不需要"正在加载"的 header。空状态用 concatAdapter.removeAdapter(feedAdapter) 来处理,而不是 Paging 的 LoadState.NotLoading 配合 empty view。


动画冲突和 ItemAnimator


ConcatAdapter 的默认行为是:子 Adapter 的数据变化通知到 ConcatAdapter,ConcatAdapter 再通知 RecyclerView,动画由 RecyclerView 的 ItemAnimator 统一处理。


但不同子 Adapter 的动画需求不同。Banner 轮播切换数据时,我希望有淡入淡出;feed 加载更多时,我希望新 item 从底部滑入;模块移除时,我希望整个模块有收缩动画。


RecyclerView 的 DefaultItemAnimator 不支持按 viewType 定制动画。你可以自定义 ItemAnimator,但 animateChange()animateMove()RecyclerView.ViewHolder 参数里,你拿到的是 ConcatAdapter 包装后的 ViewHolder,要判断它来自哪个子 Adapter,需要反射或者提前打 tag。


我试过在 onCreateViewHolder 里给 itemView 设 tag:


override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
    val holder = BannerViewHolder(...)
    holder.itemView.setTag(R.id.adapter_tag, BANNER_ADAPTER_ID)
    return holder
}

自定义 ItemAnimator 里读取这个 tag,决定动画类型。但 ConcatAdapter 的内部实现会在某些情况下创建 wrapper ViewHolder,tag 可能被覆盖。而且 ItemAnimator 的回调时机和 onBind 不完全对应,tag 可能还没打上。


更稳妥的做法是:放弃 ConcatAdapter 级别的统一动画,把动画做到 ViewHolder 内部。比如 Banner 模块数据更新时,在 onBindViewHolder 里手动做属性动画:


fun bind(data: BannerData) {
    imageView.alpha = 0f
    imageView.animate().alpha(1f).duration = 200
    // ...
}

但这和 RecyclerView 的预布局机制有冲突。如果动画开始时 item 还在屏幕外,预布局创建的 ViewHolder 可能看不到动画。而且快速滑动时,属性动画没结束就被回收,下次复用会有残留状态,必须在 onViewRecycled 里取消动画、重置属性。


我最终对动画做了妥协:只有 feed 分页加载用默认的 DefaultItemAnimator(滑入效果),其他模块的更新不做 item 级别动画,只做模块级别的显隐(removeAdapter/addAdapter 的默认淡入淡出)。视觉上够用了,代码也干净。


版本差异和 androidx.recyclerview:recyclerview:1.3.0 的变化


ConcatAdapter 从 1.2.0 到 1.3.0 有个值得注意的变化。1.3.0-alpha01 开始,RecyclerView 引入了 AdapterListUpdateCallback 的优化,ConcatAdapter 内部处理子 Adapter 的 notifyItemRangeChanged 时,payload 的传递更可靠了。


在 1.2.0 版本,我遇到过 payload 丢失的问题。feedAdapter 调用 notifyItemChanged(position, PAYLOAD_LIKE_UPDATE),期望只刷新点赞按钮,但 ConcatAdapter 转发后,RecyclerView 收到的是完整的 notifyItemChanged(position),没有 payload。导致 onBindViewHolderpayloads 参数为空,不得不全量绑定,图片闪烁。


1.3.0 的 release note 里提到:Fixed an issue where ConcatAdapter would not properly forward payloads from child adapters. 就是这个 bug。升级到 1.3.0 后解决。


但 1.3.0 也有新问题。ConcatAdapter.Config 新增了 setStableIdMode,支持 NO_STABLE_IDSISOLATED_STABLE_IDSSHARED_STABLE_IDS 三种模式。如果你的子 Adapter 设置了 setHasStableIds(true),1.3.0 之前 ConcatAdapter 直接抛异常不支持,1.3.0 后支持了,但需要选对 mode。


ISOLATED_STABLE_IDS 是每个子 Adapter 的 stable id 独立,内部会重新映射。SHARED_STABLE_IDS 要求所有子 Adapter 的 stable id 全局唯一,不重新映射。如果选错 mode,RecyclerView 的复用逻辑会乱,表现为数据对但显示错,或者闪烁。


我的项目里 feed 模块用 Paging 3,PagingDataAdapter 默认设置 stable id(基于数据本身的 id)。其他模块没有设置 stable id。这种情况下,ConcatAdapter 的配置用默认 NO_STABLE_IDS,但 PagingDataAdapter 内部需要 stable id 来做 DiffUtil 的 areItemsTheSame。冲突。


解决方式是:外层 ConcatAdapter 用 ISOLATED_STABLE_IDS,PagingDataAdapter 保持 stable id,其他子 Adapter 不设置 stable id。这样 Paging 的复用能工作,其他模块也不受影响。


ConcatAdapter(
    ConcatAdapter.Config.Builder()
        .setStableIdMode(ConcatAdapter.Config.StableIdMode.ISOLATED_STABLE_IDS)
        .build(),
    bannerAdapter,  // hasStableIds = false
    quickEntryAdapter,  // hasStableIds = false
    liveAdapter,  // hasStableIds = false
    feedPagingAdapter  // hasStableIds = true, 内部 Paging 需要
)

但注意,一旦开启 ISOLATED_STABLE_IDS,所有子 Adapter 的 getItemId() 返回值会被 ConcatAdapter 重新编码。如果你在代码里直接拿 holder.itemId 做业务判断(比如埋点),拿到的是编码后的 id,不是原始 id。需要通过 concatAdapter.getWrappedAdapterAndPosition() 反查,但这个 API 不是 public 的。


性能实测:ConcatAdapter vs 单 Adapter


最后说点数据。我在一个中等复杂度的页面做了对比测试,设备是 Pixel 4a,Android 13。


单 Adapter 方案:一个 HomeAdapter,内部 4 种 viewType,数据合并成一个 List,DiffUtil 计算整个列表。


ConcatAdapter 方案:4 个独立 Adapter,各自 DiffUtil。


测试场景:冷启动后加载首页,然后模拟直播模块从有到无(服务器返回空),再恢复。


单 Adapter 的 DiffUtil 计算时间(DiffUtil.calculateDiff 的耗时):数据量 50 条时,平均 12ms;直播模块切换空状态时,全量 Diff 18ms。


ConcatAdapter 方案:各模块独立 Diff,feed 模块 40 条数据,Diff 8ms;直播模块 3 条数据,清空时 Diff 0.5ms。但 ConcatAdapter 的位置转换和多次 notifyXxx 调用,导致 RecyclerView 的预布局阶段总耗时比单 Adapter 多 3-5ms。


实际用户体验上,两者没有明显差距。ConcatAdapter 的优势不在首屏性能,而在代码组织:每个模块的 Adapter 可以独立测试、独立复用。比如 FeedPagingAdapter 直接在搜索结果页复用,不用改代码。


但内存占用上,ConcatAdapter 略高。每个子 Adapter 有自己的 AdapterDataObserver,ConcatAdapter 内部有 NestedAdapterWrapper 数组,还有 viewType 的映射表。单个页面多几个对象,影响不大,但如果在一个页面里拼十几个 Adapter,要注意。


一个未解决的边缘情况:StaggeredGrid 的头部对齐


回到我的页面结构。主 RecyclerView 用 StaggeredGridLayoutManager,spanCount = 2。Banner、快捷入口、直播都是单列占满,feed 是双列。


StaggeredGridLayoutManager.SpanSizeLookup 在 ConcatAdapter 场景下,需要判断全局 position 对应的 span size。但前面说过,ConcatAdapter 没有公开 API 把全局 position 映射回子 Adapter。


我的 workaround 很脏:在 onAttachedToRecyclerView 时,记录每个子 Adapter 在 ConcatAdapter 里的起始位置,然后维护一个 position -> spanSize 的映射表,每次子 Adapter 增删时更新。


private val spanSizeLookup = object : StaggeredGridLayoutManager.SpanSizeLookup() {
    override fun getSpanSize(position: Int): Int {
        return when {
            position < bannerCount -> 2
            position < bannerCount + quickEntryCount -> 2
            // ...
            else -> 1
        }
    }
}

bannerCount 这些变量要在子 Adapter 数据变化时同步更新。如果子 Adapter 是 PagingDataAdapter,数据变化是异步的,用 adapter.snapshot().items.size 取数据量,但 snapshot 的更新时机和 notifyDataSetChanged 不完全同步,偶尔会出现 spanSizeLookup 拿到旧数据,导致布局错乱(比如该占两列的只占了一列,右边留白)。


这个问题我没有完美解决。目前的妥协是:非 feed 模块的数据量固定或变化不频繁(banner 通常 1-5 张,快捷入口固定 8 个),变化时手动调用 layoutManager.invalidateSpanAssignments() 强制刷新。feed 模块放在最后,不需要查 spanSizeLookup,默认就是 1。


如果 Google 能在 ConcatAdapter 里公开 getItemViewType(position) 之外的子 Adapter 查询 API,或者让 SpanSizeLookup 支持按 viewType 而不是 position 判断,这类问题会好处理很多。现在只能自己维护映射,或者放弃 StaggeredGrid,改用 LinearLayoutManager 加嵌套 RecyclerView,但那又有滑动嵌套的问题。


代码组织的真实收益


说点非技术的。用了 ConcatAdapter 后,首页的代码结构变成:


  • BannerAdapter.kt:独立文件,100 行
  • QuickEntryAdapter.kt:独立文件,80 行
  • LiveAdapter.kt:独立文件,120 行,含横向 RecyclerView 的嵌套逻辑
  • FeedPagingAdapter.kt:搜索页直接复用
  • HomeFragment.kt:只负责拼接,200 行

  • 之前单 Adapter 的 HomeAdapter.kt 有 400 多行,加上 ViewHolder 内联,接近 600 行。DiffUtil 的 areItemsTheSame / areContentsTheSame 里写满 when (item) 判断类型,新增一种布局要改三四个地方。


    现在新增一个模块,比如中间插个广告位,只需要:


    concatAdapter.addAdapter(2, AdAdapter())

    不用改现有任何 Adapter。AdAdapter 的开发和测试完全独立,甚至可以让另一个同事并行开发,最后拼进来就行。


    这种模块化的收益,在大型项目里比性能优化更有价值。尤其是首页这种频繁迭代、AB 实验多的页面,模块的插拔能力很重要。


    最终取舍和未采用的方案


    过程中我也考虑过其他方案。Epoxy 是 Airbnb 的库,用 DSL 描述多类型布局,自动生成 DiffUtil。功能很强,但引入一套完整的代码生成和模型系统,学习成本高,而且 Epoxy 的 EpoxyController 和 Paging 3 的集成需要额外适配层,社区维护不算活跃。


    AdapterDelegates 是另一个选择,用委托模式拆分多类型逻辑。它还是在单个 Adapter 里工作,只是用链式委托替代 switch-case。没有解决不同模块独立刷新、独立空状态的问题。


    Compose 的 LazyColumn 是长远方向,但项目里 RecyclerView 的迁移成本太高,而且 Compose 的嵌套滑动、Paging 集成在 2023 年的版本里还有些边缘问题,不是现在能全面切换的。


    ConcatAdapter 是官方方案,无额外依赖,和 Paging、DiffUtil、ListAdapter 都是原生集成。虽然有小坑,但方向是对的。我的建议是:如果你的页面有 3 个以上明显独立的模块,每个模块有自己的数据流和刷新逻辑,ConcatAdapter 值得用。如果只有两种布局类型,数据同源,单 Adapter 加 viewType 更简单。


    一个具体的版本升级建议


    如果你现在要用 ConcatAdapter,直接用 androidx.recyclerview:recyclerview:1.3.2(当前最新稳定版)。1.2.0 的 payload 转发 bug 必须避开。如果项目还在 1.2.0,升级时注意 ConcatAdapter.Config 的 stable id 模式,检查各子 Adapter 的 hasStableIds() 设置。


    另外,ConcatAdaptergetItemCount() 在 1.3.0 之前是 O(N) 遍历所有子 Adapter,1.3.0 后做了缓存优化。如果你的页面有非常多子 Adapter(比如 20 个),这个优化有意义。一般 4-5 个模块,感知不强。


    RecyclerView 的 setItemAnimator(null) 在 ConcatAdapter 场景下,可以消除所有动画,减少一些状态不一致的崩溃风险。如果业务不要求 item 动画,直接关掉是最稳的。


    代码里所有 concatAdapter.addAdapter()removeAdapter() 操作,尽量放在主线程的同一次消息处理里,避免中间状态被 RecyclerView 的预布局捕获。如果必须在异步回调里操作(比如网络返回后移除空模块),用 View.post { } 或者 lifecycleScope.launch(Dispatchers.Main) 确保串行。


    这些细节官方文档不会写,都是 issue tracker 和实际踩坑攒出来的。

    MockK 和 Mockito 的对比,Kotlin 项目怎么选 2026-06-16

    评论区