第 6 周把 RecyclerView 的基础骨架跑通了:容器、LayoutManager、Adapter、ViewHolder、多布局、点击、分割线和基础复用。第 7 周要解决的不是"列表能不能显示",而是列表进入真实业务后马上会遇到的问题:刷新、分页、预加载、局部更新、滑动流畅度、过度绘制,以及复杂 item 的构建成本。
一、相关资料
| 资料 | 详情 |
|---|---|
Android Developers:使用 RecyclerView 创建动态列表 |
RecyclerView、Adapter、ViewHolder、LayoutManager 的职责拆分和列表复用思想 |
Android Developers:ListAdapter API Reference |
ListAdapter 是 RecyclerView.Adapter 的封装,内部借助 AsyncListDiffer 处理列表差异,入口是 submitList() |
Android Developers:DiffUtil API Reference |
DiffUtil 用于计算新旧列表差异;列表本身不应在 diff 过程中被修改;大列表 diff 应避免阻塞主线程 |
Android Developers:SwipeRefreshLayout API Reference |
负责下拉刷新手势、setOnRefreshListener()、isRefreshing 状态,以及单个可滚动子 View 的典型用法 |
Android Developers:AsyncLayoutInflater API Reference |
把 XML inflate 尽量移出主线程,完成后回到主线程交给回调处理 |
二、先把页面结构搭起来:SwipeRefreshLayout + RecyclerView
第 7 周的页面不是把 RecyclerView 塞进 ScrollView。真实项目里很多列表卡顿、滑动冲突和测量异常,就是从"外面套一个滚动容器"开始的。
本周 Demo 的核心结构是:页面顶部放说明和操作按钮,真正滚动的主体交给 SwipeRefreshLayout 包住一个 RecyclerView。
ini
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
android:id="@+id/swipe_refresh"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rv_feed"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipToPadding="false"
android:paddingBottom="12dp" />
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
SwipeRefreshLayout 是 AndroidX 提供的下拉刷新容器。它不负责数据,不负责分页,也不负责 item 复用;它只负责识别"用户从顶部继续下拉"的手势,并提供一个刷新中的状态。刷新数据、结束动画、提交新列表,都要业务代码自己完成。

Demo 中还设置了 setOnChildScrollUpCallback():
rust
binding.swipeRefresh.setOnChildScrollUpCallback { _, _ ->
binding.rvFeed.canScrollVertically(-1)
}
canScrollVertically(-1) 的意思是:当前列表还能不能继续向上滚。如果还能向上滚,说明列表不在顶部,不能触发下拉刷新;如果不能向上滚,才允许 SwipeRefreshLayout 接管下拉手势。
实践
内容流、商品流、搜索结果页常见的做法是:页面只有一个主滚动容器,顶部筛选区、运营卡、列表头、加载尾部都尽量变成列表的一部分,而不是外层套多个滚动容器。这样滑动状态、曝光埋点、预加载和分页判断都更集中。
相关技术清单
SwipeRefreshLayout、RecyclerView、NestedScrollingParent、NestedScrollingChild、canScrollVertically()、下拉刷新状态、单滚动容器原则。
三、下拉刷新:刷新不是清空重来,而是提交一份新列表
下拉刷新最容易写成这样:清空旧数据,重新 add,最后 notifyDataSetChanged()。能跑,但体验粗糙。整屏闪一下、动画丢失、滚动状态不稳定,复杂列表里还容易让 item 状态错乱。
本周 Demo 的刷新逻辑是:生成新数据,替换状态,再通过 submitList() 交给 ListAdapter。
kotlin
binding.swipeRefresh.setOnRefreshListener {
binding.rvFeed.postDelayed({
refreshFirstPage("下拉刷新完成:生成新列表并交给 ListAdapter 做 Diff。")
binding.swipeRefresh.isRefreshing = false
}, 700)
}
private fun refreshFirstPage(message: String) {
page = 1
refreshVersion++
isLoadingMore = false
feedCards = createPage(page, refreshVersion)
submitRows(message)
}
这里有两个细节:
isRefreshing = false必须在刷新完成后设置,否则顶部刷新动画会一直转。feedCards = createPage(...)是生成一份新列表,不是原地修改旧列表。
DiffUtil 做差异计算时,需要拿旧列表和新列表比较。如果你在原列表上直接改对象、改字段、删元素,它可能已经分不清"旧状态"和"新状态",最后要么不刷新,要么刷新错。

