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, ...)
查找顺序(源码逻辑):
- mAttachedScrap :按 position 或 itemId 查找,命中则直接返回,不调用 onBindViewHolder
- mChangedScrap:仅当 item 被标记为 changed 时查找,命中则需 rebind
- mCachedViews :按 position 或 itemId 精准匹配,命中则直接返回,不调用 onBindViewHolder
- mViewCacheExtension:自定义扩展(若有)
- RecycledViewPool :按 viewType 取,取到后必须调用 onBindViewHolder
- 创建新 ViewHolder :
mAdapter.createViewHolder()→onBindViewHolder()
1.3 回收流程
滑动时,移出屏幕的 ViewHolder 会依次进入:
- 先尝试放入 mCachedViews(未满时)
- mCachedViews 满时,按 FIFO 将最老的移入 RecycledViewPool
- 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 核心步骤
- 确定锚点(Anchor) :
updateAnchorInfoForLayout()计算起始位置与偏移 - 向 start 方向填充 :
fill(recycler, layoutState, state) - 向 end 方向填充 :
fill(recycler, layoutState, state) - 滚动微调 :
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 的 onBindViewHolder;payload == 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