RecyclerView 源码分析

12 阅读6分钟

RecyclerView 源码分析:复用、优化、DiffUtil、局部刷新、滑动冲突

源码参考:AndroidX RecyclerView (Recycler.java, LayoutManager, DiffUtil.java)


一、ViewHolder 复用机制

1.1 四级缓存结构

RecyclerView 通过 Recycler 类管理 ViewHolder 的复用,采用四级缓存

缓存层级名称说明是否需要 onBindViewHolder
一级mAttachedScrap布局时从屏幕分离的 ViewHolder,仍 attached 到 RecyclerView若 position/itemId 匹配则不需要
一级mChangedScrap通过 notifyItemChanged 等标记为「已变化」的 ViewHolder需要重新绑定
二级mCachedViews滑动时刚移出屏幕的 ViewHolder,默认最多 2 个若 position/itemId 匹配则不需要
三级mViewCacheExtension开发者自定义缓存(默认 null)由实现决定
四级RecycledViewPool按 viewType 存储的缓存池,每 type 默认 5 个需要重新绑定

1.2 复用流程:tryGetViewHolderForPositionByDeadline

LayoutManager.layoutChunk()
    → recycler.getViewForPosition(position)
        → tryGetViewHolderForPositionByDeadline(position, ...)

查找顺序(源码逻辑):

  1. mAttachedScrap:按 position 或 itemId 查找,命中则直接返回,不调用 onBindViewHolder
  2. mChangedScrap:仅当 item 被标记为 changed 时查找,命中则需 rebind
  3. mCachedViews:按 position 或 itemId 精准匹配,命中则直接返回,不调用 onBindViewHolder
  4. mViewCacheExtension:自定义扩展(若有)
  5. RecycledViewPool:按 viewType 取,取到后必须调用 onBindViewHolder
  6. 创建新 ViewHoldermAdapter.createViewHolder()onBindViewHolder()

1.3 回收流程

滑动时,移出屏幕的 ViewHolder 会依次进入:

  1. 先尝试放入 mCachedViews(未满时)
  2. mCachedViews 满时,按 FIFO 将最老的移入 RecycledViewPool
  3. RecycledViewPool 满时,丢弃最老的 ViewHolder

关键点:先复用再回收。新显示的 item 优先从缓存取 ViewHolder,随后才回收被移出屏幕的 item。

1.4 相关 API

// 调整 mCachedViews 容量,默认 2
recyclerView.setItemViewCacheSize(10)

// 多个 RecyclerView 共享缓存池
recyclerView.setRecycledViewPool(sharedPool)

// 自定义缓存(较少使用)
recyclerView.setViewCacheExtension(customCache)

二、LayoutManager 与布局优化

2.1 布局流程

RecyclerView.onMeasure() / onLayout()
    → dispatchLayout()
        → dispatchLayoutStep1()  // 预布局,处理动画dispatchLayoutStep2()  // 实际布局dispatchLayoutStep3()  // 动画收尾

dispatchLayoutStep2 中调用 LayoutManager.onLayoutChildren(recycler, state)

2.2 LinearLayoutManager.onLayoutChildren 核心步骤

  1. 确定锚点(Anchor)updateAnchorInfoForLayout() 计算起始位置与偏移
  2. 向 start 方向填充fill(recycler, layoutState, state)
  3. 向 end 方向填充fill(recycler, layoutState, state)
  4. 滚动微调scrollToPosition()

2.3 fill() 与 layoutChunk()

// fill 内部循环
while (layoutState.hasMore(state)) {
    layoutChunk(recycler, state, layoutState, layoutChunkResult);
    layoutState.mOffset += layoutChunkResult.mConsumed;
}

// layoutChunk 核心
View view = layoutState.next(recycler);  // 内部调用 getViewForPosition
measureChildWithMargins(view, ...);      // 测量
layoutDecoratedWithMargins(view, ...);   // 布局

