前 5 周我们一直在处理单个控件和资源治理。到了第 6 周,页面开始进入真实业务最常见的形态:列表。
列表不是把一堆 View 竖着摆出来。真实 App 里的列表会不断滚动、刷新、插入、删除、切换状态,还要承受图片、点击、曝光、埋点和分页。RecyclerView 的价值就在这里:它把"容器、排列、复用、数据绑定"拆开,让列表可以在大量数据下仍然保持可控。
本周 Demo 已落到当前项目:Week6RecyclerViewActivity。它不是一个只显示文字的最小示例,而是同时做了:
- 垂直 / 横向列表切换
- 横向场景列表
- 单布局 / 多布局
- 点击事件和
NO_POSITION防御 ItemDecoration间距notifyItemChanged(position, payload)局部刷新setHasFixedSize、setItemViewCacheSize、RecycledViewPool基础复用配置- 完整绑定时清理复用残留状态

相关资料
| 资料 | 本文使用点 |
|---|---|
| Android Developers:使用 RecyclerView 创建动态列表 | 确认 RecyclerView、Adapter、ViewHolder、LayoutManager 的官方职责拆分,以及 View 复用机制 |
Android Developers:RecyclerView API Reference |
查证 setHasFixedSize、RecycledViewPool、ItemDecoration、position 相关边界 |
Android Developers:DiffUtil API Reference |
查证 DiffUtil 用途、列表不可原地修改、大列表建议后台计算、算法复杂度边界 |
Android Developers:ListAdapter API Reference |
查证 ListAdapter 是 AsyncListDiffer 的便捷封装,使用 submitList() 提交新列表 |
第 6 周按规划主线做 RecyclerView 基础功能和基础优化。DiffUtil / ListAdapter 本文会讲清楚它们是什么、为什么需要、边界在哪里;第 7 周会进入高级列表优化,再深入它们的完整使用。
一、RecyclerView 到底替你解决了什么
RecyclerView 是 AndroidX 里专门展示动态列表和网格的组件。它自己是一个 ViewGroup,但它不直接知道"你的商品长什么样""你的会话怎么绑定""你的标题行和内容行怎么区分"。这些事情被拆给了三类角色:
LayoutManager:决定 item 怎么排列,是竖向、横向、网格还是瀑布流。Adapter:负责把数据变成一个个可展示的 item。ViewHolder:持有单个 item 的 View,避免反复查找子控件。
Demo 的页面里先放了两个 RecyclerView:一个横向场景列表,一个主列表。
ini
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rvScenes"
android:layout_width="match_parent"
android:layout_height="132dp"
android:layout_marginTop="8dp"
android:clipToPadding="false"
android:overScrollMode="never" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rvMain"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_marginTop="8dp"
android:layout_weight="1"
android:clipToPadding="false"
android:overScrollMode="never"
android:paddingBottom="12dp" />
这段代码做了三件事:
rvScenes用固定高度展示横向列表,适合模拟首页频道、商品推荐、标签流。rvMain用layout_height="0dp" + layout_weight="1"占据剩余空间,避免把主列表塞进ScrollView里。clipToPadding="false"让列表底部 padding 不裁掉滚动内容,滚动到底时视觉更自然。
少了 layout_weight="1",主列表就不能稳定占据剩余空间;如果把主列表直接放进外层 ScrollView 并让它 wrap_content,就会让 RecyclerView 的复用优势被削弱,甚至出现测量成本过高的问题。
成熟团队做列表页时,一般不会把所有内容都塞进一个大滚动容器里"凑效果"。电商商品流、内容 Feed、IM 会话列表这类页面,主滚动容器通常只有一个,其他头部、筛选、横向推荐位再通过明确的布局关系或多类型 item 组合进去。
相关技术清单:RecyclerView、ViewGroup、LayoutManager、LinearLayoutManager、clipToPadding、主滚动容器治理。
二、LayoutManager:列表方向不应该写死在 Adapter 里
官方文档把 LayoutManager 定义为排列 item 的角色。也就是说,Adapter 只负责"有什么数据、用什么 item 绑定",不负责"竖着排还是横着排"。
Demo 里两个列表用的是同一类 Adapter,但方向由 LinearLayoutManager 控制:
ini
binding.rvScenes.layoutManager = LinearLayoutManager(this, RecyclerView.HORIZONTAL, false)
binding.rvScenes.adapter = sceneAdapter
binding.rvMain.layoutManager = LinearLayoutManager(this, RecyclerView.VERTICAL, false)
binding.rvMain.adapter = mainAdapter
这段代码里,RecyclerView.HORIZONTAL 和 RecyclerView.VERTICAL 决定排列方向。Adapter 不需要知道自己被用在横向列表还是垂直列表里。
Demo 还做了一个"切换方向"按钮:
ini
binding.btnToggleOrientation.setOnClickListener {
mainListIsHorizontal = !mainListIsHorizontal
val orientation = if (mainListIsHorizontal) RecyclerView.HORIZONTAL else RecyclerView.VERTICAL
binding.rvMain.layoutManager = LinearLayoutManager(this, orientation, false)
}
少了 layoutManager,RecyclerView 不知道怎么摆放 item,页面不会正常显示列表。把方向判断写进 Adapter,则会让 Adapter 同时关心"数据绑定"和"布局排列",后续做网格、横向、瀑布流时很难维护。
成熟团队常见做法是:业务 Adapter 尽量保持稳定,列表形态由页面层或列表容器决定。比如搜索结果页可能从单列切到双列,频道页可能有横向推荐位,但数据绑定逻辑不应该因此散落在多个 Adapter 里重复实现。
相关技术清单:LinearLayoutManager、RecyclerView.VERTICAL、RecyclerView.HORIZONTAL、职责分离。
三、Adapter / ViewHolder:一个管数据入口,一个管 item 视图
Adapter 是 RecyclerView 和业务数据之间的桥。它至少回答三个问题:
- 有多少个 item:
getItemCount() - 创建什么样的 item View:
onCreateViewHolder() - 某个位置的数据怎么绑定到 View:
onBindViewHolder()
本周 Demo 的数据先用一个 sealed class 表达:
kotlin
private sealed class Week6ListItem {
abstract val id: Long
data class Section(
override val id: Long,
val title: String,
val summary: String
) : Week6ListItem()
data class Card(
override val id: Long,
val title: String,
val description: String,
val tag: String,
val clickCount: Int = 0,
val selected: Boolean = false
) : Week6ListItem()
}
这里没有直接用 String 列表,是因为真实列表通常不是纯文本。一个页面里可能有分组标题、商品卡、广告卡、推荐卡、空状态卡。先把数据类型拆清楚,后面的多布局才不会混乱。
少了 id,短期也能跑,但后续接 DiffUtil 时就缺少稳定判断"是不是同一个业务实体"的基础。少了 selected、clickCount 这类状态字段,点击后的 UI 状态就只能散落在 View 上,复用时更容易错乱。
ViewHolder 负责持有 item 布局,并把数据写进具体控件:
kotlin
private inner class CardHolder(
private val binding: ItemWeek6ListCardBinding,
private val onItemClick: (position: Int, item: Week6ListItem) -> Unit
) : RecyclerView.ViewHolder(binding.root) {
fun bind(item: Week6ListItem.Card) {
binding.tvItemTitle.text = item.title
binding.tvItemDescription.text = item.description
binding.tvItemTag.text = item.tag
bindSelection(item.selected, item.clickCount)
}
}
这段代码的重点是:完整绑定时,不只设置标题和描述,也要设置选中态。因为 ViewHolder 会复用,旧 item 留下来的背景、描边、点击次数都可能污染新 item。
少了 bindSelection(item.selected, item.clickCount),你会看到很典型的 RecyclerView 复用 bug:刚才点过的卡片滚出屏幕后,再滚回来,另一个位置可能显示成"已选中"。
成熟团队做 IM 会话、金融账单、商品卡片时,都会要求完整 bind 能还原 item 的全部 UI 状态。不要把"默认未选中""默认灰色""默认隐藏"交给 XML 初始值,因为 View 复用后 XML 初始值不会自动重来一次。
相关技术清单:RecyclerView.Adapter、RecyclerView.ViewHolder、ViewBinding、完整绑定、复用状态清理。
四、多布局:getItemViewType() 不是炫技,是让列表结构可维护
本周主列表里同时有分组标题和业务卡片。它们不是同一种 View,所以 Adapter 要告诉 RecyclerView:不同位置应该创建不同 ViewHolder。
kotlin
override fun getItemViewType(position: Int): Int = when (items[position]) {
is Week6ListItem.Section -> VIEW_TYPE_HEADER
is Week6ListItem.Card -> VIEW_TYPE_CARD
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
val inflater = LayoutInflater.from(parent.context)
return when (viewType) {
VIEW_TYPE_HEADER -> HeaderHolder(
ItemWeek6SectionHeaderBinding.inflate(inflater, parent, false)
)
VIEW_TYPE_CARD -> CardHolder(
ItemWeek6ListCardBinding.inflate(inflater, parent, false),
onItemClick
)
else -> error("Unknown viewType: $viewType")
}
}
getItemViewType() 返回的是 View 类型,不是业务分类名。RecyclerView 会按 viewType 管理回收池,所以同一个 viewType 必须创建兼容的 ViewHolder。
少了 getItemViewType(),RecyclerView 默认所有 item 都是同一种类型。你要么只能把标题和卡片硬塞进同一个布局里,要么在 onBindViewHolder() 里写大量 if 去隐藏控件,后面维护会很痛苦。
这里还有一个容易踩的坑:多个 RecyclerView 共享 RecycledViewPool 时,viewType 的语义必须兼容。Demo 中横向列表和主列表都使用同一个 Week6Adapter,VIEW_TYPE_CARD 对应的布局和 ViewHolder 是同一套,所以可以共享。真实项目里如果两个 Adapter 都把 1 当 viewType,但一个代表商品卡、一个代表广告卡,就不能随便共享回收池。
成熟团队在复杂首页、内容社区 Feed、搜索聚合页里,常会把多类型 item 做成明确的 item model,而不是让一个巨大布局靠隐藏显示来适配所有场景。这样做的代价是 Adapter 结构更复杂,但收益是复用、局部刷新和调试都更清楚。
相关技术清单:getItemViewType()、多布局 Adapter、viewType 兼容性、RecycledViewPool 共享边界。
五、点击事件:不要把 position 当成永远正确
列表点击最容易写成这样:创建 ViewHolder 时把 position 存下来,点击时直接用。这个写法在 RecyclerView 里不稳,因为列表可能插入、删除、刷新,ViewHolder 当前对应的位置会变。
Demo 里点击时才读取 bindingAdapterPosition:
arduino
init {
binding.root.setOnClickListener {
val position = bindingAdapterPosition
if (position == RecyclerView.NO_POSITION) return@setOnClickListener
onItemClick(position, items[position])
}
}
这段代码做了两个防御:
- 点击发生时才拿当前位置,而不是提前缓存 position。
- 遇到
RecyclerView.NO_POSITION直接返回。
NO_POSITION 代表当前 ViewHolder 没有有效 Adapter 位置。比如 Adapter 刚更新、布局还没稳定时,就可能出现这种临界状态。
少了 NO_POSITION 判断,轻则点击错数据,重则数组越界。少了点击时再取 bindingAdapterPosition,就可能把旧位置的数据当成当前 item。
成熟团队做列表点击时,通常还会把点击事件上抛给页面层,由页面层决定跳转、埋点、弹窗或局部更新。Adapter 可以承载基础交互,但不应该变成业务流程中心。
相关技术清单:bindingAdapterPosition、NO_POSITION、点击事件上抛、Adapter 位置和布局位置差异。
六、ItemDecoration:分割线和间距不一定要写进 item XML
ItemDecoration 是 RecyclerView 提供的装饰机制。官方定义里它可以给特定 item 添加绘制和布局偏移,常见用途就是分割线、间距、分组装饰。
Demo 写了一个只负责间距的 Decoration:
kotlin
private class Week6SpacingDecoration(
private val spacePx: Int,
private val horizontal: Boolean = false
) : RecyclerView.ItemDecoration() {
override fun getItemOffsets(
outRect: Rect,
view: View,
parent: RecyclerView,
state: RecyclerView.State
) {
val position = parent.getChildAdapterPosition(view)
if (position == RecyclerView.NO_POSITION) return
if (horizontal) {
if (position == 0) outRect.left = spacePx
outRect.right = spacePx
} else {
outRect.bottom = spacePx
}
}
}
这段代码只改变 item 周围的 offset,不改变 Adapter 数据,也不改变 item 的 position。它让"列表间距规则"从 item XML 中独立出来。
少了 getChildAdapterPosition(view) 的有效性判断,列表更新或动画期间可能拿到无效位置。少了 horizontal 分支,横向列表和竖向列表就会共用一套间距逻辑,视觉上很容易不对。
什么时候适合用 ItemDecoration?
- 统一分割线
- 统一 item 间距
- 分组背景
- 吸顶标题背景
- 不想让每个 item 自己关心"我和别人之间隔多远"
什么时候不适合?如果某个间距是 item 内容的一部分,比如卡片内部 padding、头像和标题之间的距离,就应该留在 item XML 里。
成熟团队做列表视觉治理时,会尽量把"列表规则"和"item 内容结构"分开。这样同一个商品卡可以放在搜索页、推荐页、店铺页,而不同页面的外部间距由 RecyclerView 层处理。
相关技术清单:RecyclerView.ItemDecoration、getItemOffsets()、Rect、分割线、item 间距治理。
七、局部刷新:notifyItemChanged(position, payload) 不是自动魔法
列表里最常见的更新不是整条数据变了,而是某个字段变了:点赞数、选中态、未读数、进度条、关注状态。
本周 Demo 点击卡片后,只更新选中态和点击次数:
kotlin
fun toggleSelection(position: Int) {
val current = items.getOrNull(position) as? Week6ListItem.Card ?: return
val updated = current.copy(
selected = !current.selected,
clickCount = current.clickCount + 1
)
items[position] = updated
notifyItemChanged(
position,
Week6Payload.SelectionChanged(updated.selected, updated.clickCount)
)
}
这段代码先更新数据,再通知 RecyclerView:这个位置的 item 内容发生了变化,并且变化内容是 SelectionChanged。
少了 items[position] = updated,界面也许会临时变,但下一次完整 bind 又会回到旧数据。少了 payload,Adapter 只能走普通的完整绑定,不知道这次只需要改选中态。
Adapter 里还要重写带 payload 的 onBindViewHolder():
kotlin
override fun onBindViewHolder(
holder: RecyclerView.ViewHolder,
position: Int,
payloads: MutableList<Any>
) {
if (payloads.isEmpty() || holder !is CardHolder) {
onBindViewHolder(holder, position)
return
}
payloads.filterIsInstance<Week6Payload.SelectionChanged>()
.lastOrNull()
?.let { holder.bindSelection(it.selected, it.clickCount) }
?: onBindViewHolder(holder, position)
}
这里有一个关键边界:payload 只是"变化提示",不是自动更新 UI。你传了 payload,也必须在 Adapter 里解释它。
少了 payloads.isEmpty() 的完整绑定兜底,会出问题。因为官方 API 的语义里,payload 并不是强保证;如果目标 item 当前不在屏幕上,payload 可能不会按你想象的方式传到 ViewHolder。等它重新显示时,完整 bind 必须能恢复全部 UI。
成熟团队在 IM 会话列表、电商商品卡、内容 Feed 点赞状态里,会优先用局部刷新降低无效绑定。但不会为了"局部刷新"牺牲完整绑定可靠性。完整 bind 是底线,payload 是优化。
相关技术清单:notifyItemChanged(position)、notifyItemChanged(position, payload)、payload、完整 bind、局部刷新、UI 状态一致性。
八、复用和缓存:优化前先知道自己在优化什么
RecyclerView 的性能基础来自复用:滚出屏幕的 View 不会马上销毁,而是被拿来绑定新的数据。这个机制减少了创建 View 和 inflate 布局的成本。
Demo 做了三类基础配置:
scss
sharedPool.setMaxRecycledViews(Week6Adapter.VIEW_TYPE_HEADER, 4)
sharedPool.setMaxRecycledViews(Week6Adapter.VIEW_TYPE_CARD, 16)
binding.rvMain.setHasFixedSize(true)
binding.rvMain.setItemViewCacheSize(4)
binding.rvMain.setRecycledViewPool(sharedPool)
这几行很容易被误解,所以要拆开讲。
setHasFixedSize(true) 的意思是:Adapter 内容变化不会影响 RecyclerView 自身尺寸。它不是说每个 item 高度都必须固定,也不是说数据不能变化。Demo 里主列表高度由父布局和 layout_weight 决定,所以适合打开。
setItemViewCacheSize(4) 是让单个 RecyclerView 在 View 进入共享回收池之前,额外保留一定数量的离屏 View。它不是越大越好,过大反而会占内存。
RecycledViewPool 保存的是可复用的 ViewHolder,不是业务数据。多个 RecyclerView 共享它的前提是 viewType 和 ViewHolder 兼容。本周横向列表和主列表都使用同一个 Adapter,所以共享是安全的。
少了 setRecycledViewPool(sharedPool),两个列表就不会共享这套回收池。少了 setMaxRecycledViews(),仍然可以复用,但你无法针对具体 viewType 调整池大小。滥用 setHasFixedSize(true) 则可能在 wrap_content 列表里造成布局更新不符合预期。
成熟团队不会把这些 API 当"性能玄学开关"。它们通常会结合场景使用:
- 首页多个横向频道列表:考虑共享兼容的
RecycledViewPool。 - 固定高度的全屏列表:可以考虑
setHasFixedSize(true)。 - 高成本 item:评估缓存数量,但要监控内存。
- item 状态复杂:优先保证完整 bind 清理状态,再谈缓存。
相关技术清单:View 复用、RecycledViewPool、setHasFixedSize、setItemViewCacheSize、viewType 兼容、内存边界。
九、DiffUtil / ListAdapter
DiffUtil 是用来比较旧列表和新列表差异的工具。它会计算插入、删除、移动、内容变化,再把结果分发给 Adapter。它解决的是:不要一有变化就 notifyDataSetChanged() 整屏刷新。
ListAdapter 是 RecyclerView.Adapter 的一个基类,内部封装了 AsyncListDiffer,可以在后台线程计算列表差异。它通过 submitList() 提交新列表,而不是让你直接修改内部 list。
典型形态长这样:
kotlin
object ItemDiff : DiffUtil.ItemCallback<Item>() {
override fun areItemsTheSame(oldItem: Item, newItem: Item): Boolean {
return oldItem.id == newItem.id
}
override fun areContentsTheSame(oldItem: Item, newItem: Item): Boolean {
return oldItem == newItem
}
}
少了 areItemsTheSame(),DiffUtil 不知道两个 item 是否代表同一个业务实体。少了 areContentsTheSame(),它不知道同一个实体内容有没有变。判断写得太粗,会漏刷新;判断写得太细或永远返回 false,会产生大量不必要动画。
边界必须记住:
- 不要原地修改旧 list 或
getCurrentList()。 - 数据变化时提交新 list。
- 大列表 diff 计算可能有成本,官方建议使用后台计算能力。
- 比较逻辑不能太重。
DiffUtil不是数据库、不是分页、不是状态管理,它只负责列表差异计算。
成熟团队在搜索结果、商品流、会话列表里,通常会用 Diff 思路减少整屏刷新。但这类能力要和数据不可变、状态建模、分页加载一起设计,不是把 Adapter 换成 ListAdapter 就自动高级。
相关技术清单:DiffUtil、DiffUtil.ItemCallback、ListAdapter、AsyncListDiffer、submitList()、不可变列表、差异刷新。
十、列表和图片加载协同
第 4 周和图片加载专题已经讲过 Glide、Coil、Fresco 的基本边界。到了 RecyclerView,图片问题会更明显:ViewHolder 会复用,旧图片请求可能晚回来,图片尺寸可能不匹配,动图可能让滚动掉帧。
这周只记三条实践边界:
- 完整 bind 里必须给图片 View 设置当前 item 的图片请求,不能依赖旧状态。
- 如果当前位置不需要图片,要清理或设置明确占位,避免复用错位。
- 大量图片、GIF、视频封面列表要结合图片框架生命周期和滚动状态做更细控制。
相关技术清单:图片复用错位、占位图、请求清理、滚动状态、图片预加载边界。
十一、第6周技术清单
| 技术 | 它是什么 | Demo 落点 | 真实项目价值 | 常见坑 |
|---|---|---|---|---|
RecyclerView |
AndroidX 动态列表容器 | activity_week6_recycler_view.xml 的 rvMain / rvScenes |
支撑商品流、Feed、会话列表、搜索结果 | 放进外层大 ScrollView 导致复用优势变弱 |
Adapter |
数据和 item View 的桥 | Week6Adapter |
集中管理 item 创建、绑定和数量 | 把跳转、网络请求、业务流程全塞进 Adapter |
ViewHolder |
单个 item View 的持有者 | HeaderHolder / CardHolder |
减少重复查找 View,承载绑定逻辑 | 只改部分字段,复用后残留旧状态 |
LayoutManager |
控制 item 排列方式 | LinearLayoutManager 横向 / 纵向切换 |
同一 Adapter 可适配不同列表形态 | 把横竖方向写进 Adapter |
getItemViewType() |
告诉 RecyclerView 当前位置是什么 View 类型 | Section / Card 两种类型 |
支撑复杂首页、Feed 多类型卡片 | viewType 语义冲突,导致回收错乱 |
ItemDecoration |
给 item 添加绘制或布局偏移 | Week6SpacingDecoration |
统一治理分割线、间距、分组背景 | 把 item 内部 padding 和列表外部间距混在一起 |
notifyItemChanged |
通知某个 item 内容变化 | toggleSelection() |
避免整屏刷新,提升状态更新效率 | 用它处理插入、删除、移动这类结构变化 |
payload |
局部变化提示对象 | Week6Payload.SelectionChanged |
点赞、选中、未读数等局部更新更轻 | 以为 payload 会自动更新 UI,忘记完整 bind 兜底 |
bindingAdapterPosition |
ViewHolder 当前绑定 Adapter 中的位置 | 点击事件里实时读取 | 避免点击旧位置导致错数据 | 创建 ViewHolder 时缓存 position |
NO_POSITION |
当前没有有效 Adapter 位置 | 点击防御判断 | 防止更新临界状态下数组越界 | 不判断直接访问 items[position] |
setHasFixedSize |
声明 RecyclerView 自身尺寸不受 Adapter 内容影响 | rvMain.setHasFixedSize(true) |
减少不必要测量布局 | 误以为 item 高度必须固定,或在 wrap_content 场景滥用 |
setItemViewCacheSize |
设置离屏 View 缓存数量 | rvMain.setItemViewCacheSize(4) |
降低高频返回 item 的重新绑定成本 | 越设越大导致内存压力 |
RecycledViewPool |
可共享的 ViewHolder 回收池 | sharedPool 同时给两个列表使用 |
多个兼容列表复用 ViewHolder | 不同 Adapter 的 viewType 不兼容仍强行共享 |
DiffUtil |
比较新旧列表差异的工具 | 本周只做边界说明 | 精确分发插入、删除、移动、修改 | 原地修改 list,比较逻辑过重或错误 |
ListAdapter |
封装 AsyncListDiffer 的 Adapter 基类 |
本周只做边界说明 | 后台 diff + submitList() 简化列表更新 |
直接修改 getCurrentList() 返回值 |
ViewBinding |
根据 XML 生成类型安全绑定类 | ActivityWeek6RecyclerViewBinding 等 |
避免 findViewById 和类型转换错误 |
忘记布局 id 会导致 binding 字段不存在 |
十二、本周真正要记住什么
第 6 周不是为了背 RecyclerView API,而是建立列表开发的底层判断:
- 列表方向交给
LayoutManager。 - 数据入口交给
Adapter。 - item 视图交给
ViewHolder。 - 多布局用
getItemViewType()明确区分。 - 点击时实时拿
bindingAdapterPosition,并处理NO_POSITION。 - 间距和分割线优先考虑
ItemDecoration。 - 局部变化用
notifyItemChanged(position, payload),但完整 bind 必须可靠。 - 缓存配置不是玄学,要知道
setHasFixedSize、setItemViewCacheSize、RecycledViewPool分别优化什么。 DiffUtil / ListAdapter是下一步,但不要在基础没打牢时直接跳模板。
下一周进入 RecyclerView 高级功能和硬核优化:下拉刷新、上拉加载、嵌套滑动、DiffUtil / ListAdapter 完整实践、滑动性能、预加载和过度绘制治理。