动手之前先说实话RecyclerView 这个东西新手看着头大老手也偶尔翻车。我最近接了个小需求要把一个列表改造成带分组头、可展开收起、还要支持滑删的结构本来估摸着怎么也得折腾一整天结果从动手到跑通真就只花了一个小时。这不算什么天才操作纯粹是因为踩过的坑够多脑子里有一套固定的排查顺序和实现套路。这篇文章就把这一个小时里做的事情完整拆开把每一步为什么这么干、干了什么、遇到什么问题怎么解决的都写出来给正在被 RecyclerView 折磨的朋友一个可以直接抄的作业。先交代一下背景避免后面看得云里雾里。需求是这样的App 里有个订单列表原来就是平铺的 LinearLayout数据量一上来明显卡顿而且产品想要按日期分组展示每组可以展开收起还要支持左滑删除单条记录。这种需求在 Android 里几乎就是给 RecyclerView 量身定做的。我这次没有引入任何第三方库全部用官方组件加少量自定义代码搞定好处是依赖少、后续维护不慌坏处是有些交互得自己实现但恰恰是这些“自己实现”的部分最能体现对 RecyclerView 的理解深度。1. 内容整体设计与思路拆解1.1 为什么这个需求用 RecyclerView 最合适先聊为什么不用 ScrollView 嵌套 LinearLayout。很多新手写列表第一反应就是 ScrollView 套 LinearLayout数据量少确实没问题但一旦超过一屏甚至几百条问题就全来了所有 item 一次性全部创建内存暴涨没有 ViewHolder 复用机制滑动时 GC 频繁没有局部刷新能力改一条数据要 notifyDataSetChanged整页重绘。RecyclerView 的核心机制就是搞定了这三件事ViewHolder 复用、布局管理器控制显示范围、DiffUtil 局部刷新。这次需求还有分组和展开收起理论上不用 RecyclerView 也能写但会非常痛苦。分组头、数据项、展开状态这些本质上是不同类型的 view 在同一个列表里混排。RecyclerView 用getItemViewType天然支持多类型布局再配合Payload做局部刷新展开收起时只需要刷新当前组不需要整页 notify性能差距是肉眼可见的。另外滑动删除是 RecyclerView 自带支持的能力通过ItemTouchHelper就能实现官方库没额外引入任何东西。要是自己写手势处理光是滑动冲突就够喝一壶的。1.2 整体架构先拆出三层拿到需求不要直接开写先在心里把结构拆清楚。我习惯分成三层数据层定义列表模型包括分组信息和数据项。分组头和普通数据项可以在同一个列表中用不同类型的对象来表示但更好的是用两个列表groupList存分组信息itemMap存每个分组下的数据。界面上展示时再合并成一个“视图数据列表”。这样做的好处是折叠操作只需要维护分组状态不需要频繁重排数据。界面层一个 RecyclerView 加一个ListAdapter或者RecyclerView.Adapter。我选 ListAdapter因为内置了AsyncListDiffer配合DiffUtil局部刷新非常舒服。后面会讲为什么不用传统的 Adapter。交互层ItemTouchHelper处理滑动删除分组头的点击事件由 Adapter 内部处理避免在 Activity 里写一堆 findViewById。这三层分开之后问题定位会非常快。比如删除一条数据后列表没有刷新那要么是数据层没删掉要么是 Adapter 的 submitList 没被调用排查范围一下就缩小了。1.3 关键方案选型ListAdapter DiffUtil 为什么是首选很多老项目还在用notifyDataSetChanged()确实也能跑但数据量一多就会出现两个现象一是刷新时列表闪烁因为全部 item 重建了二是滑动明显掉帧因为 DiffUtil 的工作必须在主线程完成。ListAdapter 的submitList是异步做 diff 计算的内部会把旧列表和新列表对比只更新变化的 item并且动画效果非常自然。有人会担心 AsyncListDiffer 的 diff 计算影响性能。实际 diff 是跑在后台线程的对于几百条数据根本感觉不到。除非你有几千条数据还在每个 item 里塞大图那另说。另外 ListAdapter 要求重写areItemsTheSame和areContentsTheSame两个方法前者判断是不是同一条数据一般用 id后者判断内容有没有变化。这两个方法写好了列表就能精准刷新。这次展开收起操作我就是在groupList里记录每个分组的展开状态展开时把该组的数据项合并进视图列表收起时移除它们。这样 submitList 时 DiffUtil 会检测到变化从而精准刷新那一组其它组纹丝不动。2. 核心细节解析与实操要点2.1 数据模型设计分组状态如何与列表数据解耦数据模型设计得好不好直接决定后面写着痛不痛苦。我这次用了三个类OrderGroup分组信息包含 groupId、groupName、日期、展开状态isExpanded。OrderItem订单数据包含 id、金额、状态等。ListItem一个通用包装类用枚举区分类型TYPE_GROUP和TYPE_ITEM。里面持有OrderGroup或OrderItem。为什么用ListItem这种包装类因为 RecyclerView 的 adapter 最终面对的是一个ListListItem这个列表决定了屏幕上显示的所有内容。展开时把一组数据展平到这个列表中收起时把它们剔除。分组状态放在OrderGroup里而不是放在包装类里是因为 DiffUtil 比对时需要区分组和项如果都包装成 ListItem那就用ListItem.getType()来判断。这里有一个容易被忽略的坑展开收起后旧的 ListItem 列表和新列表的 diff 不能只比较内容是否相同。因为展开时组下面的数据项全都在新列表里旧列表里没有DiffUtil 会识别为“新增”这没问题收起时数据项从新列表消失了DiffUtil 会识别为“删除”这也没问题。但如果你在同一组数据里直接改某个 item 的内容你得在areContentsTheSame里判断具体字段。这个部分写清楚后面刷新才稳。2.2 getItemViewType 与多布局绑定的细节多类型列表最烦的是 ViewHolder 管理容易乱。我建议一个类对应一种布局一个 ViewHolder 对应一种类型不要攥在一个大 ViewHolder 里 switch 来 switch 去。这次就两种类型一个组头 ViewHolder一个订单 ViewHolder干净利落。getItemViewType的典型写法override fun getItemViewType(position: Int): Int { return when (list[position].type) { ListItem.TYPE_GROUP - TYPE_GROUP else - TYPE_ITEM } }注意 position 参数是从 0 开始的绝对位置。ListAdapter 内部处理 diff 时position 跟旧列表和新列表的位置对标不要自己在 getItemViewType 里做偏移。之前见过有人在这里写position - 1之类的东西基本都会出越界。onCreateViewHolder里根据 viewType 返回对应的 ViewHolder。布局文件用两个简单的 XML一个组头布局比如左边一个日期 TextView右边一个展开箭头一个订单 item 布局订单号、金额、状态。2.3 展开收起动画用 notifyItemChanged 结合 payload 实现精准刷新展开收起的动画有两种玩法一种是用notifyItemChanged(position, payload)只更新组头箭头方向然后用notifyItemRangeInserted插入数据项另一种是重新构建列表提交给 submitList让 DiffUtil 自动处理。我这次用第二种因为 ListAdapter 的 submitList 本身会做 diff插入和删除都会自动带上默认动画非常省事。但展开箭头旋转的动画怎么办如果组头在 diff 中被判定为“内容相同”就不会刷新箭头就不会转。所以必须在areContentsTheSame里把isExpanded字段也纳入比较。具体写法是override fun areContentsTheSame(oldItem: ListItem, newItem: ListItem): Boolean { if (oldItem.type ! newItem.type) return false return when (oldItem.type) { ListItem.TYPE_GROUP - { val oldGroup oldItem.group!! val newGroup newItem.group!! oldGroup.groupId newGroup.groupId oldGroup.name newGroup.name oldGroup.isExpanded newGroup.isExpanded } else - { val oldOrder oldItem.order!! val newOrder newItem.order!! oldOrder.id newOrder.id oldOrder.amount newOrder.amount oldOrder.status newOrder.status } } }这里注意一个细节isExpanded变化了diff 会判断 contents 不同于是onBindViewHolder会重新执行这样我们就有机会更新箭头方向。同时diff 还会计算出该组下数据项的新增或删除并配上动画。这样一个操作既更新了箭头又插入了数据完美。如果不做这个判断你会发现点击组头后数据出来了但箭头没动要手动调用notifyItemChanged(position)才行。而且那个手动调用的位置如果你拿到的 position 是点击之前的旧位置展开后数据项插进来position 就错了极易导致刷新错位。这是新手最容易踩的坑没有之一。2.4 滑动删除的完整接入与边界处理滑动删除用ItemTouchHelper非常简单。先写一个ItemTouchHelper.Callbackclass SwipeDeleteCallback( private val onSwiped: (position: Int) - Unit ) : ItemTouchHelper.Callback() { override fun getMovementFlags( recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder ): Int { val swipeFlags ItemTouchHelper.START or ItemTouchHelper.END return makeMovementFlags(0, swipeFlags) } override fun onMove(...): Boolean false override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) { onSwiped(viewHolder.bindingAdapterPosition) } }然后在 Activity/Fragment 里ItemTouchHelper(SwipeDeleteCallback { position - viewModel.deleteItem(position) }).attachToRecyclerView(binding.recyclerView)这里有几个坑必须讲。第一不要在 onSwiped 里直接调用 adapter 的 getItem(position)因为此时列表可能还没变化但更安全的做法是用viewHolder.bindingAdapterPosition不要用adapterPosition后者在嵌套滚动时可能会有偏移。第二删除后一定要在数据层移除对应数据再 submitList 新列表。如果只是调用 adapter 的 notifyItemRemoved但数据源没变下次任意一次刷新都会把已删除的数据加回来。第三分组头不能被滑动删除。可以在getMovementFlags里根据viewHolder.itemViewType判断如果是组头类型就返回 0override fun getMovementFlags(...): Int { if (viewHolder.itemViewType TYPE_GROUP) return 0 ... }这样组头就不会滑出删除按钮了。第四删除动画结束后再更新数据。很多人直接viewModel.deleteItem(position)后立即 submitList这时列表已经在 diff 计算中可能会导致动画错乱。正确姿势是在 onSwiped 里先获取要删除的数据 id从数据源删除然后调用submitList(newList)。ListAdapter 默认会处理删除动画而且我们不需要手动调用notifyItemRemoved。这样才能保证动画顺滑且不会闪屏。3. 实操过程与核心环节实现3.1 环境准备与快速搭建本次开发环境很简单Android Studio 最新稳定版KotlinminSdk 24targetSdk 34没有任何第三方依赖全部使用官方 androidx 库。新建项目时选择 Empty Views Activity然后在build.gradle.kts里添加依赖implementation(androidx.recyclerview:recyclerview:1.3.2) implementation(androidx.activity:activity-ktx:1.9.0)RecyclerView 在较新版本中不需要额外引入其它库ListAdapter 和 DiffUtil 都在androidx.recyclerview.widget包下。布局文件就是一个简单的 CoordinatorLayout 加一个 RecyclerView。RecyclerView 设置layoutManager为LinearLayoutManager因为我们要做垂直列表并且开启优化binding.recyclerView.layoutManager LinearLayoutManager(this) binding.recyclerView.setHasFixedSize(true)setHasFixedSize(true)意味着 RecyclerView 的大小不会因为 item 变化而改变这样可以跳过一些不必要的测量计算。如果列表里 item 高度变化明显建议不要开或者根据场景选择。这个列表 item 高度固定所以我开了。3.2 Adapter 的完整实现ListAdapter 模板Adapter 继承ListAdapterListItem, RecyclerView.ViewHolder。class OrderAdapter : ListAdapterListItem, RecyclerView.ViewHolder(DiffCallback) { companion object { private val DiffCallback object : DiffUtil.ItemCallbackListItem() { override fun areItemsTheSame(oldItem: ListItem, newItem: ListItem): Boolean { if (oldItem.type ! newItem.type) return false return when (oldItem.type) { ListItem.TYPE_GROUP - oldItem.group?.groupId newItem.group?.groupId else - oldItem.order?.id newItem.order?.id } } override fun areContentsTheSame(oldItem: ListItem, newItem: ListItem): Boolean { // 见上文 2.3这里略 } } } override fun getItemViewType(position: Int): Int { return getItem(position).type } override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { return when (viewType) { ListItem.TYPE_GROUP - GroupViewHolder(...) else - OrderViewHolder(...) } } override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { when (holder) { is GroupViewHolder - holder.bind(getItem(position).group!!) is OrderViewHolder - holder.bind(getItem(position).order!!) } } }这里有个很微妙的点getItem(position)是 ListAdapter 内部根据当前提交的列表返回的它跟 diff 的计算结果保持一致。你不需要自己维护list字段。以前写 RecyclerView.Adapter 时喜欢维护一个var items: ListX然后手动 notify现在完全没必要。这减少了数据不同步的问题。组头 ViewHolder 中点击事件为什么在 Adapter 里处理因为这个点击事件需要知道当前组头的状态并更新列表如果我们回调到 ActivityActivity 还得拿到 adapter 的 currentList 再修改绕了一圈。直接在 Adapter 里暴露一个onGroupClick: ((OrderGroup) - Unit)?回调Activity 里设置具体的展开逻辑这样职责清晰。3.3 展开收起的逻辑闭环展开收起逻辑放在 ViewModel 中。Activity 只负责把 ViewModel 的列表提交给 Adapter。class OrderViewModel : ViewModel() { private val _listItems MutableLiveDataListListItem() val listItems: LiveDataListListItem _listItems fun toggleGroup(groupId: String) { var changed false val newList _listItems.value.orEmpty().toMutableList() val index newList.indexOfFirst { it.type ListItem.TYPE_GROUP it.group?.groupId groupId } if (index -1) return val group newList[index].group!! group.isExpanded !group.isExpanded if (group.isExpanded) { // 找到该组下的数据插到组头后面 val orders orderMap[groupId] ?: emptyList() newList.addAll(index 1, orders.map { ListItem(type TYPE_ITEM, order it) }) } else { // 删除该组后面到下一个组头之间的所有数据项 val end newList.indexOfFirst { it.type TYPE_GROUP it.group?.groupId ! groupId it.group ! null } val removeStart index 1 val removeEnd if (end -1) newList.size else end newList.subList(removeStart, removeEnd).clear() } _listItems.value newList } }这只是一个示意版本实际代码里最好用不可变列表toList()然后提交整个新列表。注意 subList 操作后需要复制原 subList 再 clear否则会抛 ConcurrentModificationException。我实际用的是先 filter 再组合的方式更稳。val newList mutableListOfListItem() for (item in oldList) { newList.add(item) if (item.type TYPE_GROUP item.group?.groupId groupId) { if (item.group.isExpanded) { // 下面是该组的数据 newList.addAll(orderMap[groupId].orEmpty().map { ListItem(type TYPE_ITEM, order it) }) } } }这样写非常直观遍历旧列表遇到目标组且展开状态为 true 时在组头后面插入该组所有数据。收起状态为 false 时什么都不插于是数据项自然消失了。每次 toggle 后提交新列表DiffUtil 自动计算差异动画自然出现。这个逻辑可以说是整个列表中最重要的部分搞懂了它分组列表基本就拿下了一半。3.4 滑动删除与 ViewModel 配合滑动删除的回调传的是 position但 position 是删除前列表中的位置。为了不依赖位置最好从 adapter 的 currentList 里拿数据ItemTouchHelper(SwipeDeleteCallback { position - val item binding.recyclerView.adapter?.let { (it as OrderAdapter).getItem(position) } if (item?.type ListItem.TYPE_ITEM) { viewModel.deleteOrder(item.order!!.id) } }).attachToRecyclerView(binding.recyclerView)viewModel 中删除后重新生成列表过滤掉已删除的 id并提交。这里有一个关键点删除订单后如果该组已无数据是否保留空组这个要看产品需求。我这次的需求是保留组头但显示“暂无订单”因为订单列表是近 30 天的即便某天没订单也会展示日期分组只是里面是空的。所以删除数据不需要做额外处理。3.5 用一小时跑通整个流程时间分配在哪里把时间分配写出来大家就明白为什么我说“只用了 1 小时”。前 5 分钟梳理需求确定数据结构画一个简单的列表结构图。中间 10 分钟写好 ListItem、OrderGroup、OrderItem 数据类DiffCallback 骨架。接下来 15 分钟实现 Adapter 和 ViewHolder两种布局绑定点击事件。后面 20 分钟实现 ViewModel 的 toggle 和 delete 逻辑接入 ItemTouchHelper。最后 10 分钟跑起来测试发现一个小坑组头箭头不转修好后收工。这个时间仅供参考关键思路通了剩下的都是体力活。4. 常见问题与排查技巧实录4.1 列表刷新后闪烁或错乱这个几乎人人都遇过。原因一般是没用好 DiffUtil或者每次 submitList 都传一个全新的 list但里面的对象没变。DiffUtil 的判定机制是areItemsTheSame 判断是否是同一个 item用 idareContentsTheSame 判断同一个 item 的内容是否变化。如果你每次提交的是同一个对象的引用但 contents 没变DiffUtil 不会刷新。但如果你每次都 new 一堆对象但 id 相同DiffUtil 也会认为 item 相同contents 相同则不会刷新但此时 onBind 可能不会调用于是 UI 不会更新。想要强制刷新就要在 areContentsTheSame 里精确比较所有需要显示的字段。另外不要用notifyDataSetChanged()它是全量刷新会导致所有 item 重新绑定图片闪烁、焦点丢失。还有一个常见错误在onBindViewHolder里做了耗时操作或者创建对象导致滚动卡顿。onBind 会被频繁调用应该只做绑定操作不要做 IO不要 new 大量对象。4.2 展开收起后列表跳到顶部或位置丢失这是因为我们在展开收起时重新构建了新列表RecyclerView 默认会保持当前位置但如果我们在操作后调用了scrollToPosition(0)之类的东西或者 item 高度发生变化就会跳。避免办法是除非特意需要滚动到某个位置否则不要调用任何滚动方法。如果只是想要展开时让新插入的那几项完整可见可以用RecyclerView.SmoothScroller去平滑滚动但要注意在 submitList 后的动画结束再滚动否则容易失效。我这次没做这个因为列表本身会保持位置展开部分如果超出就在下面用户自己滑也合理。4.3 删除不生效或者删了又出现删除不生效多半是因为数据源没有真正移除。刚才说过要在 ViewModel 中移除后再 submitList。这里还有一种情况如果你从 Adapter.currentList 中拿 item 删除但 currentList 是旧数据可能会删错。比如滑动删除时用户快速滑动了两条第二次回调拿到的 position 已经是变化后的了但 currentList 还是未更新的。稳妥的做法是拿 item 的唯一 id 去 ViewModel 里删ViewModel 内部用 id 过滤。如果删了又出现说明 submitList 时又包含了原来那个对象检查 remove 逻辑是否用了removeIf { it.id targetId }注意 list 的可变性。4.4 分组头箭头不旋转这就是我们前面讲到的坑areContentsTheSame 没包含 isExpanded。有两种解决方式一是把 isExpanded 参与比较二是在 onBind 里根据 group.isExpanded 字段动态设置旋转但关键是 onBind 必须被重新调用。用参与比较的方式最省心。箭头旋转动画其实可以配合animate()实现imageView.animate() .rotation(if (isExpanded) 180f else 0f) .setDuration(200) .start()但要注意如果 onBind 被频繁调用比如滚动动画会被打断。简单场景没问题如果滚动时也调用建议只设置 rotation 值不启动动画。我这次只是瞬间旋转没做动画因为产品没要求。4.5 组头不能被滑删结果组头下的数据滑删后位置错乱ItemTouchHelper 默认是针对每个 ViewHolder 的。如果组头、数据项都在一起组头设置了不可滑数据项可滑一般没问题。位置错乱往往是因为数据更新后ItemTouchHelper 内部持有的 ViewHolder 失效。解决办法是不要持有 ViewHolder 的旧对象只在 onSwiped 回调时使用当时的 bindingAdapterPosition然后立刻执行删除逻辑。如果删除前有别的异步操作位置就会变。所以删除逻辑最好同步处理。5. 进一步提升的实用技巧5.1 利用 payload 做更精细的刷新展开收起场景中如果只是组头的箭头状态变化不涉及数据插入可以用notifyItemChanged(position, arrow)来刷新并在onBindViewHolder(holder, position, payloads)里只更新箭头图标。这样不会触发整个 item 重绘降低开销。但如果同时还有数据插入那就交给 DiffUtil 来做更合适不必手动混合。5.2 使用自定义 LayoutManager 实现更多效果LinearLayoutManager 只是最基本的。如果要实现流式排列可以用 GridLayoutManager多类型列表可以用StaggeredGridLayoutManager。我这次是垂直分组列表用 Linear 就够了。但是要注意GridLayoutManager 里实现分组展开是非常折腾的因为 item 占据的 span 要动态调整。有这种需求可以先用官方基础类再考虑自定义。5.3 空状态与加载更多的处理列表没有数据时RecyclerView 不会显示空布局。建议在 Activity/Fragment 中观察数据列表的 size切换空布局的可见性。加载更多可以用addOnScrollListener在滑动到底部时加载下一页。这一点很多人容易忘但产品很常见。5.4 从 Observable 到 Flow 的演进如果你用 Kotlin Flow StateFlowListAdapter 配合起来也很顺。把 ViewModel 的数据流收集后 submitList 即可。我这次用的 LiveData是因为项目里其他地方都是 LiveData保持一致。Flow 的优势是生命周期安全、支持更灵活的操作符但在小需求里没太大区别。6. 回到最初一小时解决 RecyclerView 这些坑的心得回头总结一下这次能一小时跑通最大的功臣不是手速而是脑子里有一张“优先级表”数据模型先定义好再写 Adapter最后接交互。所有报错都围绕“数据与视图不同步”来排查。只要数据源是唯一真相视图只是投影所有问题都有迹可循。如果你目前正在写一个列表觉得很痛苦建议先停下来看看是不是数据模型没设计好。RecyclerView 本身不难难的是数据变化时怎么正确地告诉它“哪些变了”。DiffUtil 就是干这个的你只需要把比较规则写清楚。展开收起、多类型、滑动删除这些花活都是基于“数据驱动视图”这个原则自然长出来的。希望这篇内容能让你少花点时间在看 API 文档上多花点时间写业务逻辑。如果有写得不对的地方欢迎在评论区交流——最好的学习方式就是把踩过的坑说出来大家互相填坑。最后分享一个不是技巧的技巧写列表之前先在纸上把列表的“类型”和“数据字段”画出来五分钟后你就知道该怎么建类、怎么写 DiffUtil 了。别高估自己的记忆也别低估列表数据的复杂度这样能省下很多冤枉时间。