优化要点:只对可见区域内的 item 进行 measure/layout,不会一次性加载全部数据。

2.4 常见优化手段

优化项说明
setHasFixedSize(true)item 尺寸固定时,跳过 measure 计算
setItemViewCacheSize()增大 mCachedViews,减少滑动时的 rebind
减少 item 布局层级降低 measure/layout 耗时
预取(Prefetch)RecyclerView 在空闲时预取即将进入屏幕的 ViewHolder
getItemId() 返回稳定 id便于精准复用,减少 rebind

三、DiffUtil 原理

3.1 算法概述

DiffUtil 使用 Eugene W. Myers 差分算法,计算将旧列表转换为新列表的最少编辑操作

  • 空间复杂度:O(N)
  • 时间复杂度:O(N + D²),D 为编辑脚本长度
  • 移动检测:detectMoves=true 时,额外 O(M×N),M 为新增数,N 为删除数

3.2 核心流程(DiffUtil.calculateDiff)

// 1. 初始化
stack.add(new Range(0, oldSize, 0, newSize));
CenteredArray forward, backward;  // k 线数组

// 2. 迭代找 Snake(对角线匹配)
while (!stack.isEmpty()) {
    Range range = stack.pop();
    Snake snake = midPoint(range, cb, forward, backward);
    if (snake != null) {
        if (snake.diagonalSize() > 0) diagonals.add(snake.toDiagonal());
        stack.add(leftRange);   // 左半部分
        stack.add(rightRange);  // 右半部分
    }
}

// 3. 排序并构建 DiffResult
Collections.sort(diagonals, DIAGONAL_COMPARATOR);
return new DiffResult(cb, diagonals, ..., detectMoves);

3.3 Snake 与 Diagonal

  • Snake:在 (oldList, newList) 二维矩阵中的一条匹配路径,可包含 add/remove 边
  • Diagonal:纯对角线段,表示两列表在该区间的元素相同
  • midPoint:在 range 内找中间 Snake,将问题分治为左右两段

3.4 Callback 四个方法

public abstract static class Callback {
    int getOldListSize();
    int getNewListSize();
    boolean areItemsTheSame(int oldPos, int newPos);   // 身份:是否同一项
    boolean areContentsTheSame(int oldPos, int newPos); // 内容:是否相同
    Object getChangePayload(int oldPos, int newPos);   // 可选:局部更新 payload
}
  • areItemsTheSame:判断是否为同一逻辑项(如 id 相同)
  • areContentsTheSame:仅在 areItemsTheSame 为 true 时调用,判断内容是否变化
  • getChangePayload:内容变化时返回 payload,用于 onBindViewHolder(holder, position, payloads) 局部刷新

3.5 使用建议

  • 大列表应在后台线程执行 calculateDiff(),主线程只做 diffResult.dispatchUpdatesTo(adapter)
  • 列表已按同一规则排序且无移动时,可设 detectMoves=false 提升性能
  • 配合 ListAdapter / AsyncListDiffer 可简化异步 diff 流程

四、局部刷新

4.1 notify 系列方法

方法作用
notifyDataSetChanged()全量刷新,无动画,可能闪烁
notifyItemChanged(position)单 item 更新,payload=null 时完整 rebind
notifyItemChanged(position, payload)单 item 更新,支持局部刷新
notifyItemInserted/Removed/Moved()增删移,有默认动画

4.2 payload 局部刷新机制

notifyItemChanged(position, payload)
    → AdapterDataObservable.notifyItemRangeChanged(position, 1, payload)
        → RecyclerViewDataObserver.onItemRangeChanged()
            → AdapterHelper 记录 UpdateOp(payload)
                → ViewInfoStore / 布局时传递 payload
                    → onBindViewHolder(holder, position, payloads)

关键payload != null 时,会调用带 payload 的 onBindViewHolderpayload == null 时等价于完整刷新。

