RecyclerView 中的核心组件

  • RecyclerView:承载整个列表,处理触摸交互、边界判断,并协调 Recycler(回收器)与 Adapter(适配器)之间的工作。

  • LayoutManager:负责子 View 的测量与布局,决定何时回收 View。它通过 getViewForPosition() 向 Recycler 索取可复用的 View,并管理滚动逻辑与布局策略。

  • ViewHolder:对单个 item View 的封装,持有缓存的子控件引用,避免重复调用 findViewById,提升性能。

  • Recycler:RecyclerView 内部的“回收管理者”。它根据缓存策略,优先复用不同状态下的 ViewHolder,再将其交给 LayoutManager 使用。

  • Adapter:负责数据与 View 之间的桥接。当 Recycler 内部没有合适的 ViewHolder 时,会调用 onCreateViewHolder() 创建新的 ViewHolder,然后通过 onBindViewHolder() 为其绑定数据。

简易的运行机制

  1. LayoutManager 请求 View:通过 getViewForPosition() 向 Recycler 要求一个指定位置的 View。

  2. ViewHolder 创建/复用:如果 Recycler 内部没有合适的缓存,Adapter 会调用 onCreateViewHolder() 创建新的 ViewHolder。

  3. 数据绑定:Adapter 调用 onBindViewHolder(),为新创建或复用的 ViewHolder 绑定对应的数据。

  4. 布局展示:Recycler 将绑定好数据的 View 交给 LayoutManager,完成测量与摆放,显示在屏幕上。

RecyclerView 的复用机制

RecyclerView 的复用机制主要体现在两类场景

  • 滑动场景:重复利用滑出屏幕的 ViewHolder,避免重复创建和绑定。

  • 数据更新场景:通过 Scrap 列表进行局部刷新,最大程度地复用已有 ViewHolder。

滑动场景的复用机制

当用户滚动列表时,部分 ViewHolder 会滑出/滑入屏幕。RecyclerView 使用 CachedViewsRecycledViewPool 两级缓存来管理复用:

  • 滑出屏幕 → 回收流程

    • 滑出的 ViewHolder 优先进入 CachedViews(默认容量为 2)。

    • 超过容量后,会将最早进入的 ViewHolder 转存到 RecycledViewPool

    • RecycledViewPool 是一个 Map<viewType, List<ViewHolder>>,默认容量为 5。容量满时,新加入的 ViewHolder 会被直接丢弃。

  • 滑入屏幕 → 复用流程

    1. 先从 CachedViews 中查找。如果命中并且 position 相同,可以直接复用,不需要重新绑定数据。

    2. 如果未命中,再从 RecycledViewPool 中查找。如果存在相同 viewType 的 ViewHolder,则复用,但需要重新调用 onBindViewHolder() 绑定数据。

    3. 如果都未命中,才会走 onCreateViewHolder() 创建新的 ViewHolder。

数据更新的复用机制

当调用 Adapter 的 notifyItemChanged()notifyItemInserted() 等方法时,RecyclerView 会通过 Scrap 列表复用屏幕上已有的 ViewHolder,避免重复创建,流程如下:

  1. 分离当前屏幕上的 item

    • 将屏幕上的所有 ViewHolder 从布局中临时移除,放入 Scrap 列表中。

    • mAttachedScrap:保存正常分离的 ViewHolder。

    • mChangedScrap:保存那些因数据变化(如 notifyItemChanged)而需要做动画的 ViewHolder。

  2. 重新布局

    • LayoutManager 根据新的数据顺序,优先从 mAttachedScrap 中取出对应 ViewHolder。

    • 如果没有,再尝试从 CachedViewsRecycledViewPool 中复用。

    • 最后才会调用 Adapter 创建新的 ViewHolder。

  3. 清理工作

    • 布局完成后,Scrap 中未被重新利用的 ViewHolder,会被转存到 RecycledViewPool 中。

这样,RecyclerView 能够在数据局部更新的同时,最大化复用已有 ViewHolder,避免全量刷新,提高性能和动画体验。

ListView 与 RecyclerView 的区别

早期的 ListView 在 Android 开发中使用非常广泛,但它的适配器(Adapter)机制相对简单。
在数据刷新方面,ListView 只提供了 notifyDataSetChanged() 方法:

  • 调用该方法时,系统会认为整个数据集发生了变化;

  • ListView 会重新走一遍 getView(),对屏幕上所有可见的 item 进行数据绑定;

  • 即使只有一个 item 更新,ListView 也会对全部可见 item 重新绑定数据;

这会带来两个问题:

  1. 性能浪费:无效的 bind 次数增加,影响流畅度;

  2. 无法做精细动画:所有 item 一起刷新,没有插入、删除、移动的过渡效果。

相比之下,RecyclerView 在 Adapter 中提供了更精细的通知方法:

  • notifyItemChanged(position):更新单个 item;

  • notifyItemInserted(position):插入新的 item;

  • notifyItemRemoved(position):删除指定位置的 item;

  • notifyItemMoved(from, to):移动 item;

  • notifyItemRangeXXX(...):对一段范围做批量更新。

通过这些方法,RecyclerView 可以只刷新真正发生变化的 item,大幅度减少不必要的 bind 操作。同时,RecyclerView 还配合 ItemAnimator,在增删改的过程中提供平滑的动画过渡,用户体验显著优于 ListView。

