返回 Android
Android
15 分钟阅读

RecyclerView 全解:从入门到源码与性能排查

从基本使用、进阶技巧、核心源码到滑动卡顿排查,系统梳理 RecyclerView 的完整学习路径与面试要点。

面试开场:「RecyclerView 滑动时偶发卡顿,帧率从 60 掉到 30,你会怎么排查?」

这道题表面问性能,实际在考察你对 列表从绑定到渲染的整条链路 是否熟悉——Adapter 怎么写只是起点,真正拉开差距的是:缓存复用机制、LayoutManager 测量布局、DiffUtil 增量刷新,以及 Systrace / Perfetto 能不能把 16.6ms 预算花在哪说清楚。

这篇按 学习路径 → 基本使用 → 进阶使用 → 源码机制 → 性能排查 的顺序展开,可作为 RecyclerView 的系统笔记。


学习路径总览

┌─────────────────────────────────────────────────────────────────────┐
│  Level 1 · 基本使用                                                  │
│  Adapter / ViewHolder / LayoutManager / ItemDecoration / ItemAnimator │
└───────────────────────────────┬─────────────────────────────────────┘
                                ▼
┌─────────────────────────────────────────────────────────────────────┐
│  Level 2 · 进阶使用                                                  │
│  DiffUtil · ListAdapter · Paging · 多类型 · 嵌套滚动 · 共享 Pool     │
└───────────────────────────────┬─────────────────────────────────────┘
                                ▼
┌─────────────────────────────────────────────────────────────────────┐
│  Level 3 · 源码机制                                                  │
│  四级缓存 · Scrap/Recycled · prefetch · Layout 三阶段 · 嵌套滚动分发   │
└───────────────────────────────┬─────────────────────────────────────┘
                                ▼
┌─────────────────────────────────────────────────────────────────────┐
│  Level 4 · 性能与工具                                                │
│  主线程耗时 · 布局层级 · Bitmap · GC · Trace / Perfetto / JankStats  │
└─────────────────────────────────────────────────────────────────────┘
阶段目标检验标准
基本使用能独立实现标准列表理解 ViewHolder 复用,会选 Linear / Grid / Staggered LayoutManager
进阶使用应对复杂业务列表多类型、分页、局部刷新、CoordinatorLayout 嵌套滚动不踩坑
源码机制知道「为什么快」能画出四级缓存流转,解释 prefetch 与 onBind 时机
性能排查线上卡顿可定位用 Trace 找到超 16.6ms 的 bind / measure / draw 阶段

一、基本使用

1.1 核心角色

RecyclerView 把 数据展示布局策略 解耦,四个扩展点各司其职:

组件职责常见实现
Adapter数据 ↔ ViewHolder 绑定RecyclerView.Adapter<VH>
ViewHolder持有 Item 视图引用减少 findViewById
LayoutManager测量、布局、滚动LinearLayoutManagerGridLayoutManagerStaggeredGridLayoutManager
ItemDecoration分割线、间距、背景自定义 getItemOffsets / onDraw
ItemAnimator增删改动画默认 DefaultItemAnimator,局部刷新时可关闭
class UserAdapter : RecyclerView.Adapter<UserAdapter.VH>() {
 
    private val items = mutableListOf<User>()
 
    inner class VH(val binding: ItemUserBinding) : RecyclerView.ViewHolder(binding.root)
 
    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH {
        val binding = ItemUserBinding.inflate(
            LayoutInflater.from(parent.context), parent, false
        )
        return VH(binding)
    }
 
    override fun onBindViewHolder(holder: VH, position: Int) {
        val user = items[position]
        holder.binding.name.text = user.name
        // 图片加载见进阶章节
    }
 
    override fun getItemCount() = items.size
}

1.2 LayoutManager 选型

场景推荐注意点
单列垂直列表LinearLayoutManager(context)默认;配合 setStackFromEnd(true) 做聊天列表
网格GridLayoutManager(context, spanCount)SpanSizeLookup 实现 header 占满行
瀑布流StaggeredGridLayoutManager(span, VERTICAL)Item 高度不一致;避免在 bind 里改 layout params 触发 relayout
横向画廊LinearLayoutManager(context, HORIZONTAL, false)配合 SnapHelperPagerSnapHelper

