RecyclerView 滑动卡顿怎么办

5 阅读5分钟

RecyclerView 滑动卡顿分析与优化

一、RV Scroll 16ms 正常吗?

处于临界值,很容易卡帧

屏幕刷新率    每帧预算     RV Scroll 16ms 的情况
─────────────────────────────────────────────────
60Hz         16.6ms      ⚠️ 几乎占满,稍有波动就掉帧
90Hz         11.1ms      🔴 已经超时!必掉帧
120Hz        8.3ms       🔴 严重超时!

RV Scroll 只是整帧的一部分,一帧的完整流程:

一帧 16.6ms 预算分配:
┃ input (RV Scroll) ┃ animation ┃ traversal (measure+layout+draw) ┃ RenderThread ┃
┃     16ms!        ┃           ┃                                  ┃              ┃
      ↑
 光这一步就占满了,后续步骤必然导致超时

所以 16ms 的 RV Scroll 不正常,需要优化


二、Perfetto 中如何定位问题

1. 展开卡帧的完整 Slice 堆栈

com.your.app (pid: 12345)
  ▼ main (tid: 12345)
    Expected Timeline:  ┃████┃████┃████┃████┃
    Actual Timeline:    ┃████┃████┃████████████┃  ← 🔴 红色=超时
    │
    Choreographer#doFrame ──────────────────────────────── 32ms 🔴
    ├── input ──────────────────────────────── 17ms ★ 问题在这
    │   └── RV Scroll ─────────────────────── 16ms ★★★
    │       └── dispatchNestedPreScroll
    │       └── scrollByInternal
    │           └── fill ────────────────── 14ms ★★★ 核心耗时
    │               └── recycler.getViewForPosition
    │                   └── createViewHolder ── 8ms
    │                   │   └── onCreateViewHolder
    │                   │       └── inflate ── 7ms ★ 布局inflate太慢
    │                   └── bindViewHolder ──── 5ms
    │                       └── onBindViewHolder
    │                           └── Glide.load ── 3ms ★ 图片加载
    ├── animation ─── 0.5ms
    ├── traversal ─── 8ms
    │   ├── measure ── 3ms
    │   ├── layout ─── 3ms
    │   └── draw ───── 2ms
    └── commit

2. 重点关注的 Slice

Slice 名称含义正常范围
RV ScrollRecyclerView 滚动事件处理< 3-5ms
RV CreateViewonCreateViewHolder 调用< 2-3ms
RV OnBindViewonBindViewHolder 调用< 1-2ms
RV FullInvalidate全量刷新不应出现
RV Prefetch预取机制正常行为
inflateXML 布局解析< 2ms

3. 用 SQL 精确查询

-- 查询所有 RV Scroll 耗时
SELECT
    ts / 1e6 as ts_ms,
    dur / 1e6 as dur_ms,
    name
FROM slice
WHERE name LIKE '%RV Scroll%'
ORDER BY dur DESC
LIMIT 50;
-- 查询 RecyclerView 相关的所有操作
SELECT
    name,
    COUNT(*) as count,
    AVG(dur) / 1e6 as avg_ms,
    MAX(dur) / 1e6 as max_ms,
    MIN(dur) / 1e6 as min_ms
FROM slice
WHERE name LIKE '%RV%'
GROUP BY name
ORDER BY avg_ms DESC;
-- 查看 RV Scroll 期间的子操作分布
SELECT
    s.name,
    s.dur / 1e6 as dur_ms,
    s.depth
FROM slice s
JOIN slice parent ON s.ts >= parent.ts 
    AND s.ts + s.dur <= parent.ts + parent.dur
    AND s.track_id = parent.track_id
    AND s.depth = parent.depth + 1
WHERE parent.name LIKE '%RV Scroll%'
    AND parent.dur > 10e6  -- 超过10ms的RV Scroll
ORDER BY parent.ts, s.ts;

三、RV Scroll 耗时长的常见原因

RV Scroll 16ms
    │
    ├── ① onCreateViewHolder 太慢(布局复杂/inflate 慢)
    │       └── 表现: "RV CreateView" / "inflate" 耗时长
    │
    ├── ② onBindViewHolder 太慢(主线程做了耗时操作)
    │       └── 表现: "RV OnBindView" 中有 Binder/IO/计算
    │
    ├── ③ 缓存命中率低(频繁 create 新 ViewHolder)
    │       └── 表现: 大量 "RV CreateView" 而非复用
    │
    ├── ④ ViewType 过多(每种类型都需要独立缓存池)
    │       └── 表现: 不同 viewType 的 create 交替出现
    │
    ├── ⑤ 嵌套布局过深(measure/layout 慢)
    │       └── 表现: "measure" "layout" 耗时长
    │
    ├── ⑥ notify 方式不当(notifyDataSetChanged 全量刷新)
    │       └── 表现: "RV FullInvalidate" 出现
    │
    └── ⑦ ItemDecoration / ItemAnimator 耗时
            └── 表现: draw 阶段出现自定义 decoration slice