因此,ListView 更适合小数据量的简单场景,而 RecyclerView 更适合大数据量高性能高交互的场景。

嵌套 RecyclerView 的滑动冲突问题

在实际开发中,我们经常会遇到 RecyclerView 内部再嵌套 RecyclerView 的场景。例如,一个竖向的父 RecyclerView,每个 item 中又包含一个固定高度的竖向 RecyclerView。当用户上下滑动时,默认情况下事件会被父 RecyclerView 拦截,导致子 RecyclerView 无法滚动。

问题溯源

Android 的事件分发机制是 自顶向下分发、自底向上传递 的过程。父容器(ViewGroup)在分发事件时会调用 onInterceptTouchEvent() 判断是否要拦截。如果父容器拦截了,那么子 View 就完全拿不到触摸事件。

onInterceptTouchEvent(MotionEvent ev) 是 ViewGroup 决定要不要把事件截留在自己这里处理的关键方法,默认实现是 返回 false(不拦截)RecyclerView 在内部重写了 onInterceptTouchEvent(),当检测到是竖直滑动 且自己能滚动时,就会返回 true,把事件截留在自己这里,从而开始列表的滑动逻辑。

因此在嵌套 RecyclerView 的场景中,父 RecyclerView 默认会优先消费上下滑动事件,因此子 RecyclerView 很难有机会响应。

解决思路

在分析 ViewGroup 的事件分发源码时可以发现,dispatchTouchEvent() 在调用 onInterceptTouchEvent() 之前,会先检查一个标志位 FLAG_DISALLOW_INTERCEPT

if (mGroupFlags & FLAG_DISALLOW_INTERCEPT) {
    // 已被 requestDisallowInterceptTouchEvent(true) 设置过了
    intercepted = false; // 直接禁止拦截
} else {
    intercepted = onInterceptTouchEvent(ev);
}

也就是说,如果子 View 调用了 requestDisallowInterceptTouchEvent(true),父容器的 onInterceptTouchEvent() 将不会再被执行,这相当于“强行禁止”了拦截。

另外,RecyclerViewonInterceptTouchEvent() 方法中默认不会拦截 ACTION_DOWN 事件,所以子 RecyclerView 能够顺利接收到手势的第一帧(DOWN)。这就给了子 View 一个“窗口期”:在 DOWN 或最早的 MOVE 阶段调用 requestDisallowInterceptTouchEvent(true),阻止父容器后续的拦截。这样整个触摸序列就能稳定地交给子 RecyclerView 处理。等到手势结束或子 View 滚动到边界,再把拦截权限还给父容器即可。

实现方案

下面是一个常见的实现方式:通过给子 RecyclerView 设置 OnTouchListener,在触摸过程中动态判断是否需要让父容器拦截事件。

holder.rv.layoutManager = LinearLayoutManager(holder.rv.context)
holder.rv.adapter = ChildAdapter2()

var lastY = 0f
holder.rv.setOnTouchListener { v, event ->
    val view = v as RecyclerView
    when (event.actionMasked) {
        MotionEvent.ACTION_DOWN -> {
            lastY = event.y
            // 通知父容器:本次手势开始,先别拦截
            v.parent.requestDisallowInterceptTouchEvent(true)
        }

        MotionEvent.ACTION_MOVE -> {
            val dy = event.y - lastY
            lastY = event.y

            val draggingDown = dy > 0       // 手势向下
            val draggingUp = dy < 0         // 手势向上

            val canScrollUp = view.canScrollVertically(-1)   // 内容还能往下拖
            val canScrollDown = view.canScrollVertically(1)  // 内容还能往上拖

            val childCanConsume =
                (draggingDown && canScrollUp) || (draggingUp && canScrollDown)

            // 如果子 RV 能消费事件 -> 禁止父拦截
            // 如果子 RV 已经到边界 -> 允许父继续拦截
            v.parent.requestDisallowInterceptTouchEvent(childCanConsume)
        }

        MotionEvent.ACTION_CANCEL, MotionEvent.ACTION_UP -> {
            // 手势结束,允许父容器恢复拦截
            v.parent.requestDisallowInterceptTouchEvent(false)
        }
    }
    false // 返回 false,不截断事件链,让 RV 的自身逻辑继续处理
}

RecyclerView 的预加载机制

RecyclerView 的卡顿优化

主要原因是 UI 线程被阻塞:

  1. item 布局层级过深,测量和绘制开销大。

  2. onBindViewHolder 里做了耗时操作(比如图片解码、复杂计算)。

  3. 图片加载未优化(大图未压缩、无缓存)。

  4. 使用 notifyDataSetChanged 导致全量刷新。

  5. 内存抖动频繁 GC。

  6. 动画、过度绘制开销大。

卡顿的优化思路:

  1. 布局优化:减少层级,优先用 ConstraintLayout,减少 measure/layout。

  2. 异步加载:图片、数据处理放后台线程,UI 层只做绑定。

  3. 缓存 & 复用:ViewHolder 缓存、setItemViewCacheSize()、图片缓存。

  4. 局部刷新:用 notifyItemChanged/Inserted/Removed 或 DiffUtil,避免全量刷新。

  5. 图片优化:用 Glide/Coil,设置占位图、合理缩放。

  6. 禁用不必要动画:recyclerView.setItemAnimator(null)。

  7. 减少过度绘制:避免多层背景、检查 GPU Overdraw。