1.3 必须养成的习惯

  1. onCreateViewHolder 只做 inflate,不做业务逻辑、不加载图片。
  2. onBindViewHolder 里避免创建对象(字符串拼接、new 监听器、匿名内部类)。
  3. 点击事件在 ViewHolder 构造时注册,通过 bindingAdapterPosition 取 position,并判断 NO_POSITION
  4. 列表为空 / 加载中 / 错误ConcatAdapter 或单独 header/footer Adapter,而不是在 Activity 里叠一层 View 挡滚动(参见 Phomemo 首页踩坑)。

二、进阶使用

2.1 DiffUtil 与 ListAdapter

全量 notifyDataSetChanged() 会导致 所有可见 Item 重新 bind,滑动时极易掉帧。DiffUtil 在后台线程计算差异,主线程只做局部刷新:

class UserDiffCallback : DiffUtil.ItemCallback<User>() {
    override fun areItemsTheSame(old: User, new: User) = old.id == new.id
    override fun areContentsTheSame(old: User, new: User) = old == new
}
 
class UserListAdapter : ListAdapter<User, UserListAdapter.VH>(UserDiffCallback()) {
    // onCreateViewHolder / onBindViewHolder 同上
}
 
// 使用
adapter.submitList(newList)   // 自动 diff + 动画
API适用场景
DiffUtil.calculateDiff()大数据集、自定义 diff 策略
ListAdapter + AsyncListDiffer日常列表,自动后台 diff
notifyItemRangeChanged(pos, count, payload)只更新头像、点赞数等局部字段

Payload 局部刷新示例:

override fun onBindViewHolder(holder: VH, position: Int, payloads: MutableList<Any>) {
    if (payloads.isEmpty()) {
        super.onBindViewHolder(holder, position, payloads)
        return
    }
    // payloads 非空时只更新变化字段,避免重载图片
    payloads.forEach { /* 更新 likeCount 等 */ }
}

2.2 多 ViewType

companion object {
    private const val TYPE_HEADER = 0
    private const val TYPE_ITEM = 1
}
 
override fun getItemViewType(position: Int): Int =
    if (position == 0) TYPE_HEADER else TYPE_ITEM
 
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH =
    when (viewType) {
        TYPE_HEADER -> HeaderVH(...)
        else -> ItemVH(...)
    }

ViewType 种类应 可控且稳定;动态类型过多会导致 RecycledViewPool 缓存碎片化,复用率下降。

2.3 Paging 3 分页

大数据集不要一次性 submitList 万条数据。Paging 3 配合 PagingDataAdapter

lifecycleScope.launch {
    viewModel.pagingFlow.collectLatest { pagingData ->
        adapter.submitData(pagingData)
    }
}

底层仍是 RecyclerView,但数据按页加载、占位符与 LoadState 可统一处理。

2.4 嵌套滚动与 CoordinatorLayout

