在 Android 开发里,只要是做过列表页的,基本都绕不开 RecyclerView。用得多了,就一定会碰到那个让无数新人头皮发麻的场景:RecyclerView 嵌套 RecyclerView,也就是所谓的两级列表、双层级列表、或者叫分组列表。外层是一个纵向滑动的 RecyclerView,每一项里面又套了一个横向或者纵向的 RecyclerView。
这种布局在电商 App 的商品分类、首页的模块聚合、或者后台管理系统的多级菜单里非常常见。但真正上手写过的人都知道,外层适配器里怎么给内层 RecyclerView 设值,绝对不是写两行 setAdapter 那么简单的事。数据错乱、滑动卡顿、高度不显示、内层数据丢更新,这些问题我不止一次在项目里遇到过,也帮不少人排查过。这篇文章就把这整件事从头到尾掰开揉碎讲清楚,核心聚焦在一个问题上:外层 RecyclerView 的条目里,内层 RecyclerView 的数据到底该怎么设进去,才能既不出错、又不卡。
先给个结论:内层 RecyclerView 的设值,本质上是在外层 ViewHolder 的 onBindViewHolder 回调里完成的,但关键在于这个"设值"动作的时机、数据来源、以及复用策略。很多人的写法看起来对,实际跑起来就是各种诡异问题。下面从设计思路到具体代码,再到问题排查,一条线讲透。
1. 整体设计与思路拆解:嵌套 RecyclerView 的本质难点
1.1 到底是谁在什么时候创建了内层 RecyclerView
要搞清楚设值问题,先得明白布局是怎么拼出来的。外层 RecyclerView 的每个 item 布局 XML 里,直接放了一个 RecyclerView 控件。外层适配器在 onCreateViewHolder 的时候,会把整个 item 布局解析出来,其中就包含了这个内层 RecyclerView。也就是说,内层 RecyclerView 的实例,是在外层 onCreateViewHolder 时创建出来的,而且每个可见的外层条目,都有自己独立的一个内层 RecyclerView 对象。
这个事实是理解一切问题的基石。因为 RecyclerView 的核心机制是 ViewHolder 复用,也就是说屏幕外的外层条目回收后,它的 ViewHolder 连同里面的内层 RecyclerView 会一起被缓存起来,下一次滚回来的时候,又会重新走 onBindViewHolder 更新数据。所以内层 RecyclerView 对象本身是复用的,但它的内容(内层适配器的数据源)必须每次在 onBindViewHolder 时重新设置,否则就会显示上一次的旧数据,也就是所谓的数据错乱。
1.2 嵌套结构下"设值"到底意味着什么
普通 RecyclerView 的设值,就是 adapter.setXxxList(data) 然后 adapter.notifyDataSetChanged()。到了嵌套场景,很多人第一时间会想:我在外层适配器里拿到当前外层条目对应的内层数据集合,然后 new 一个内层 Adapter,再 setAdapter,直接往里塞。这种"即用即建"的思路从逻辑上讲完全没错,但性能上很糟。
因为 onBindViewHolder 是很高频的方法。用户每滑动一格,屏幕上会有大量外层条目被重新绑定。如果在每次绑定的时候都新建内层 Adapter、重新 new 内层 RecyclerView 的 LayoutManager,就算功能上能跑通,内存和 CPU 的开销也会相当可观。跑几下就能明显感觉到列表变"肉",尤其是在低端机上,滑动掉帧是必然的。
所以,设值的核心难点不是"怎么把数据传进去",而是"复用已有的 ViewHolder 和内层 Adapter,然后高效地把新数据塞进去,并触发正确的刷新"。
可以这么类比:外层 RecyclerView 是一个酒店走廊,每个房间(外层 ViewHolder)里摆了好多格子货架(内层 RecyclerView)。客人入住时,你得把对应的商品摆到货架上。你不能每次客人来都把所有货架砸了重建,也不能商品摆错了不换。正确做法是,货架一直留着,每次入住把货架上的商品换成入住清单对应的那批。
2. 内层 RecyclerView 数据注入的三种模式与场景选型
2.1 无脑模式:每次 onBindViewHolder 新建 Adapter 设值
直接上代码,这种是最容易想到的写法:
@Override public void onBindViewHolder(@NonNull OuterVH holder, int position) { OuterBean bean = dataList.get(position); List<InnerBean> innerList = bean.getInnerList(); InnerAdapter innerAdapter = new InnerAdapter(innerList); holder.innerRecyclerView.setAdapter(innerAdapter); }如果只有一屏、数据量小、内层条目数量很少,这代码能跑。但它有两个硬伤。
第一,每次绑定都会新建 Adapter 对象。加上常用的 LinearLayoutManager 也是新建,如果还新建一些 ItemDecoration、点击监听等对象,开销更大。第二,setAdapter 会触发内层 RecyclerView 的完整重新测量和布局,代价很高。即使数据完全没变,只是外层上下滑动导致 onBindViewHolder 再走一遍,整个内层列表也会重新创建 ViewHolder、重新排一遍。
这种模式适合什么场景?适合那种外层列表不会上下滑动、或者外层条目极少(比如三五个)的小工具页面。真要在正式 App 里大面积使用,卡顿是必然的。
2.2 保守模式:在 onBindViewHolder 中更新已有 Adapter 的数据
稍微进阶一点的写法:不每次新建 Adapter,而是外层 ViewHolder 持有内层 Adapter 实例,在 onBindViewHolder 时只更新数据,然后触发刷新。
class OuterVH extends RecyclerView.ViewHolder { RecyclerView innerRv; InnerAdapter innerAdapter; public OuterVH(View itemView) { super(itemView); innerRv = itemView.findViewById(R.id.inner_rv); innerRv.setLayoutManager(new LinearLayoutManager(itemView.getContext(), LinearLayoutManager.VERTICAL, false)); innerAdapter = new InnerAdapter(new ArrayList<>()); innerRv.setAdapter(innerAdapter); } } @Override public void onBindViewHolder(@NonNull OuterVH holder, int position) { OuterBean bean = dataList.get(position); List<InnerBean> innerList = bean.getInnerList(); holder.innerAdapter.setData(innerList); }内层 Adapter 里的 setData 方法:
public void setData(List<InnerBean> newList) { this.dataList.clear(); this.dataList.addAll(newList); notifyDataSetChanged(); }这里有个细节,如果直接this.dataList = newList再 notifyDataSetChanged 也是可以的,但要注意这样会丢掉原来的引用,如果其他地方持有旧引用就会出问题。我习惯用 clear + addAll,这样只改内容不改引用,比较安全。
这个模式的好处是 Adapter 和 LayoutManager 只创建一次,onBindViewHolder 里只做数据替换加一次通知,开销小很多。坏处是每次绑定都会 notifyDataSetChanged,虽然比 setAdapter 轻,但对于内层数据没变化的情况,还是会浪费一次全量刷新。
这个模式我应该推荐给大多数场景,因为它是安全性和性能的均衡点,绝大多数嵌套列表页面用这种写法已经足够了。而且这个模式配合 DiffUtil 还可以更进一步优化,只更新变化的那一项,而不是整个内层列表全部重画,后文会细说。
2.3 优化模式:ViewHolder 复用 + 局部刷新
如果你的外层列表有很多条目,每个内层列表有几十上百条数据,或者数据更新频率很高,那么保守模式也会有点扛不住。这时候要上局部刷新,核心工具就是 DiffUtil 或者 ListAdapter。
public void setData(List<InnerBean> newList) { DiffUtil.DiffResult diffResult = DiffUtil.calculateDiff(new DiffUtil.Callback() { @Override public int getOldListSize() { return dataList.size(); } @Override public int getNewListSize() { return newList.size(); } @Override public boolean areItemsTheSame(int oldPos, int newPos) { return dataList.get(oldPos).getId() == newList.get(newPos).getId(); } @Override public boolean areContentsTheSame(int oldPos, int newPos) { return dataList.get(oldPos).equals(newList.get(newPos)); } }); dataList.clear(); dataList.addAll(newList); diffResult.dispatchUpdatesTo(this); }DiffUtil 计算的是新旧两个列表的差异,然后只调用对应的 notifyItemInserted、notifyItemRemoved、notifyItemChanged 之类的方法。这样内层 RecyclerView 只会重画真正变化的那几项,效率会高非常多。
但要注意,DiffUtil 不是免费的午餐。它本身也要遍历列表算差异,在内层列表数据量很大的时候,这个计算成本也不能忽略。一般说来,内层条目少于 100 个、数据不常变,用保守模式就够了;内层数据经常局部变化、或者单个内层列表项特别重,才值得上 DiffUtil。
我在一个电商项目里实测过,一个外层条目对应 60~80 个内层商品,整个页面外层有 20 多个条目。用保守模式时滑动起来偶尔掉帧,换成 ListAdapter 加 DiffUtil 之后,内层刷新几乎是无感的。需要注意的是,这里 DiffUtil 的线程问题:calculateDiff 默认在主线程同步执行,如果数据量大,建议用 AsyncListDiffer 的方案,它内部会走线程池。
2.4 三种模式对比速查
| 模式 | 实现复杂度 | 内存开销 | 刷新粒度 | 适用场景 |
|---|---|---|---|---|
| 每次新建 Adapter 设值 | 最低 | 高 | 全量重建 | 数据量极小、几乎不滑动的静态页 |
| ViewHolder 持有 Adapter 并更新数据 | 中等 | 低 | 全量通知 | 大多数嵌套列表场景,首选方案 |
| DiffUtil/ListAdapter 局部刷新 | 较高 | 低 | 单条增量 | 内层数据频繁变化、单条目渲染重的场景 |
3. 核心细节解析与实操要点
3.1 内层 LayoutManager 的初始化位置
许多人在 onBindViewHolder 里写了 setLayoutManager,和 2.1 里 setAdapter 放在一起。这属于可以运行但是不该这么写。LayoutManager 和 Adapter 一样,是 ViewHolder 级别的状态,应该只在 onCreateViewHolder 里初始化一次。
理由很简单:LayoutManager 本身是一个轻量级对象吗?LinearLayoutManager 内部有简单的缓存,但真正的问题不是创建开销,而是 setLayoutManager 这种操作发生在绑定阶段,会打乱 RecyclerView 内部对 item 布局的管理状态。说得再直白一点,在 onBindViewHolder 里 setLayoutManager,相当于每次都重新告诉 RecyclerView "我要用这种排列方式",那么它之前记录的滚动位置、子视图缓存信息,都会变得不可靠。这在某些机型上甚至会触发重新测量和布局,直接导致滚动位置乱跳。
正确的做法是:
public OuterVH(View itemView) { super(itemView); innerRv = itemView.findViewById(R.id.inner_rv); LinearLayoutManager layoutManager = new LinearLayoutManager(itemView.getContext()); layoutManager.setOrientation(LinearLayoutManager.HORIZONTAL); innerRv.setLayoutManager(layoutManager); }注意,如果你的内层布局方向是固定的,水平就水平,竖直就竖直,别在绑定阶段去改方向。
3.2 内层 Adapter 的创建与复用
前面说了,内层 Adapter 在 onCreateViewHolder 创建,在绑定阶段只更新数据。但还有一个细节容易被忽略:内层 Adapter 的数据列表初始化为空列表,而不是 null。
private List<InnerBean> dataList = new ArrayList<>(); public void setData(List<InnerBean> list) { dataList.clear(); if (list != null) { dataList.addAll(list); } notifyDataSetChanged(); }这样 setData 传 null 也不会导致空指针。很多人图省事在 adapter 的构造器里直接接收一个 List 传进来,但如果这个 List 是从网络请求里来的,很可能在后续某个时刻被替换成 null,导致 getItemCount 里访问 dataList.size() 直接崩溃。防御式编程在这里很有必要。
还有一个复用维度:如果你在一个页面里有多个外层条目,内层用的都是同一种类型的 item(都是水平滑动的商品小图),完全可以让所有外层 ViewHolder 共用同一个内层 Adapter 类型。注意是"类型共用",实例还是各自的,因为数据不同。这样在代码里少写几个 Adapter 类,维护起来也轻松。
3.3 数据更新的时机把控
嵌套列表最常见的数据刷新场景是什么?是外层第一次加载完成、拿到整个树形数据之后,一次性通知外层 Adapter 刷新。这时外层 onBindViewHolder 会依次执行,内层数据自然就填充进去了。这没什么问题。
但如果你的数据是分层加载的,比如外层先加载出分组,每组的子项要等用户点击展开或者滑到附近时才懒加载,那么这里有个很关键的操作顺序:必须先保证外层已有数据、且 ViewHolder 已经被绑定,才能去更新内层数据。否则你拿着一个还没有绑定到屏幕上的 ViewHolder 引用去 setData,大概率 UI 不会变。
怎么避免?不要在外面缓存 ViewHolder 引用去直接操作。标准做法是:内层数据到位之后,调用外层 adapter 的对应条目刷新方法,比如 notifyItemChanged(outerPosition),让它重新走 onBindViewHolder,在绑定过程中根据最新的数据源取数,内层自然更新。如果你确实需要"局部更新内层而不想重绑外层",可以走更精细的方案:
// 外层 ViewHolder 暴露一个更新内层数据的方法 public void updateInnerData(List<InnerBean> newList) { innerAdapter.setData(newList); } // 调用方通过 RecyclerView 找到对应 position 的 ViewHolder if (recyclerView.findViewHolderForAdapterPosition(outerPos) instanceof OuterVH) { OuterVH vh = (OuterVH) recyclerView.findViewHolderForAdapterPosition(outerPos); vh.updateInnerData(newList); }但是注意,findViewHolderForAdapterPosition 只在 ViewHolder 正处于可见/部分可见状态时才能找到。如果条目被回收了,拿到的是 null,这时候你只能靠 notifyItemChanged 让条目重新绑定。这套组合拳在写多级分类展开功能时很常用。
3.4 为什么不能在 onBindViewHolder 里 setAdapter
上面其实已经提了两点:性能差、状态混乱。这里我再补一个更隐蔽的问题画面。onBindViewHolder 本身是在 UI 线程执行的,而且是在 RecyclerView 的布局/绘制流程里被回调的。你在其中给兄弟 View 的 RecyclerView 调用 setAdapter,等于在布局过程中又触发了一次新的布局请求,这在 RecyclerView 的机制里叫"布局嵌套调用",轻则多一次 measure/layout 的浪费,重则触发崩溃或者 dirty 布局的警告。
就算数据小、不卡、不崩,这种写法也埋下了很深的坑。以后如果有人要在内层列表里加动画、加 header、加拖拽,这种"绑定阶段重建列表"的模式会和 RecyclerView 的动画机制冲突,出现各种"为什么我的 ItemAnimator 不生效""为什么插入的 item 不自带动画"这种问题。根源都在 setAdapter 重置了整个列表的内部状态。
从设值角度看,bind 阶段的任务是"把数据反映到视图上",而不是"重新搭一套视图"。数据驱动绑定的精髓在于,视图框架始终不变,变的只是它展示的内容。
4. 实操过程与核心环节实现
4.1 从布局 XML 开始搭建嵌套结构
先搭一个最典型的纵向嵌套横向的结构。外层:
<androidx.recyclerview.widget.RecyclerView android:id="@+id/outer_rv" android:layout_width="match_parent" android:layout_height="match_parent" />外层 item 布局 outer_item.xml:
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical"> <TextView android:id="@+id/tv_title" android:layout_width="match_parent" android:layout_height="wrap_content" android:textSize="16sp" android:padding="12dp" /> <androidx.recyclerview.widget.RecyclerView android:id="@+id/inner_rv_horizontal" android:layout_width="match_parent" android:layout_height="wrap_content" android:paddingStart="8dp" android:paddingEnd="8dp" /> </LinearLayout>这里有个非常关键的问题:内层 RecyclerView 高度到底怎么写。如果是横向滑动,高度建议写成 wrap_content,但 wrap_content 的 RecyclerView 在 measure 阶段需要特殊处理,先卖个关子,下一节单独讲。如果是纵向嵌套纵向,也就是内层也是纵向列表,那高度必须显式指定,或者用 NestedScrollView 的思想去测量所有子项,不然内层只有一屏高度,里面滚不起来也展示不全。这个也放到后面讲。
4.2 外层 Adapter 的完整实现
外层的核心适配器如下,注意我在 onCreateViewHolder 里已经把内层 RecyclerView 的 LayoutManager 和 Adapter 都初始化好了:
public class OuterAdapter extends RecyclerView.Adapter<OuterAdapter.OuterVH> { private List<OuterBean> dataList; public OuterAdapter() { this.dataList = new ArrayList<>(); } @NonNull @Override public OuterVH onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { View view = LayoutInflater.from(parent.getContext()).inflate(R.layout.outer_item, parent, false); return new OuterVH(view); } @Override public void onBindViewHolder(@NonNull OuterVH holder, int position) { OuterBean outerBean = dataList.get(position); holder.tvTitle.setText(outerBean.getTitle()); // 核心:只更新数据,不重新创建 Adapter holder.innerAdapter.setData(outerBean.getInnerList()); } @Override public int getItemCount() { return dataList == null ? 0 : dataList.size(); } public void setData(List<OuterBean> list) { this.dataList = list; notifyDataSetChanged(); } static class OuterVH extends RecyclerView.ViewHolder { TextView tvTitle; RecyclerView innerRv; InnerAdapter innerAdapter; OuterVH(@NonNull View itemView) { super(itemView); tvTitle = itemView.findViewById(R.id.tv_title); innerRv = itemView.findViewById(R.id.inner_rv_horizontal); LinearLayoutManager innerLayoutManager = new LinearLayoutManager(itemView.getContext()); innerLayoutManager.setOrientation(LinearLayoutManager.HORIZONTAL); innerRv.setLayoutManager(innerLayoutManager); innerAdapter = new InnerAdapter(); innerRv.setAdapter(innerAdapter); } } }4.3 内层 Adapter 的实现
内层 Adapter 就没什么特殊了,关键是记得写好 setData 方法:
public class InnerAdapter extends RecyclerView.Adapter<InnerAdapter.InnerVH> { private List<InnerBean> dataList = new ArrayList<>(); @NonNull @Override public InnerVH onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { View view = LayoutInflater.from(parent.getContext()).inflate(R.layout.inner_item, parent, false); return new InnerVH(view); } @Override public void onBindViewHolder(@NonNull InnerVH holder, int position) { InnerBean bean = dataList.get(position); holder.tvName.setText(bean.getName()); holder.itemView.setOnClickListener(v -> { // 点击事件处理 }); } @Override public int getItemCount() { return dataList.size(); } public void setData(List<InnerBean> newList) { dataList.clear(); if (newList != null) { dataList.addAll(newList); } notifyDataSetChanged(); } }这套代码跑起来,一个最基本的嵌套列表就算搭好了。但你敢直接用到线上环境吗?还不行。下面几个坎不过,线上会出幺蛾子。
4.4 关键参数计算:内层高度 wrap_content 为什么只显示一行
横向嵌套横向几乎没人用,大多数是外层纵向、内层横向,或者外层纵向、内层也纵向(多级分类菜单、二级列表)。
高度问题最常出在外层纵向、内层纵向的场景。如果你的内层 RecyclerView 高度设的是 wrap_content,你会发现内层列表只在第一屏显示了几条,再多也显示不出来,滑动也没有反应。这其实是 RecyclerView 的测量机制导致的。
打个比方,RecyclerView 平时像是一个带滚动条的窗口,窗口尺寸由 parent 给出的测量规格决定。但当我们把一个 RecyclerView 放在另一个 RecyclerView 的 item 里,并且 item 高度又是 wrap_content 的时候,外层 RecyclerView 在 measure 阶段会告诉内层 RecyclerView:"你的高度上限是无限的,你想占多高就告诉我。"但内层 RecyclerView 默认并不具备这种"让内容撑开自己"的能力,它的 onMeasure 逻辑在 UNSPECIFIED 规格下会直接返回所有可见 item 加在一起的高度。
也就是说,理论上 wrap_content 应该是能显示全部内容的。这里的问题在于外层 RecyclerView 的 Recycler 缓存和测量顺序:外层 item 在 measure 时,内层 RecyclerView 还没有执行过 layout,它也不知道自己有多少子 View、每个子 View 多高。结果就是内层 RecyclerView 在测量阶段提前返回了一个偏小的高度,等到真正 layout 子项时,发现内容多了,但高度已经被锁定了,多余的部分就不可见了。
解决办法有三种,按个人推荐程度排序:
方案一:外层 item 高度用固定值或者动态算出来的具体值,内层 RecyclerView 高度也用固定值。这种最稳,没有任何测量上的不确定性,适合已知内层条目高度和数量上限的场景。比如商品横向滑动区,一张卡片宽 120dp,高 150dp,内层横向 RecyclerView 高度固定 150dp,完美。
方案二:内层 RecyclerView 重写 onMeasure,让它在 UNSPECIFIED 模式下把所有 item 量一遍,算出总高度。这是万金油方案,适合动态数据、条数不确定的纵向嵌套纵向。
public class WrapContentRecyclerView extends RecyclerView { public WrapContentRecyclerView(Context context) { super(context); } public WrapContentRecyclerView(Context context, AttributeSet attrs) { super(context, attrs); } public WrapContentRecyclerView(Context context, AttributeSet attrs, int defStyle) { super(context, attrs, defStyle); } @Override protected void onMeasure(int widthSpec, int heightSpec) { if (getLayoutManager() == null) { super.onMeasure(widthSpec, heightSpec); return; } // 关键:如果是 UNSPECIFIED,就遍历所有 child 累加高度 if (MeasureSpec.getMode(heightSpec) == MeasureSpec.UNSPECIFIED) { int height = 0; for (int i = 0; i < getLayoutManager().getItemCount(); i++) { View child = getLayoutManager().getViewForPosition(i); if (child != null) { measureChild(child, widthSpec, MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED)); height += child.getMeasuredHeight(); getLayoutManager().removeAndRecycleView(child, getRecycler()); } } setMeasuredDimension(getMeasuredWidth(), height); } else { super.onMeasure(widthSpec, heightSpec); } } }这段代码看着不复杂,但它是性能杀手。每次 onMeasure 都要去 create 所有的 item 来计算高度,如果内层有几百条数据,draw 一遍之前 measure 就先卡住了。所以我只在数据量较小(少于 50 条)的分组列表里用,数据量一大就换方案三。
方案三:换成 NestedScrollView + RecyclerView 的组合。这种方案严格意义上不算嵌套 RecyclerView 了。外层用 NestedScrollView,里面放纵向的 RecyclerView,并设置android:nestedScrollingEnabled="false"。因为 NestedScrollView 本身就是可滚动容器,它会主动把所有子 View 的高度量到完整,于是内层 RecyclerView 就能完整展示了,不丢条目。
注意:NestedScrollView 嵌套 RecyclerView 会让 RecyclerView 失去自身的回收机制,因为它的高度被测量成所有子项的总和,同时它自身又不能滚动(nestedScrollingEnabled=false),所有的 ViewHolder 全部一次性创建。性能问题依旧存在,而且更严重。所以方案三只适合内层数据几十条的小场景,数据几百条的,老老实实做分页加载,或者用多个 item type 的扁平列表替代嵌套,才是长久的解法。
4.5 滑动冲突与事件分发处理的三个关键开关
嵌套 RecyclerView 第二个大坑就是滑动冲突。内层横向、外层纵向这种正好错开方向的组合,默认情况下不会有明显冲突,因为 Android 事件分发机制会根据滑动手势的方向分发给对应方向的滑动容器,实测下来基本顺滑。但如果在水平滑动时手指稍微带一点纵向位移,外层就会拦截事件,导致内层横向滑动"不跟手"。反过来,外层纵向、内层纵向的嵌套,那冲突就是完全正面刚了。
处理思路有三个开关。
第一,内层 RecyclerView 设置setNestedScrollingEnabled(false)。这是最常用的手段。它的本质是打断 RecyclerView 之间的嵌套滚动协作,让内层不再向父容器(外层 RecyclerView)上报滚动距离和消费请求,外层就不会因为收到内层的 nested scroll 回调而干预内层滚动。对于"外层纵向 + 内层横向"这种场景,一般不需要设这个,因为方向不同,不会产生父容器与子列表同时消费同一方向滚动的情况。但"外层纵向 + 内层纵向"就必须重点考虑,如果你希望内层也能滚动,可以让内层保留 enabled,让内层优先处理纵向滑动,配合下面第二点。
第二,重写外层 RecyclerView 的onInterceptTouchEvent,当手指落在内层 RecyclerView 上时直接不拦截。判断方法有很多,比较通用的是在分发事件时检查当前触摸位置是否命中某个子 RecycleView,再结合事件的方向。要求不高的话,可以简单粗暴地:内层是纵向,就不拦截纵向事件;是横向,就不拦截横向事件。
@Override public boolean onInterceptTouchEvent(MotionEvent e) { if (e.getAction() == MotionEvent.ACTION_DOWN && canChildScrollInDirection(this, (int) e.getX(), (int) e.getY(), RecyclerView.VERTICAL)) { return false; } return super.onInterceptTouchEvent(e); } private boolean canChildScrollInDirection(ViewGroup parent, int x, int y, int direction) { View child = findChildViewUnder(x, y); if (child instanceof RecyclerView) { RecyclerView rv = (RecyclerView) child; if (direction == RecyclerView.VERTICAL) { return rv.canScrollVertically(1) || rv.canScrollVertically(-1); } else if (direction == RecyclerView.HORIZONTAL) { return rv.canScrollHorizontally(1) || rv.canScrollHorizontally(-1); } } return false; }这段逻辑相当于在手指按下时判断:"当前摸到的位置是不是一个可以滚动的子列表?如果是,这个方向的手势我就不抢了,让子列表自己处理。"
第三,内层 RecyclerView 自己重写dispatchTouchEvent或者onTouchEvent,在需要的时候主动请求父容器不要拦截:
innerRv.setOnTouchListener((v, event) -> { if (event.getAction() == MotionEvent.ACTION_DOWN) { v.getParent().requestDisallowInterceptTouchEvent(true); } return false; });但注意,如果直接在 DOWN 事件里无条件 requestDisallowInterceptTouchEvent(true),会让你外层列表在手指落在内层区域时完全无法上下滑动,哪怕是内层已经滑到边界了,外层也接不到事件。正确做法是配合 canScrollVertically 判断内层是否还能继续滑动,只有内层滑不动时才允许父容器接管。这个逻辑网上有很多封装好的库,比如"自定义不拦截嵌套 RecyclerView 父布局",可以直接搜来抄,但原理就是上面这几行。
4.6 内层 RecyclerView 复用池优化
嵌套 RecyclerView 的性能问题还有一块来自内层条目 View 的创建成本。一个外层条目被滑出屏幕后,它内部所有内层 item 也一起被销毁回收。下次滑回来,又要重新创建几十个内层 item。如果内层 item 布局稍微复杂点,图片多几个,这个创建成本就会被放大几十倍。
这是我看很多人会忽略的优化点:给外层 RecyclerView 设置 RecycledViewPool。默认情况下,每个 RecyclerView 都有自己的 ViewHolder 缓存池。嵌套结构里有多个内层 RecyclerView,它们各自持有一个独立的池,互不共享。因为外层 item 被回收时,内层 item 的 ViewHolder 也会被回收到对应内层 RecyclerView 自己的池里,但如果内层 RecyclerView 实例被摧毁,这个池就没了。
正确的做法是,让所有内层 RecyclerView 共享同一个 RecycledViewPool:
RecycledViewPool sharedPool = new RecycledViewPool(); // 在 onCreateViewHolder 里给每个内层 RecyclerView 设置同一个 pool innerRv.setRecycledViewPool(sharedPool);更进一步,可以预设池子的大小:
sharedPool.setMaxRecycledViews(0, 20);这里的 0 是 item type 的默认值。如果你有不同样式的内层 item,按对应的 viewType 设置不同缓存上限。这套操作对内存压力的缓解非常明显,实测在商品卡片多图场景下,滑动流畅度提升是肉眼可见的。
4.7 内层数据变化时的联动刷新策略
再往深一点说。很多时候内层列表不是静态数据,网络请求回调可能随时带来某个分组的子项更新。如果你简单粗暴地调用外层 adapter.notifyDataSetChanged(),所有内层列表都会重新绑定,连那些数据完全没变的组也会跟着重画一遍。如果外层有 20 个组、每组 50 个商品,这一下就是 1000 个 item 的刷新代价。
推荐的做法是按组更新。有了 DiffUtil 之后,可以对每个内层 Adapter 单独做差异更新:哪个组的数据变了,就调用哪个组的 setData。更进一步,如果只有组里某个商品变了,配合 DiffUtil 就只重画那一个商品。整体体验接近秒更新。
实现上,可以是外层维护一个SparseArray<InnerAdapter>的映射:
// 当某个分组的子项数据从网络返回时 public void notifyInnerDataChanged(int outerPosition, List<InnerBean> newList) { // 如果对应 ViewHolder 可见,直接通过 ViewHolder 更新 OuterVH vh = (OuterVH) outerRv.findViewHolderForAdapterPosition(outerPosition); if (vh != null) { vh.innerAdapter.setData(newList); } else { // 不可见时,更新外层数据源,等下次绑定自然生效 outerDataList.get(outerPosition).setInnerList(newList); } }这套写法的核心思想是:可见的立刻更新,不可见的只改数据源。配合 notifyItemChanged 虽然也能达到目的,但 findViewHolder 加直接更新的开销更小,因为不会触发外层 item 重新绑定。在用户正盯着某个分组快速展开操作时,这种"零闪烁"更新体验会明显好很多。
5. 常见问题与排查技巧实录
5.1 高频问题速查表
下面这些是我在实际开发和帮人 review 代码时遇到最多的几个问题,基本覆盖了嵌套 RecyclerView 设值方向的九成坑。
| 现象 | 根本原因 | 解决方案 |
|---|---|---|
| 内层列表显示上一次的数据,滑动后才正常 | onBindViewHolder 里没更新内层 Adapter 数据,或者拿的是缓存 ViewHolder | 确保每次绑定都调用内层 adapter.setData |
| 内层只有一屏高度,内容显示不全 | 内层 RecyclerView 高度 wrap_content,measure 阶段高度锁定 | 固定高度 / 重写 onMeasure / NestedScrollView 方案 |
| 内层横向滑动不流畅,经常被外层抢事件 | 事件分发冲突,外层拦截了横向手势 | 外层 onInterceptTouchEvent 判断方向放行,或内层 dispatchTouchEvent 处理 |
| 页面滑动越来越卡,内存持续升高 | 每次绑定都新建 Adapter / LayoutManager,item 没走共享缓存池 | onCreateViewHolder 固定创建,外层统一设置 RecycledViewPool |
| 内层 item 更新后整页闪动 | notifyDataSetChanged 全量刷新,所有内层列表重画 | 使用 DiffUtil / 按组局部刷新 |
| 内层 item 点击事件失效 | itemView 内部有消耗事件的控件,或父容器拦截了事件 | 检查事件分发链,设置 clickable 和 listener |
| 数据更新后 UI 不变,但日志打印 ViewHolder 还在 | 调用了 notifyDataSetChanged 但数据源没更新,或更新的对象地址换了但旧引用没换 | 确认数据源先改,再通知刷新,且引用一致 |
5.2 问题排查实录:内层设值后不刷新 UI
这里挑一个特别典型的案例详细还原一下。有次做一个双级分类选择页,外层是分类大项,内层是该大类下的子项列表,数据是从接口异步返回的,一次返回整个树。有个同事写出来的效果是:第一次进入页面一切正常,但退出到别的页面再回来,发现内层列表还停留在上一次展开的分类,甚至有时候切到新分类,内层列表明明有数据但 item 显示空白,滑一下又好了。
我当时排查的思路分三步。
第一步,怀疑是 ViewHolder 缓存导致数据没更新。检查外层 Adapter 的 onBindViewHolder,发现里面确实写了 holder.innerAdapter.setData(...),说明绑定阶段数据在设置,不是没更新。
第二步,打日志看内层 adapter 的 getItemCount 在绑定后是多少。发现一个诡异现象:切换分类时,旧的 inner RecyclerView 的 ViewHolder 没有走 onBindViewHolder,而是被整个复用了,但它的内层 Adapter 还在,而新分类的数据源已经换成新对象。问题出在内层 Adapter.setData 里我用的是直接替换引用this.dataList = newList,而 Android 绘制流程拿到 getItemCount 时,多个外层 ViewHolder 可能持有同一个 dataList 引用,加上外层列表项 viewType 一致,导致旧 ViewHolder 拿到的 dataList 已经被新分类覆盖了,UI 显示的是新数据,但位置和 item 内容对不上。
第三步,把 setData 改成 clear + addAll,保持引用一致,问题就消失了。这个坑本质上就是典型的"引用问题引发的数据串场",也是嵌套列表中设值阶段最容易翻车的地方。真遇到数据没问题但 UI 不对,先检查 setData 的写法。
5.3 局部刷新与 findViewHolder 的边界条件
前文推荐了用 findViewHolderForAdapterPosition 局部更新 ViewHolder 的方法,这里要提醒几个边界条件。
这个方法的返回值只在 ViewHolder 处于"可见或部分可见"状态时非空。如果条目被完全滑出屏幕,RecyclerView 会把它回收到缓存池,此时即使 adapter position 还能对应上,你拿到的也是 null。这在调用方需要做判空。
另外,findViewHolderForAdapterPosition 和 findViewHolderForLayoutPosition 是有区别的。前者适配的是 Adapter 的最新 position,后者是当前 LayoutManager 布局的 position。如果在数据更新过程中(还没有执行完 notify 之后的第一帧布局),两者可能不一致。实操建议:数据没有变更的时刻,用哪个都成;数据刚变更完还没布局的时刻,优先用 ForAdapterPosition。
多数情况下,你主动调用 notifyItemChanged(outerPos) 就足够了,系统会自己找到对应 ViewHolder 并重新绑定。走"手动拿 ViewHolder 更新"路径,只有在极其在意"重绑外层会闪烁"的场景才需要。
5.4 用截图/录制工具辅助定位绘制问题
这算是一个排除法技巧。遇到内层显示异常,先别急着翻代码,可以用系统的"开发者选项-显示布局边界"打开布局轮廓。如果内层 RecyclerView 的边界框显示只有一屏高度,那么问题就定位在测量阶段;如果边界框是全高但里面 item 没显示,就是数据/复用的问题;如果边界框正常但 item 被裁切,那就是 item 内部的绘制问题。
我自己排查时还遇到过一种特殊情况:内层设置的数据正确、高度也正常,但 item 之间隔了大段空白。最后发现是 item 根布局里写死了某个 margin 或者用了 weight 分配高度,而该 item 类型不只一种,导致测量时把高度权值加错了。这种问题靠布视线很难看出,大多是结合布局结构走查才会发现。
5.5 不是非得嵌套:扁平化多类型适配器或许是更优选
最后说一句大实话,也是我踩过无数坑之后的经验:嵌套 RecyclerView 并不是唯一选择,而且往往不是最好的选择。如果你的场景是"外层分组 + 内层条目",完全可以考虑用单个 RecyclerView + 多 viewType 来实现。外层 item 就当 header 类型,内层条目拍平后当成若干个 contended item。这么做的好处太多了:没有测量问题、没有滑动冲突、没有复用池问题、性能上限高出一大截。
唯一的难点是数据源的构造:需要把"外层的分组"和"内层的子项"整理成一个扁平的列表,并在 adapter 里通过 viewType 区分。比如:
// type = 0 表示分组标题,type = 1 表示普通子项 @Override public int getItemViewType(int position) { return flatData.get(position) instanceof GroupBean ? TYPE_GROUP : TYPE_ITEM; }在拼接扁平列表时,给每个 GroupBean 后面接着插一堆 ItemBean,并标记每个 ItemBean 属于哪个分组,点击事件也能通过 position 逆推到分组 context。
顺带一提,Google 官方在 Android 早期版本的"RecyclerView 嵌套列表最佳实践"里也提到过这个思路,不过在复杂的多级树形结构下,扁平化会让数据管理变繁琐,两者各有利弊。我的判断标准很简单:内层数据总量小于 50 的,随便怎么嵌套都行;内层数据量大、层级超过两层的,优先扁平化;中间的,就用嵌套 + 共享 RecycledViewPool + DiffUtil,足够顺滑。
6. 我的一些心得
从最初傻傻地在 onBindViewHolder 里 setAdapter 卡到怀疑人生,到现在闭着眼睛都能写出稳定方案,核心就一句:内层 RecyclerView 只是外层 ViewHolder 的一个普通子 View,它的 Adapter 生命周期应当和外层 ViewHolder 绑定,而不是和每次数据绑定绑定。设值处理好了,嵌套列表就成功了一半。剩下一半是高度和事件,高度问题原则上用固定高度或 NestedScrollView,事件问题用事件分发三开关,性能问题用共享 ViewHolder 池。这几招打完,一般的嵌套列表需求都能稳稳接住。
以后如果你遇到更复杂的树形多级嵌套,思路也是同理:每一级 ViewHolder 都持有自己的子列表 Adapter 和 LayoutManager,只更新数据,不重建框架。把这条思路吃透,Android 里任何层级嵌套列表对你来说都不是事了。