四、针对性优化方案

① 优化 onCreateViewHolder(减少 inflate 耗时)

问题:布局 XML 复杂,inflate 耗时

// ❌ 复杂嵌套布局
<LinearLayout>
  <RelativeLayout>
    <FrameLayout>
      <ConstraintLayout>
        <ImageView/>
        <TextView/>
        <TextView/>
        <LinearLayout>
          <TextView/>
          <TextView/>
        </LinearLayout>
      </ConstraintLayout>
    </FrameLayout>
  </RelativeLayout>
</LinearLayout>

优化

// ✅ 方案1: 扁平化布局,用 ConstraintLayout 替代多层嵌套
<ConstraintLayout>  <!-- 只有一层 -->
    <ImageView />
    <TextView />
    <TextView />
    <TextView />
    <TextView />
</ConstraintLayout>

// ✅ 方案2: 使用 ViewStub 延迟加载不常用的部分
<ConstraintLayout>
    <ImageView />
    <TextView />
    <ViewStub
        android:id="@+id/stub_extra"
        android:layout="@layout/item_extra_info"
        android:inflatedId="@+id/extra_info" />
</ConstraintLayout>

// ✅ 方案3: 异步 Inflate(AndroidX)
class MyAdapter : RecyclerView.Adapter<VH>() {

    // 预创建 ViewHolder 池
    private val viewHolderPool = mutableListOf<VH>()

    fun preInflate(parent: ViewGroup, count: Int) {
        val inflater = AsyncLayoutInflater(parent.context)
        repeat(count) {
            inflater.inflate(R.layout.item_layout, parent) { view, _, _ ->
                viewHolderPool.add(VH(view))
            }
        }
    }

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH {
        // 优先从预创建池中取
        return viewHolderPool.removeFirstOrNull()
            ?: VH(LayoutInflater.from(parent.context)
                .inflate(R.layout.item_layout, parent, false))
    }
}

② 优化 onBindViewHolder(减少绑定耗时)

// ❌ 错误:在 bind 中做耗时操作
override fun onBindViewHolder(holder: VH, position: Int) {
    val item = items[position]
    
    // ❌ 主线程格式化日期
    holder.date.text = SimpleDateFormat("yyyy-MM-dd").format(item.timestamp)
    
    // ❌ 主线程解析 SpannableString
    holder.content.text = Html.fromHtml(item.htmlContent)
    
    // ❌ 同步加载图片
    val bitmap = BitmapFactory.decodeFile(item.imagePath)
    holder.image.setImageBitmap(bitmap)
    
    // ❌ 每次 bind 都 new OnClickListener
    holder.itemView.setOnClickListener { onClick(item) }
}
// ✅ 优化后
override fun onBindViewHolder(holder: VH, position: Int) {
    val item = items[position]
    
    // ✅ 提前在数据层格式化好
    holder.date.text = item.formattedDate  // 预处理
    
    // ✅ 提前在后台线程解析好
    holder.content.text = item.parsedSpannable  // 预处理
    
    // ✅ 异步加载图片
    Glide.with(holder.itemView)
        .load(item.imageUrl)
        .override(100, 100)          // 指定尺寸,避免大图解码
        .placeholder(R.drawable.ph)  // 占位图
        .into(holder.image)
    
    // ✅ 复用 listener,通过 position 区分
    holder.itemView.tag = position
    holder.itemView.setOnClickListener(sharedClickListener)
}

// 共享 listener
private val sharedClickListener = View.OnClickListener { v ->
    val pos = v.tag as Int
    onClick(items[pos])
}

③ 提高缓存命中率

// ✅ 增大 RecycledViewPool 的缓存容量
recyclerView.recycledViewPool.setMaxRecycledViews(VIEW_TYPE_NORMAL, 20)
recyclerView.recycledViewPool.setMaxRecycledViews(VIEW_TYPE_HEADER, 5)

// ✅ 增大离屏缓存
recyclerView.setItemViewCacheSize(10)  // 默认是 2

// ✅ 多个 RecyclerView 共享缓存池(如 ViewPager 中的多个列表)
val sharedPool = RecyclerView.RecycledViewPool()
recyclerView1.setRecycledViewPool(sharedPool)
recyclerView2.setRecycledViewPool(sharedPool)

④ 减少 ViewType 数量

// ❌ 过多的 ViewType
override fun getItemViewType(position: Int): Int {
    return when {
        items[position].hasImage && items[position].hasVideo -> TYPE_IMAGE_VIDEO  // 1
        items[position].hasImage -> TYPE_IMAGE       // 2
        items[position].hasVideo -> TYPE_VIDEO        // 3
        items[position].isAd -> TYPE_AD               // 4
        items[position].isHeader -> TYPE_HEADER       // 5
        else -> TYPE_NORMAL                           // 6
    }
    // 6种 ViewType = 6个独立缓存池 = 缓存命中率低
}

