RecyclerView 源码分析

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

scss 复制代码
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

kotlin 复制代码
// 调整 mCachedViews 容量,默认 2
recyclerView.setItemViewCacheSize(10)

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

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

二、LayoutManager 与布局优化

2.1 布局流程

scss 复制代码
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()

java 复制代码
// 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)

java 复制代码
// 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 四个方法

java 复制代码
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 局部刷新机制

scss 复制代码
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 正确实现局部刷新

kotlin 复制代码
// 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

sql 复制代码
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 中的使用

java 复制代码
// 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 自定义解决滑动冲突

kotlin 复制代码
// 子 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、减少布局层级、预取
DiffUtil Myers 算法求最少编辑;areItemsTheSame/areContentsTheSame;大列表后台计算
局部刷新 notifyItemChanged(pos, payload) + onBindViewHolder(holder, pos, payloads) 分支处理
滑动冲突 requestDisallowInterceptTouchEvent 在触摸过程中调用;ACTION_DOWN 会重置标志

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

相关推荐
南城书生1 小时前
LeakCanary 原理分析
前端
没想好d1 小时前
通用管理后台组件库-13-页签组件
前端
xChive1 小时前
ECharts-大屏开发复习记录与踩坑总结
前端·javascript·echarts
南城书生2 小时前
Java HashMap 源码分析
前端
南城书生2 小时前
Java 线程池(ThreadPoolExecutor)源码分析
前端
前端Hardy2 小时前
别再靠 Code Review 纠格式了!一套自动化前端工程化方案,让 Vue 项目提交即合规
前端·程序员·代码规范
前端Hardy2 小时前
用 uni-app x 重构我们的 App:一套代码跑通 iOS、Android、鸿蒙!人力成本直降 60%
前端·ios·uni-app
林恒smileZAZ2 小时前
告别满屏 v-if:用一个自定义指令搞定 Vue 前端权限控制
前端·javascript·vue.js
南城书生2 小时前
Android View 绘制流程
前端