CoordinatorLayout + AppBarLayout + RecyclerView 是常见折叠头布局。要点:

  • RecyclerView 使用 app:layout_behavior="@string/appbar_scrolling_view_behavior"
  • 快速 fling 后反向拖拽若 AppBar 弹跳,是 Behavior 状态机冲突,不是列表性能问题(详见 Fixed AppBarLayout Behavior
  • 外层 ScrollView 嵌套内层 RecyclerView 需启用嵌套Scrolling 或改为单一 RecyclerView + 多 ViewType

2.5 共享 RecycledViewPool

Tab + ViewPager2 多列表场景,共享 Pool 减少 inflate:

val pool = RecyclerView.RecycledViewPool().apply {
    setMaxRecycledViews(VIEW_TYPE, 10)
}
recyclerView1.setRecycledViewPool(pool)
recyclerView2.setRecycledViewPool(pool)

2.6 图片加载

override fun onBindViewHolder(holder: VH, position: Int) {
    Glide.with(holder.itemView)
        .load(user.avatarUrl)
        .override(avatarSize, avatarSize)   // 指定目标尺寸,避免解码过大 Bitmap
        .into(holder.binding.avatar)
}
 
override fun onViewRecycled(holder: VH) {
    Glide.with(holder.itemView).clear(holder.binding.avatar)
    super.onViewRecycled(holder)
}

三、源码机制

RecyclerView 的性能根基是 View 缓存与复用。理解下面这张流转图,面试和排查都有抓手。

3.1 四级缓存

                    ┌──────────────────┐
  滑动 / 刷新        │  mAttachedScrap  │  屏幕内、已 detach 待复用
                    └────────┬─────────┘
                             │
                    ┌────────▼─────────┐
                    │   mCachedViews   │  屏幕外、未回收,保留 position 与数据
                    │  (默认最多 2 个)  │
                    └────────┬─────────┘
                             │ 超出容量
                    ┌────────▼─────────┐
                    │ RecycledViewPool │  按 viewType 分桶,默认每类 5 个
                    └────────┬─────────┘
                             │ 仍无可用
                    ┌────────▼─────────┐
                    │  onCreateViewHolder │  重新 inflate
                    └──────────────────┘
缓存层特点调优 API
Scrap布局暂存,不清数据LayoutManager 内部使用
CachedViews保留 ViewHolder 状态setItemViewCacheSize(n) 增大可加速回滑
RecycledViewPool清空 bind 数据,按 type 复用setMaxRecycledViews(type, max)、多 RV 共享
Create最昂贵减少 ViewType 种类、简化 item 布局

3.2 布局三阶段

LayoutManager 布局时依次执行:

  1. onMeasure — 确定 RecyclerView 尺寸
  2. onLayoutChildren — 填充、回收、决定哪些 Item 可见
  3. scrollHorizontallyBy / scrollVerticallyBy — 滚动时增量布局

卡顿常出在 onLayoutChildren 触发过多 measure。Item 根布局尽量扁平,ConstraintLayout 单层约束优于多层嵌套 LinearLayout

3.3 Prefetch 预取

API 21+ 默认开启:在 当前帧空闲时 提前 layout + bind 即将进入屏幕的 Item。

(layoutManager as LinearLayoutManager).apply {
    isItemPrefetchEnabled = true
    initialPrefetchItemCount = 4   // 首次布局预取数量
}

onBindViewHolder 过重,prefetch 会把耗时 提前到当前帧,反而可能造成 jank——根因仍是 bind 逻辑太重。

3.4 嵌套滚动分发

RecyclerView 实现 NestedScrollingChild3。滚动事件沿 Child → Parent 向上传递(dispatchNestedPreScroll / dispatchNestedScroll),与 AppBarLayoutBottomSheet 协作。自定义 Behavior 时需正确处理 onNestedPreScroll 消费与 unconsumed 传回。

3.5 关键源码入口(AOSP)

关注点类 / 方法
缓存回收RecyclerView.Recycler
布局LinearLayoutManager.layoutChunk()
滑动GapWorker(prefetch)、OverScroller
适配器通知AdapterHelper(处理 notify 与 offset)
DiffDiffUtilAsyncListDiffer

四、性能问题分析与排查

回到开头的面试题,按 命中率从高到低 排查:

4.1 主线程耗时

症状: Systrace 里 RV OnBindViewChoreographer#doFrame 单帧 > 16.6ms。

常见原因:

  • onBindViewHolder 里解析 JSON、查数据库、同步网络
  • 复杂字符串 / Spannable 每次 bind 重新构建
  • 在 bind 里 requestLayout() 或修改 LayoutParams

修法: 数据预处理放到 ViewModel / Repository;bind 只做赋值;耗时格式化用缓存或 payload 局部更新。

4.2 布局层级

症状: measure / layout 占比高,GPU 过度绘制严重。

排查:

  • Layout Inspector 看层级深度
  • 开发者选项 → GPU 过度绘制
  • 避免 Item 根布局 wrap_content 嵌套 wrap_content

修法: Item 用单层 ConstraintLayout;固定 Item 高度(列表项高度已知时);减少 <merge> 误用导致的额外层级。

4.3 Bitmap 解码

症状: bind 时出现 BitmapFactory.decode、Glide EngineJob 在主线程等待。

排查: StrictMode 检测主线程磁盘 / 网络;Trace 看 decode 耗时。

修法:

  • Glide override(w, h)format(PREFER_RGB_565)(无透明通道时)
  • 不在 bind 里 BitmapFactory.decodeResource 大图
  • 占位图 + 淡入,避免空白闪烁导致「体感卡」

4.4 GC 抖动

症状: 滑动时 Memory Profiler 锯齿状,Trace 出现 Background partial concurrent copying GC

常见原因:

  • 每帧大量 new 临时对象(字符串、Pair、lambda)
  • notifyDataSetChanged() 导致大规模 rebind
  • 图片缓存策略不当,频繁分配 ByteArray

修法: DiffUtil 替代全量刷新;对象池 / 复用 Spannable;减少 bind 里日志输出。

4.5 DiffUtil 使用不当

问题后果
areItemsTheSame 写错错误动画、闪烁、甚至 crash
主线程 calculateDiff 大数据提交列表本身卡顿
每次 submit 新 ArrayList 但内容相同无意义 diff 开销

建议: 优先 ListAdapter;大数据集用 AsyncListDiffergetChangePayload 返回非空实现精细更新。

4.6 Compose LazyColumn 对比

若项目混用 Compose,Lazy 列表原理类似(composition reuse + prefetch),但重组(Recomposition)是额外成本:

维度RecyclerView + ViewLazyColumn + Compose
复用单元ViewHolderSlot / Node 复用
局部更新DiffUtil + payloadkey() + 稳定参数减少重组
图片Glide 成熟Coil;需注意重组时重复 launch
典型坑bind 过重不稳定 lambda 导致整列表重组

Compose 瀑布流 + 大图场景若掉帧,先查 remember / key / derivedStateOf 是否用对,再对比同等条件下 RecyclerView + Glide 基线(参见 Jetpack Compose 实践)。

4.7 工具链:Trace、Perfetto、JankStats

工具用途使用要点
Systrace / Perfetto看每帧主线程切片关注 traversalRV OnBindViewdraw;Android Studio Profiler → System Trace
Frame Timeline / GPU Rendering定位 jank 帧红色帧展开看是 layout 还是 draw
JankStats(Jetpack)线上 / 调试 jank 统计监听 JankStats.OnFrameListener,上报 frameDurationUiNanos
Macrobenchmark滑动 FPS、启动对比RecyclerViewBenchmark 自定义滑动场景
StrictMode开发期抓主线程 IO与列表 bind 叠加使用

Perfetto 实操步骤(简化):

  1. 复现卡顿 → 开始 System Trace 录制
  2. 滑动列表 5~10 秒 → 停止
  3. Choreographer#doFrame 找 > 16ms 的帧
  4. 展开看是 bindmeasuredraw 还是 GC
  5. 对症回到 4.1~4.5 修法

Android Studio 内置 Profiler 在低端机或内存紧张时本身也会拖慢调试(参见 Android Profiler 笔记)。Trace 文件导出到 Perfetto UI 分析 往往是更轻量的选择。


五、面试回答模板

「RecyclerView 滑动从 60 掉到 30,我会先 Systrace 抓一帧,看超时发生在 bind、layout 还是 draw。」

  1. 主线程onBindViewHolder 是否有 IO、解码、复杂计算
  2. 布局 — Item 层级、wrap_content 嵌套、过度绘制
  3. Bitmap — 解码尺寸、Glide override、是否在主线程等磁盘
  4. GC — 是否频繁 notifyDataSetChanged、bind 里大量 new
  5. 缓存ItemViewCacheSize、共享 Pool、DiffUtil 是否启用
  6. DiffUtil / Payload — 能否局部刷新而非全量 rebind
  7. Compose — 若 LazyColumn,查重组范围与 unstable 参数
  8. 工具 — Perfetto 定帧、JankStats 线上监控、Macrobenchmark 回归

六、延伸阅读

主题链接
面试中的列表卡顿话术Android 面试复盘
Systrace / Profiler 使用Android Profiler
主线程消息循环Handler 机制全解
CoordinatorLayout 嵌套滚动Fixed AppBarLayout Behavior
Compose 列表对比Jetpack Compose

一句话总结: RecyclerView 的学习曲线,本质是 「会用 → 会优化 → 懂缓存与布局 → 会用 Trace 证明」。把四级缓存和 16.6ms 帧预算刻进肌肉记忆,列表相关的面试和线上问题就都有据可依。

相关文章

Android
18 分钟
Android面试套路
Android 常见面试题的分层答法、关键词接龙链路与实战追问——从 ANR 到启动模式、从 Handler 到架构选型。
Android
16 分钟
Android Profiler 全解:内存、卡顿、耗电与 ANR 排查
系统梳理 Android Studio Profiler 四大面板的使用方法、System Trace 卡顿定位、ANR traces.txt 分析与 Perfetto 轻量替代方案,并坦诚讨论开发机配置与实战取舍。
Android
17 分钟
Android Handler 机制全解:从源码看懂消息循环
从 Handler、Looper、MessageQueue、Message 四件套出发,结合 ActivityThread 启动链路与 nativePollOnce 底层实现,完整讲清 Android 主线程消息循环的设计与源码细节。
Android
5 分钟
声明式UI布局
一种在构建MVVM业务逻辑阶段,就需要把所有情况定义好的UI布局,通过数据去推动页面的更新状态形式。(这是一种绝对的数据状态驱动UI展示逻辑,而不再是获取到数据之后,再去根据数据手动更新UI)