实践
电商首页和推荐 Feed 常见下拉刷新并不是简单"清空再拉取"。它可能要保留部分缓存、合并运营卡、重置分页游标、刷新曝光策略。Demo 只做最小版本:刷新第 1 页、更新刷新版本、提交新列表。真实项目会把这些状态放进 ViewModel 或状态容器里。
相关技术清单
setOnRefreshListener()、isRefreshing、不可变列表、submitList()、刷新版本、状态重建。
四、上拉加载与预加载:不要等用户看到底才开始请求
上拉加载有两种触发方式:
- 用户点击"加载更多"。
- 用户滑动接近底部时自动预加载。
本周 Demo 两个都做了。核心判断在 RecyclerView.OnScrollListener 里:
kotlin
binding.rvFeed.addOnScrollListener(object : RecyclerView.OnScrollListener() {
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
if (dy <= 0) return
val layoutManager = recyclerView.layoutManager as? LinearLayoutManager ?: return
val lastVisible = layoutManager.findLastVisibleItemPosition()
val shouldPreload = feedAdapter.itemCount - lastVisible <= PRELOAD_THRESHOLD
if (shouldPreload) {
loadNextPage("接近底部:OnScrollListener 提前触发下一页加载。")
}
}
})
PRELOAD_THRESHOLD 是预加载阈值。比如阈值是 5,就表示距离底部还剩 5 个 item 左右时开始加载下一页。这样用户真正滑到底时,下一页可能已经准备好了。
加载函数里还要处理两个边界:
kotlin
private fun loadNextPage(message: String) {
if (isLoadingMore || page >= MAX_PAGE) return
isLoadingMore = true
submitRows(message)
binding.rvFeed.postDelayed({
page += 1
feedCards = feedCards + createPage(page, refreshVersion)
isLoadingMore = false
submitRows("第 $page 页加载完成:新旧列表通过 DiffUtil 合并展示。")
}, 800)
}
isLoadingMore 防止重复触发。没有这个开关,用户快速滑动时可能连续发起多个下一页请求。page >= MAX_PAGE 用来模拟"没有更多数据"。真实项目里这个状态通常来自后端分页结果,例如 hasMore=false 或 nextCursor=null。
实践
短视频 Feed、商品流、信息流都会做预加载,但预加载不是越早越好。太晚,用户看到 loading;太早,浪费流量、内存和接口资源。成熟团队一般会结合网络状态、滑动速度、图片/视频封面加载成本、接口耗时和缓存命中率调整阈值。本周 Demo 只演示最基本的"接近底部提前加载"。
相关技术清单
RecyclerView.OnScrollListener、LinearLayoutManager.findLastVisibleItemPosition()、预加载阈值、分页状态、loading footer、重复请求保护。
五、DiffUtil / ListAdapter:让列表知道"哪里变了"
DiffUtil 是列表差异计算工具。它比较旧列表和新列表,算出哪些 item 插入、删除、移动、内容变化。ListAdapter 是基于 RecyclerView.Adapter 的封装,内部使用 AsyncListDiffer,让你通过 submitList() 提交新列表。
本周 Demo 的 Adapter 这样定义:
kotlin
private class Week7FeedAdapter(
private val onLikeClick: (Week7FeedCard) -> Unit
) : ListAdapter<Week7Row, RecyclerView.ViewHolder>(Week7DiffCallback) {
override fun getItemViewType(position: Int): Int {
return when (getItem(position)) {
is Week7Row.Header -> VIEW_TYPE_HEADER
is Week7Row.Card -> VIEW_TYPE_CARD
is Week7Row.Footer -> VIEW_TYPE_FOOTER
}
}
}
这里不是只放业务卡片,而是把 Header、Card、Footer 都建模成 Week7Row。原因很简单:真实列表经常不是纯商品卡,里面会有标题、推荐理由、广告位、加载尾部、空状态。把它们统一成列表行,后面的 diff、曝光、插入删除都会更清晰。
DiffUtil.ItemCallback 的关键是两个判断:
kotlin
private object Week7DiffCallback : DiffUtil.ItemCallback<Week7Row>() {
override fun areItemsTheSame(oldItem: Week7Row, newItem: Week7Row): Boolean {
return oldItem.stableId == newItem.stableId
}
override fun areContentsTheSame(oldItem: Week7Row, newItem: Week7Row): Boolean {
return oldItem == newItem
}
}
areItemsTheSame() 判断是不是同一个业务实体。比如同一个商品、同一条评论、同一个会话。areContentsTheSame() 判断内容有没有变化。前者看身份,后者看内容。
如果 areItemsTheSame() 写错,列表会把同一个实体当成两个 item,动画和状态都可能乱。如果 areContentsTheSame() 永远返回 false,每次提交都会触发大量不必要刷新。如果永远返回 true,内容变了也不会刷新。
实践
IM 会话列表、搜索结果页、订单列表都很依赖稳定 id。会话未读数变化、订单状态变化、商品价格变化,都是"同一个实体内容变了",不应该整屏刷新。成熟团队通常会要求列表数据有稳定业务 id,并把 UI 状态放进数据模型,而不是散落在 ViewHolder 里。
相关技术清单
DiffUtil、DiffUtil.ItemCallback、ListAdapter、AsyncListDiffer、submitList()、稳定 id、不可变数据、混合 viewType。
六、payload 局部刷新:不是所有变化都要重绑整张卡
点赞、阅读数、更新时间这类小变化,没有必要重新绑定标题、正文、标签、图片。DiffUtil 提供 getChangePayload(),可以告诉 Adapter:这次到底变了哪里。
Demo 里是这样写的:
kotlin
override fun getChangePayload(oldItem: Week7Row, newItem: Week7Row): Any? {
if (oldItem is Week7Row.Card && newItem is Week7Row.Card) {
val oldCard = oldItem.item
val newCard = newItem.item
val textChanged = oldCard.title != newCard.title ||
oldCard.tag != newCard.tag ||
oldCard.summary != newCard.summary
if (textChanged) return null
return CardPayload(
likeChanged = oldCard.liked != newCard.liked,
readsChanged = oldCard.reads != newCard.reads,
timeChanged = oldCard.updatedAt != newCard.updatedAt
)
}
return null
}
然后在 Adapter 里重写带 payload 的 onBindViewHolder():
kotlin
override fun onBindViewHolder(
holder: RecyclerView.ViewHolder,
position: Int,
payloads: MutableList<Any>
) {
val row = getItem(position)
if (holder is CardHolder && row is Week7Row.Card && payloads.isNotEmpty()) {
holder.bindPayload(row.item, payloads.filterIsInstance<CardPayload>())
} else {
super.onBindViewHolder(holder, position, payloads)
}
}
payload 的意义不是"少写几行代码",而是减少不必要的重绑定。复杂卡片里可能有图片、富文本、倒计时、视频封面、进度条。如果每次点赞都重绑整张卡,就会放大滑动抖动和状态闪烁。
实践
内容社区点赞、评论数变化、直播间热度变化、IM 未读数变化,都适合 payload 局部刷新。但 payload 不能破坏完整 bind 的可靠性。ViewHolder 被重新创建、item 从回收池回来、屏幕旋转后恢复,都可能走完整 bind,所以完整 bind 仍然必须能独立还原 UI。
相关技术清单
getChangePayload()、onBindViewHolder(holder, position, payloads)、局部刷新、完整 bind、ViewHolder 复用状态。
七、AsyncLayoutInflater:把复杂布局构建从主线程挪开一点
AsyncLayoutInflater 是 AndroidX 提供的异步 inflate 工具。它尝试在后台线程解析 XML、创建 View,完成后通过回调回到主线程。注意,它不是"让所有 UI 操作都能在子线程做"。真正 addView()、修改 UI,仍然要在主线程。
本周 Demo 用它异步构建一张预览卡片:
ini
private fun runAsyncInflatePreview() {
binding.asyncPreviewHost.removeAllViews()
binding.tvAsyncInflateState.text = "正在用 AsyncLayoutInflater 在后台线程 inflate 一张预览卡片..."
asyncInflater.inflate(R.layout.item_week7_feed_card, binding.asyncPreviewHost) { view, _, parent ->
val previewBinding = ItemWeek7FeedCardBinding.bind(view)
previewBinding.tvTitle.text = "异步 Inflate 预览卡"
previewBinding.tvSummary.text = "复杂首屏或预加载卡片可以把 XML 解析从主线程挪出去;回调回到主线程后再 addView。"
parent?.addView(view)
binding.tvAsyncInflateState.text = "AsyncLayoutInflater 回调完成:布局已添加到预览容器。"
}
}
它适合解决什么问题?适合首屏中某些复杂但不立刻需要展示的布局,或者提前准备某个轻量预览区域。它不适合替代 RecyclerView 的正常 ViewHolder 创建,也不能解决所有卡顿。复杂列表的主要优化仍然是减少层级、减少过度绘制、避免 bind 阶段做重计算、合理复用 ViewHolder。
成熟团队实践映射
成熟团队做首屏性能时,会把工作拆成几类:必须立刻显示的同步完成;可延后的延后;可预构建的提前构建;可缓存的缓存。AsyncLayoutInflater 属于"把部分布局构建成本挪开"的小工具,不是性能银弹。
相关技术清单
AsyncLayoutInflater、XML inflate、主线程、后台线程、回调、首屏性能、布局构建成本。
八、嵌套滑动:能不嵌套就别乱嵌套
本周规划里有 NestedScroll。这里要先讲清楚:嵌套滑动不是鼓励你把 RecyclerView 放进 ScrollView。它解决的是父子 View 都需要参与滑动时,如何协商滚动距离、惯性、边界和手势。
SwipeRefreshLayout + RecyclerView 本身就是一个典型嵌套滑动场景:子 View 是 RecyclerView,父 View 是刷新容器。父容器需要知道子列表是否已经滚到顶部,只有顶部继续下拉时才触发刷新。
常见错误是:
xml
<ScrollView>
<LinearLayout>
<RecyclerView />
</LinearLayout>
</ScrollView>
这种写法短期看能显示,长期看会引出三类问题:
RecyclerView高度测量异常,可能一次性测量大量 item。- 外层和内层抢滑动,手势体验不稳定。
- 分页、曝光、预加载判断变复杂。
如果页面确实需要复杂头部,优先考虑把头部做成 RecyclerView 的 header viewType,或者使用 CoordinatorLayout 这类明确支持协同滑动的容器。
实践
商品详情页、个人主页、内容详情页经常出现头部信息 + Tab + 列表。成熟方案一般会统一滚动模型,而不是随意多层套滚动容器。因为曝光、吸顶、预加载、刷新、返回顶部都依赖一致的滚动状态。
相关技术清单
NestedScroll、NestedScrollingParent、NestedScrollingChild、SwipeRefreshLayout、RecyclerView、ScrollView 嵌套风险、统一滚动模型。
九、滑动流畅度:缓存不是越大越好
本周 Demo 里设置了两个常见优化项:
scss
binding.rvFeed.setHasFixedSize(true)
binding.rvFeed.setItemViewCacheSize(8)
setHasFixedSize(true) 的意思不是"每个 item 高度必须一样"。它表达的是:Adapter 内容变化不会改变 RecyclerView 自身尺寸。比如列表高度就是屏幕剩余空间,item 增删不会让 RecyclerView 这个容器本身变高变矮,就可以设置它,减少不必要的测量布局。
setItemViewCacheSize(8) 是增加离屏 View 缓存数量。它可以减少刚滑出屏幕的 item 再滑回来时重新创建或重新绑定的成本。但缓存不是越大越好。缓存越大,占用的 View 和内存越多。如果 item 很复杂,盲目加大缓存反而会增加内存压力。
更重要的是:不要在 onBindViewHolder() 做重活。比如同步解析大 JSON、同步读磁盘、重复创建复杂对象、重复计算富文本,都可能让滑动掉帧。
实践
大型 App 做列表优化时,通常不会只改一个参数,而是配合帧率监控、卡顿堆栈、Layout Inspector、Systrace/Perfetto、图片加载状态、接口耗时一起看。Demo 只演示两个基础配置,真正线上还要用数据证明优化有效。
相关技术清单
setHasFixedSize()、setItemViewCacheSize()、ViewHolder 复用、bind 成本、内存压力、帧率、Perfetto、Layout Inspector。
十、过度绘制治理:少一层是一层,少一个背景是一个背景
过度绘制就是同一个像素被画了多次。列表里最容易过度绘制的地方是 item:外层背景、内层背景、阴影、分割线、圆角、图片占位都叠在一起,滑动时每一帧都要付出成本。
本周 item_week7_feed_card.xml 用 ConstraintLayout 做根布局:
ini
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@drawable/bg_demo_card"
android:padding="16dp">
<TextView
android:id="@+id/tv_title"
android:layout_width="0dp"
android:layout_height="wrap_content"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/tv_rank" />
</androidx.constraintlayout.widget.ConstraintLayout>
ConstraintLayout 的价值是减少多层 LinearLayout 嵌套。它不是永远最快,但在复杂卡片里,经常能用一个父布局表达多个控件之间的约束关系。
治理过度绘制时可以按这个顺序看:
- item 根布局是否已经有背景,子布局是否还重复设置背景。
- 圆角、阴影、边框是否真的需要每个 item 都有。
- 是否能把分割线交给
ItemDecoration,而不是每个 item 多放一个 View。 - 用 Layout Inspector 或 GPU overdraw 工具观察,而不是靠感觉。
实践
内容流、商品流、评论流的 item 数量巨大,单个 item 多一层布局、多一次背景绘制,放大到整页滑动就是明显成本。成熟团队通常会沉淀统一卡片组件和性能规范,避免每个业务线随手堆布局。
相关技术清单
ConstraintLayout、过度绘制、布局层级、重复背景、ItemDecoration、Layout Inspector、GPU overdraw。
十一、本周技术清零表
| 技术 | 它是什么 | Demo 落点 | 真实项目价值 | 常见坑 |
|---|---|---|---|---|
RecyclerView |
AndroidX 动态列表容器,负责滚动和复用 | rvFeed |
承载 Feed、商品、会话等大列表 | 把业务状态放 View 上,复用后错乱 |
SwipeRefreshLayout |
AndroidX 下拉刷新容器 | swipeRefresh.setOnRefreshListener |
标准下拉刷新交互 | 忘记设置 isRefreshing=false,或包多个直接子 View |
OnScrollListener |
监听 RecyclerView 滚动事件 | 接近底部触发 loadNextPage() |
上拉加载、预加载、曝光统计 | 不加 loading 锁导致重复请求 |
DiffUtil |
新旧列表差异计算工具 | Week7DiffCallback |
精准分发插入、删除、内容变化 | 原地修改旧 list,比较逻辑过粗或过细 |
ListAdapter |
封装 AsyncListDiffer 的 Adapter 基类 |
Week7FeedAdapter |
简化后台 diff 和列表提交 | 直接修改 currentList 或复用可变对象 |
AsyncListDiffer |
ListAdapter 内部用于异步计算差异的组件 |
通过 ListAdapter 间接使用 |
避免大列表 diff 堵住主线程 | 误以为它能解决网络分页和状态管理 |
submitList() |
向 ListAdapter 提交新列表的入口 |
feedAdapter.submitList(rows) |
触发差异计算和局部更新 | 提交同一个被原地修改的 list |
getChangePayload() |
返回 item 局部变化信息 | 点赞、阅读数、更新时间变化 | 避免整卡重绑,减少闪烁 | 只写 payload,不保证完整 bind 正确 |
AsyncLayoutInflater |
异步 inflate XML 的 AndroidX 工具 | runAsyncInflatePreview() |
降低部分布局构建对主线程的压力 | 回调后仍需主线程操作 UI,不能替代所有性能优化 |
NestedScroll |
父子滚动容器协同机制 | SwipeRefreshLayout + RecyclerView |
处理刷新、吸顶、协同滚动 | 误把 RecyclerView 随便塞进 ScrollView |
setHasFixedSize() |
声明 RecyclerView 自身尺寸不随内容变化 | rvFeed.setHasFixedSize(true) |
减少不必要测量 | 误以为 item 高度必须固定 |
setItemViewCacheSize() |
增加离屏 View 缓存数量 | rvFeed.setItemViewCacheSize(8) |
降低短距离来回滑动重绑成本 | 越大越占内存,不是越大越好 |
ConstraintLayout |
用约束减少布局嵌套的 ViewGroup | item_week7_feed_card.xml |
降低复杂卡片层级 | 简单布局滥用也可能增加理解成本 |
| 过度绘制 | 同一像素被重复绘制多次 | 卡片减少重复背景和嵌套 | 提升滑动帧率和渲染效率 | 只靠口号,不用工具观察 |