// ✅ 合并 ViewType,用 View.GONE 控制显隐
override fun getItemViewType(position: Int): Int {
    return if (items[position].isHeader) TYPE_HEADER else TYPE_NORMAL
    // 只有 2 种 ViewType
}

override fun onBindViewHolder(holder: VH, position: Int) {
    holder.imageView.isVisible = items[position].hasImage
    holder.videoView.isVisible = items[position].hasVideo
}

⑤ 使用 DiffUtil 替代 notifyDataSetChanged

// ❌ 全量刷新(触发 RV FullInvalidate)
fun updateData(newItems: List<Item>) {
    items = newItems
    notifyDataSetChanged()  // 所有 ViewHolder 重建!
}

// ✅ 增量刷新
fun updateData(newItems: List<Item>) {
    val diffResult = DiffUtil.calculateDiff(object : DiffUtil.Callback() {
        override fun getOldListSize() = items.size
        override fun getNewListSize() = newItems.size
        
        override fun areItemsTheSame(old: Int, new: Int): Boolean {
            return items[old].id == newItems[new].id
        }
        
        override fun areContentsTheSame(old: Int, new: Int): Boolean {
            return items[old] == newItems[new]
        }
    })
    items = newItems
    diffResult.dispatchUpdatesTo(this)
}

// ✅✅ 最佳:使用 ListAdapter(自动异步 Diff)
class MyAdapter : ListAdapter<Item, VH>(ItemDiffCallback()) {
    // submitList(newList) 自动在后台线程计算 diff
}

⑥ 开启预取(Prefetch)

// RecyclerView 默认开启 GapWorker 预取
// 确保没有被关闭
(recyclerView.layoutManager as LinearLayoutManager)
    .isItemPrefetchEnabled = true  // 默认 true

// 自定义预取数量(嵌套 RecyclerView 场景)
innerLayoutManager.initialPrefetchItemCount = 4

⑦ 其他优化

// ✅ 固定尺寸(避免每次 measure 整个 RecyclerView)
recyclerView.setHasFixedSize(true)

// ✅ 滑动时暂停图片加载
recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() {
    override fun onScrollStateChanged(rv: RecyclerView, newState: Int) {
        when (newState) {
            RecyclerView.SCROLL_STATE_DRAGGING,
            RecyclerView.SCROLL_STATE_SETTLING -> {
                Glide.with(rv.context).pauseRequests()
            }
            RecyclerView.SCROLL_STATE_IDLE -> {
                Glide.with(rv.context).resumeRequests()
            }
        }
    }
})

// ✅ 关闭默认动画(如果不需要)
recyclerView.itemAnimator = null

// ✅ 使用 RecyclerView.setItemViewCacheSize 增加缓存
recyclerView.setItemViewCacheSize(20)

五、优化前后 Perfetto 对比

优化前 🔴

Choreographer#doFrame ──────────────────────────────── 35ms
├── input ──────────────────────────────── 18ms
│   └── RV Scroll ─────────────────────── 16ms
│       └── fill ──────────────────────── 14ms
│           ├── createViewHolder ────────── 8ms  (inflate 复杂布局)
│           └── bindViewHolder ──────────── 5ms  (主线程解析HTML)
├── traversal ──────────────────────────── 12ms
│   ├── measure ────────────────────────── 6ms  (深层嵌套)
│   ├── layout ─────────────────────────── 4ms
│   └── draw ───────────────────────────── 2ms

优化后 🟢

Choreographer#doFrame ──────────────────── 8ms
├── input ──────────── 3ms
│   └── RV Scroll ──── 2.5ms
│       └── fill ────── 2ms
│           └── bindViewHolder ── 1.5ms  (复用 ViewHolder,轻量 bind)
├── traversal ──────── 4ms
│   ├── measure ────── 1.5ms  (扁平布局)
│   ├── layout ─────── 1.5ms
│   └── draw ────────── 1ms

六、优化检查清单

□ 布局层级 ≤ 3 层(用 Layout Inspector 检查)
□ onCreateViewHolder 中的 inflate < 3ms
□ onBindViewHolder < 2ms,无 IO/Binder/重计算
□ 使用 DiffUtil / ListAdapter 而非 notifyDataSetChanged
□ setHasFixedSize(true)
□ 合理的 ViewType 数量(≤ 3-4 种)
□ RecycledViewPool 容量充足
□ 图片异步加载 + 指定尺寸
□ 无主线程 GC 压力(避免 bind 中创建大量临时对象)
□ 预取开启(isItemPrefetchEnabled = true