4.3 正确实现局部刷新

// 1. 调用时传入 payload
adapter.notifyItemChanged(position, "like_count")  // 或任意 Object

// 2. Adapter 中重写三参数 onBindViewHolder
override fun onBindViewHolder(holder: ViewHolder, position: Int, payloads: MutableList<Any>) {
    if (payloads.isEmpty()) {
        // 完整绑定
        bindFull(holder, getItem(position))
    } else {
        // 按 payload 类型局部更新
        payloads.forEach { payload ->
            when (payload) {
                "like_count" -> holder.updateLikeCount(getItem(position).likeCount)
                "avatar" -> holder.updateAvatar(getItem(position).avatarUrl)
            }
        }
    }
}

注意:payload 为 null 或空时,会触发完整 rebind,可能导致图片重新加载、闪烁。应尽量传入有意义的 payload,并在 onBindViewHolder 中区分处理。


五、滑动冲突

5.1 事件分发与 requestDisallowInterceptTouchEvent

ViewGroup.dispatchTouchEvent()
    → 若 FLAG_DISALLOW_INTERCEPT 为 true,则跳过 onInterceptTouchEvent
    → 直接分发给子 View

requestDisallowInterceptTouchEvent(true):子 View 请求父 View 不拦截后续事件。父 View 在 dispatchTouchEvent 中会检查该标志,从而不再执行 onInterceptTouchEvent

5.2 重要:ACTION_DOWN 会重置标志

每次 ACTION_DOWN 时,ViewGroup.resetTouchState() 会将 FLAG_DISALLOW_INTERCEPT 置为 false。因此必须在触摸过程中(如 ACTION_MOVE)动态调用 requestDisallowInterceptTouchEvent(true),而不能在初始化时调用一次了事。

5.3 RecyclerView 中的使用

// RecyclerView 在可滚动且发生实际滚动时
if (scrollByInternal(...)) {
    getParent().requestDisallowInterceptTouchEvent(true);
}

这样在用户滑动 RecyclerView 时,父容器(如 ViewPager、ScrollView)不会拦截事件,避免滑动冲突。

5.4 嵌套滑动冲突的常见场景

场景处理思路
RecyclerView 内嵌横向 RecyclerView子 RV 在可横向滑动时调用 parent.requestDisallowInterceptTouchEvent(true)
RecyclerView 在 ViewPager 中ViewPager 与 RecyclerView 滑动方向一致时易冲突,需根据滑动方向决定谁处理
RecyclerView 在 SwipeRefreshLayout 中下拉刷新与 RV 垂直滑动冲突,SRL 通常已处理;部分库(如 SmartRefreshLayout)重写 requestDisallowInterceptTouchEvent 可能导致异常

5.5 自定义解决滑动冲突

// 子 RecyclerView 在 dispatchTouchEvent 或 onTouchEvent 中
override fun dispatchTouchEvent(ev: MotionEvent): Boolean {
    when (ev.action) {
        MotionEvent.ACTION_DOWN -> parent?.requestDisallowInterceptTouchEvent(true)
    }
    return super.dispatchTouchEvent(ev)
}

或通过 OnTouchListener 在合适的时机请求父 View 不拦截。


六、总结对照表

主题核心要点
复用四级缓存:Scrap → CachedViews → Extension → Pool;按 position/itemId 精准匹配可避免 rebind
优化setHasFixedSize、增大 cacheSize、稳定 getItemId、减少布局层级、预取
DiffUtilMyers 算法求最少编辑;areItemsTheSame/areContentsTheSame;大列表后台计算
局部刷新notifyItemChanged(pos, payload) + onBindViewHolder(holder, pos, payloads) 分支处理
滑动冲突requestDisallowInterceptTouchEvent 在触摸过程中调用;ACTION_DOWN 会重置标志

参考:AndroidX RecyclerView 源码、DiffUtil.java、kotlin-standards.mdc