第7周:RecyclerView 高级功能与列表硬核优化

第 6 周把 RecyclerView 的基础骨架跑通了:容器、LayoutManagerAdapterViewHolder、多布局、点击、分割线和基础复用。第 7 周要解决的不是"列表能不能显示",而是列表进入真实业务后马上会遇到的问题:刷新、分页、预加载、局部更新、滑动流畅度、过度绘制,以及复杂 item 的构建成本。

一、相关资料

资料 详情
Android Developers:使用 RecyclerView 创建动态列表 RecyclerViewAdapterViewHolderLayoutManager 的职责拆分和列表复用思想
Android Developers:ListAdapter API Reference ListAdapterRecyclerView.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 接管下拉手势。

实践

内容流、商品流、搜索结果页常见的做法是:页面只有一个主滚动容器,顶部筛选区、运营卡、列表头、加载尾部都尽量变成列表的一部分,而不是外层套多个滚动容器。这样滑动状态、曝光埋点、预加载和分页判断都更集中。

相关技术清单

SwipeRefreshLayoutRecyclerViewNestedScrollingParentNestedScrollingChildcanScrollVertically()、下拉刷新状态、单滚动容器原则。

三、下拉刷新:刷新不是清空重来,而是提交一份新列表

下拉刷新最容易写成这样:清空旧数据,重新 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)
}

这里有两个细节:

  1. isRefreshing = false 必须在刷新完成后设置,否则顶部刷新动画会一直转。
  2. 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=falsenextCursor=null

实践

短视频 Feed、商品流、信息流都会做预加载,但预加载不是越早越好。太晚,用户看到 loading;太早,浪费流量、内存和接口资源。成熟团队一般会结合网络状态、滑动速度、图片/视频封面加载成本、接口耗时和缓存命中率调整阈值。本周 Demo 只演示最基本的"接近底部提前加载"。

相关技术清单

RecyclerView.OnScrollListenerLinearLayoutManager.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 里。

相关技术清单

DiffUtilDiffUtil.ItemCallbackListAdapterAsyncListDiffersubmitList()、稳定 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>

这种写法短期看能显示,长期看会引出三类问题:

  1. RecyclerView 高度测量异常,可能一次性测量大量 item。
  2. 外层和内层抢滑动,手势体验不稳定。
  3. 分页、曝光、预加载判断变复杂。

如果页面确实需要复杂头部,优先考虑把头部做成 RecyclerView 的 header viewType,或者使用 CoordinatorLayout 这类明确支持协同滑动的容器。

实践

商品详情页、个人主页、内容详情页经常出现头部信息 + Tab + 列表。成熟方案一般会统一滚动模型,而不是随意多层套滚动容器。因为曝光、吸顶、预加载、刷新、返回顶部都依赖一致的滚动状态。

相关技术清单

NestedScrollNestedScrollingParentNestedScrollingChildSwipeRefreshLayoutRecyclerViewScrollView 嵌套风险、统一滚动模型。

九、滑动流畅度:缓存不是越大越好

本周 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.xmlConstraintLayout 做根布局:

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 嵌套。它不是永远最快,但在复杂卡片里,经常能用一个父布局表达多个控件之间的约束关系。

治理过度绘制时可以按这个顺序看:

  1. item 根布局是否已经有背景,子布局是否还重复设置背景。
  2. 圆角、阴影、边框是否真的需要每个 item 都有。
  3. 是否能把分割线交给 ItemDecoration,而不是每个 item 多放一个 View。
  4. 用 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 降低复杂卡片层级 简单布局滥用也可能增加理解成本
过度绘制 同一像素被重复绘制多次 卡片减少重复背景和嵌套 提升滑动帧率和渲染效率 只靠口号,不用工具观察
相关推荐
YF02112 小时前
Android BLE 信号强度获取与 底层原理深度解析
android·蓝牙
qq3621967052 小时前
手机App下载安装完全指南:2026最新教程(Android & iOS)
android·ios·智能手机
想取一个与众不同的名字好难2 小时前
安卓设置亮度的时候,系统会在100%与0%反复横跳
android·java·开发语言
帅次2 小时前
Android 高级工程师面试参考答案:Kotlin MVVM 高频题、追问与项目表达
android·面试·职场和发展·kotlin
唔662 小时前
在 Flutter 混合开发中,Android 原生层通知 Dart 界面更新状态
android·flutter
故渊at2 小时前
系列一:架构思想进阶 | 第1篇 Android 架构演进实录:从 MVC 的“万能类”到 MVVM 的数据驱动
android·架构·mvc
流星白龙3 小时前
【MySQL高阶】22.双写缓冲区,重做日志
android·mysql·adb
世人万千丶3 小时前
鸿蒙PC问题解决:窗口配置错误修复指南
android·学习·华为·开源·harmonyos·鸿蒙·鸿蒙系统