简介:这是一份围绕 RecyclerView 嵌套 RecyclerView 场景整理的 Android 工程案例,面向已掌握基础列表开发、需要处理多层数据展示与嵌套滚动的开发者。案例以外层主列表配合内层网格或列表为结构,覆盖双适配器与布局管理器设置、ViewHolder 数据绑定、ItemDecoration 间隔处理,以及 NestedScrolling 协调、SwipeRefreshLayout 上拉下拉刷新和 DiffUtil 等性能优化思路,可用于新闻分类、商品分组等页面参考。压缩包为 rar 格式,共 65 个文件、约 260KB,包含 30 个 xml 布局与配置、10 个 java 源码、9 个 png 图片资源,以及 gradle、properties 等工程构建文件,目录完整,便于导入 Android Studio 对照学习。已有 1248 人学习下载,适合需要理解嵌套列表事件传递、复用机制与刷新实现的读者拆解和二次开发。
1. 双层列表的嵌套方案:先想清楚滚动归属,再谈 RecyclerView 叠 RecyclerView
外层一个纵向 RecyclerView,每个 item 里再塞一个横向 RecyclerView,这种「列表套列表」的界面在电商、内容流、个人主页里几乎是标配。可真正动工时,问题往往还会在滚动冲突和性能上冒出来:外层的滑动会被内层抢走,内层的宽度在嵌套下算不准,滚动时卡顿,ViewHolder 复用也会打架。这套方案要解决的就是嵌套滑动怎么设计、布局怎么量、数据怎么分层、性能怎么验。适合两类人:做课设或毕设需要交一个可运行的双层列表 Demo 的同学,以及在项目里被嵌套 RecyclerView 卡过、想找一套能照抄的分层写法的人。
1.1 需求边界与交互约定
把需求讲清楚,比先写代码重要得多。嵌套列表最容易翻车的地方,在于没有事先约定「谁负责滚、谁负责点、谁负责撑高度」。我一般会在动手前先写一张交互约定表,把它当成需求文档的一部分交上去,后面写代码、验收都能对着它逐条核。
| 交互场景 | 期望行为 | 责任方 |
|---|---|---|
| 手指在外层空白区纵向拖动 | 外层列表滚动 | 外层 RecyclerView |
| 手指在内层卡片上横向拖动 | 内层列表滚动,外层不动 | 内层 RecyclerView + 父级拦截策略 |
| 手指在内层区域纵向拖动 | 由外层接管,整页滚动 | 外层 + 嵌套滚动接口 |
| 点击内层某个卡片 | 只触发内层 item 点击,不冒泡到外层 | 内层 Adapter |
| 外层 item 滑出屏幕再回来 | 内层滚动位置与数据保持一致 | 内层独立的 LayoutManager 状态 |
这张表里有一行最容易被忽略:横向列表区域的纵向拖动该归谁。直觉上会认为内层不处理纵向,实际上如果内层没有声明不消费纵向手势,父容器的事件分发就会先问内层,内层一犹豫,父级就收不到连续的滚动事件,于是出现「上下滑一下、动一下、再滑就卡住」的怪现象。合法的做法是让内层只对横向方向做判定,纵向事件通过嵌套滚动接口交还给外层,也就是常说的NestedScrollingChild/NestedScrollingParent协作机制。
需求里还要明确数据规模。课设常见的写法是外层 10 组、每组 8 条,合计 80 个 ViewHolder;真实项目里外层上百、内层几十条也很正常。规模决定了你是可以直接为每条数据都创建 View,还是必须严格依赖回收。这个数字建议直接写进设计文档,答辩时老师问「你这个方案能撑多少数据」就有据可依。
1.2 技术选型:为什么是嵌套 RecyclerView 而不是别的
有人会问,既然嵌套这么麻烦,为什么不用ScrollView包一个LinearLayout,或者用一个GridLayout手动摆?这涉及选型,不是立场问题。ScrollView包所有子 View 的方案确实能绕开嵌套滑动,因为它一次性测量全部内容,滚动由一个容器统一负责。代价是全部 View 常驻内存,外层一旦超过三四十组,内存和首次布局时间就会明显恶化;而且没有回收,滑到后面该卡的还是卡。这种写法只适合数据量固定且很小、几乎没有交互的静态页面。
另一种做法是「拉平数据结构」,把二维数据拍平成一维,用一个多类型 Adapter 渲染,表头用spanSize跨列。这套思路在纯展示场景非常干净,性能也最好,但它有个硬性前提:横向区域的内容必须能对齐到网格,或者你愿意为每组单独写一套测量逻辑。一旦内层需要独立的横向滑动、独立的滚动位置、独立的点击状态,拉平就会变得别扭。所以我的取舍是:纯展示、行高一致、内层不需要独立滚动,走拉平;内层要横向滑动、要保留各自的滚动位置,走嵌套 RecyclerView。
嵌套方案的核心成本在两块:布局测量与滚动分发。前者靠给内层设置固定高度或明确的layout_height解决,后者靠嵌套滚动接口解决。这两点做到位,嵌套 RecyclerView 就是一个很稳的方案,而不是什么玄学。
2. 总体设计:三层结构与四个职责模块
方案分三层:数据层负责把「组 → 子项」的二维结构组织好;视图层是外层 RecyclerView 加内层 RecyclerView 的容器;协调层处理测量、滚动、状态保存这些跨层的事情。四个职责模块分别是外层 Adapter、内层 Adapter、内层容器(负责测量与滚动策略)、数据模型。分层的意义在于,测量和滚动的逻辑只出现在容器这一处,Adapter 保持纯粹,谁出问题就找谁,不留黑匣子。
2.1 数据模型与分层
数据结构上不要用List<List<Item>>直接裸传。裸嵌套列表的问题是内层没有身份,滚动位置、选中状态、局部刷新都无从挂靠。我会给外层建一个 Group 模型,内层建一个 Item 模型,Group 持有自己的子项列表和一个稳定 id。
// 外层分组模型:id 用于稳定标识,title 用于组头展示 data class Group( val groupId: Long, val title: String, val items: List<Item> ) // 内层子项模型:itemId 是内层列表的稳定标识 data class Item( val itemId: Long, val label: String, val coverUrl: String )groupId和itemId的作用不只是好看。RecyclerView 的setHasStableIds(true)配合getItemId()返回稳定 id,能在数据局部更新时避免整表重绘;内层列表保存滚动位置时,也需要一个能定位到具体分组的东西。如果 id 用列表下标充当,插入或删除一条数据后,整个位置映射就会错位,滚动位置恢复就会跳到别处,这是很典型的踩坑点。
数据分层还决定了刷新粒度。外层 Adapter 用DiffUtil比较 Group 列表,内层用DiffUtil比较 Item 列表,两层各自独立。这样某组内一条数据变了,只触发该组的局部刷新,其他组的内层列表不会重建,滚动位置也不会被重置。如果把两层数据混在一个扁平列表里,任何一条变动都会让整页重新绑定,视觉上就是「点一下、整页闪一下」。
2.2 外层与外层 Adapter 的职责
外层 Adapter 只做三件事:渲染组头、把内层 RecyclerView 作为 item 的容器返回、把数据传给内层 Adapter。它不负责测量内层高度,也不应该去监听内层的滚动。组头可以用itemViewType区分,也可以用独立的 ViewHolder,取决于组头是否需要吸顶。需要吸顶的话,交给ItemDecoration或单独的吸顶容器处理,不要塞进 Adapter 逻辑里,否则代码会迅速膨胀。
创建内层 ViewHolder 的时候要注意,不要在onCreateViewHolder里每次都new一个 LayoutManager。LayoutManager 是有状态的对象,内层滚动位置就存在它身上。如果每次绑定都换一个新的,滑动再回来位置必然丢失。正确做法是每个外层 ViewHolder 持有一个内层 Adapter 和一个 LayoutManager,绑定数据时只更新 Adapter 的数据集。
2.3 内层容器:测量与滚动的收口处
内层容器是整个方案最关键的一处。它要解决两件事:高度怎么定、纵向手势往哪走。高度上,横向列表的行高通常是固定的,直接给内层 RecyclerView 写死layout_height(比如 120dp),测量问题就消失了。如果内容高度确实可变,用一个继承LinearLayoutManager的自定义 LayoutManager,在onMeasure完成后按最大子项高度回写,但这会带来一次额外测量,数据量大时要注意。
滚动上,纵向手势必须交还外层。做法是给内层 RecyclerView 设置isNestedScrollingEnabled = true(默认就是开的),并确保外层的父容器实现了嵌套滚动。绝大多数情况下,标准 RecyclerView 作为父级已经处理好,真正需要动的是「内层区域纵向拖动」这一条:让内层在onInterceptTouchEvent阶段只拦截横向位移大于纵向位移的手势。这个判定的阈值建议写成一个常量,太小会误判、太大会让横向滑动变得迟钝。
// 内层容器:只拦截横向手势,纵向交还父级 class HorizontalInnerRecyclerView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null ) : RecyclerView(context, attrs) { private var downX = 0f private var downY = 0f override fun onInterceptTouchEvent(e: MotionEvent): Boolean { when (e.actionMasked) { MotionEvent.ACTION_DOWN -> { downX = e.x downY = e.y } MotionEvent.ACTION_MOVE -> { val dx = abs(e.x - downX) val dy = abs(e.y - downY) // 横向位移明显大于纵向才拦截,否则交给外层滚动 if (dx > dy && dx > touchSlop) { return true } } } return super.onInterceptTouchEvent(e) } }这段代码里touchSlop是系统给出的最小滑动判定距离,用ViewConfiguration.get(context).scaledTouchSlop取,不要硬编码像素值,不同密度屏幕下手感差别很大。ACTION_DOWN时记录起点,ACTION_MOVE里比较两个方向的位移量,只有在横向明确占优时才返回 true 拦截。这样纵向拖动会继续走默认流程,由嵌套滚动机制把滚动量传给外层,视觉上就是整页顺滑上下滚,横向卡片区域也不失效。
提示:这段判定里的
dx > dy是严格大于,不是「差不多就拦」。用手指斜着滑的时候,方向抖动很常见,用严格比较能保证只有明确横向意图才被内层吃掉。
3. 实现拆解:从布局文件到滚动位置恢复
设计定下来之后,实现上剩下的都是细节活,但恰恰是细节决定这套东西是能交作业还是能上线。这一章按「布局 → Adapter 绑定 → 状态保存 → 性能」的顺序拆,每段给出可以直接粘的写法,并说明参数为什么这么取。
3.1 布局文件与尺寸约束
外层用LinearLayoutManager纵向,内层用LinearLayoutManager横向,这是最常规的组合。内层一定要有确定的高度,否则测量会出问题:RecyclerView 在无约束高度下会试图测量全部子项,嵌套场景里的表现就是内层高度忽大忽小,或者外层 item 高度跳动。
<!-- 外层 item 布局:组头 + 固定高度的横向列表 --> <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical"> <TextView android:id="@+id/tv_group_title" android:layout_width="match_parent" android:layout_height="wrap_content" android:padding="12dp" android:textSize="16sp" /> <!-- 高度必须写死或明确约束,避免嵌套测量异常 --> <androidx.recyclerview.widget.RecyclerView android:id="@+id/rv_inner" android:layout_width="match_parent" android:layout_height="132dp" android:orientation="horizontal" android:clipToPadding="false" android:paddingStart="12dp" android:paddingEnd="12dp" /> </LinearLayout>clipToPadding="false"配合左右 padding,能让第一张卡片和最后一张卡片贴边时仍有呼吸空间,滑到尽头时卡片不会被裁掉一半,这个小细节在验收时很容易被注意到。高度 132dp 是按卡片 108dp 加内边距估出来的,改卡片尺寸时这个值要跟着改,不然会出现上下留白或卡片被压。
如果内层卡片高度不一,固定高度会导致矮卡片留白多。这时候不要急着改自适应测量,先考虑是不是视觉上本来就应该统一高度;确实要自适应,再换自定义 LayoutManager 做按最大子项回写,那是另一套开销。
3.2 内外两层 Adapter 的绑定逻辑
外层 Adapter 在onBindViewHolder里把子项列表交给内层 Adapter。这里有个常见误区:每次绑定都新建内层 Adapter。新建本身不算贵,但会把内层的滚动位置和DiffUtil的旧列表一起丢掉,滚动回来必然重置。
class OuterAdapter( private val groups: MutableList<Group>, private val onItemClick: (Item) -> Unit ) : RecyclerView.Adapter<OuterAdapter.GroupViewHolder>() { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): GroupViewHolder { val view = LayoutInflater.from(parent.context) .inflate(R.layout.item_outer_group, parent, false) // 每个外层 ViewHolder 固定持有一个内层 Adapter,不随绑定重建 val innerAdapter = InnerAdapter(onItemClick) return GroupViewHolder(view, innerAdapter) } override fun onBindViewHolder(holder: GroupViewHolder, position: Int) { val group = groups[position] holder.title.text = group.title // 只更新数据集,Adapter 与 LayoutManager 保持同一个实例 holder.innerAdapter.submit(group.items) } class GroupViewHolder(itemView: View, val innerAdapter: InnerAdapter) : RecyclerView.ViewHolder(itemView) { val title: TextView = itemView.findViewById(R.id.tv_group_title) private val innerRv: RecyclerView = itemView.findViewById(R.id.rv_inner) init { // LayoutManager 只创建一次,滚动位置存在它上面 innerRv.layoutManager = LinearLayoutManager(itemView.context, LinearLayoutManager.HORIZONTAL, false) innerRv.adapter = innerAdapter } } }init块里设置 LayoutManager 和 adapter,保证每个外层 ViewHolder 的内层实例是稳定的。submit方法内部走DiffUtil计算差异,避免notifyDataSetChanged导致整段重建。点击回调通过构造参数传进来,而不是让内层 Adapter 去拿外层引用,这样两层之间是单向依赖,不会形成循环。
内层 Adapter 的onCreateViewHolder里只 inflate 卡片布局,onBindViewHolder里绑文字和封面。注意封面加载要交给图片库并给占位图,不要在绑定阶段同步解码,那是卡顿的最大来源之一。
3.3 滚动位置的保存与恢复
嵌套列表最容易被投诉的一点是:滑到第三组、横向翻了几张,再滑回来,横向位置又回到最左边。原因是外层 ViewHolder 被回收后重新创建,LayoutManager 是新的,位置自然是初始值。
解决方向有两个。一是把内层滚动位置存到数据模型里,绑定前先读出来再scrollToPosition;二是让内层 LayoutManager 自己保存状态,在 ViewHolder 回收时把onSaveInstanceState的结果塞进对应的 Group 数据里。
// 外层 ViewHolder 回收前保存内层滚动状态 class GroupViewHolder(itemView: View, val innerAdapter: InnerAdapter) : RecyclerView.ViewHolder(itemView) { private val innerRv: RecyclerView = itemView.findViewById(R.id.rv_inner) var savedState: Parcelable? = null fun bind(group: Group) { innerAdapter.submit(group.items) // 恢复上次保存的滚动位置 savedState?.let { innerRv.layoutManager?.onRestoreInstanceState(it) } } fun saveInnerState() { savedState = innerRv.layoutManager?.onSaveInstanceState() } }外层 Adapter 在onViewRecycled里调用saveInnerState(),在onBindViewHolder里调用bind()。onSaveInstanceState返回的Parcelable就是 LayoutManager 的完整状态,比手动记 position 更稳,因为还包含偏移量。这里要注意savedState存在 ViewHolder 上还是存在 Group 上:存在 ViewHolder 上,ViewHoldder 被真正销毁时状态就没了;更保险的做法是存进 Group 数据模型,随数据一起活。
注意:不要用
scrollToPosition代替状态恢复。它只对齐到某个位置,丢失了偏移量,视觉上会有一次「跳一下」的抖动。
4. 性能与避坑:五个最容易翻车的点
嵌套 RecyclerView 的性能问题,八成不是出在 RecyclerView 本身,而是用法上踩了坑。下面五条是我见过频率最高的,每条按现象、原因、解决写,对照着排。
4.1 症状:整页滚动掉帧,横向卡片跟手差
原因通常是内层没有复用机制,或者每绑一次数据就重建 Adapter、LayoutManager、监听器。另一个隐蔽来源是内层 ViewHolder 数量控制不当:横向列表默认会缓存一些离屏 ViewHolder,如果外层有几十组,每组都缓存一批,内存会上去,GC 一触发就掉帧。
解决上,先确认内外两层都只创建一次 Adapter 和 LayoutManager;再给内层设置合理的setItemViewCacheSize,横向列表一般 2 到 4 就够;最后把图片加载改成异步加占位图,不在绑定阶段做任何同步 I/O。
4.2 症状:内层高度错乱,滑回来卡片被裁
这是测量问题,多半是内层 RecyclerView 写了wrap_content。嵌套场景下父级的测量规格传下去时高度不确定,RecyclerView 会按最大子项测量,一旦某组数据和别的组不同,高度就跳。
解决上,横向列表一律给固定高度;如果确实需要自适应,用自定义 LayoutManager 在onLayoutCompleted后按最大子项高度回写layoutParams.height,并注意触发一次 requestLayout,但这条路需要压测,数据量大时容易引发测量循环。
4.3 症状:上下滑不动,或者滑到内层位置就「粘住」
现象是手指在内层区域纵向拖动,页面纹丝不动或者只动一小段。原因是内层把纵向事件也拦截了,父级拿不到连续滚动。
解决上,按前面给的写法,内层只在横向位移明确大于纵向时拦截;同时确认内层isNestedScrollingEnabled是 true,父级链路完整。用拦截阈值加严格方向比较,基本能消除这个症状。
4.4 症状:点内层卡片,外层 item 也被触发
原因在于内层没有消费点击事件,事件冒泡到了外层 item 的点击监听。这是事件分发的经典问题,不是嵌套特有的。
解决上,内层卡片的点击监听里把事件消费掉,返回 true;外层 item 的点击监听不要加在内层容器上,只加在组头或外层空白区域。
4.5 症状:数据局部更新后,内层滚动位置跳回开头
原因是刷新粒度太粗,用了notifyDataSetChanged,整段重建导致 LayoutManager 状态丢失。也可能是DiffUtil的areItemsTheSame用错,把不同 item 判成同一个。
解决上,内外两层都用DiffUtil,areItemsTheSame比较稳定 id,areContentsTheSame比较展示字段;同时把内层 LayoutManager 状态按前面说的存进 Group 模型,双重保险。
5. 验收与答辩:怎么证明这套嵌套是真的稳
方案交上去之前,最好自己先走一遍可复现的验收流程,不要等老师或者评审现场翻车。我一般会准备一张验收清单,逐条过、逐条打勾,这个过程本身在答辩时就是加分项,因为它说明你知道这套东西的边界在哪。
第一条,滚动归属验收。手指在外层空白区纵向滑、在内层卡片上横向滑、在内层区域纵向滑,三种手势各做五遍,看是否稳定符合交互约定表。特别要试「斜着滑」和「快速甩」两种极端输入,这里是玄学高发区,快速甩最容易暴露方向判定阈值的问题。
第二条,位置恢复验收。滑到第五组,把该组横向翻到最后一张,再滑到第十五组,然后一路滑回第五组,看横向位置是否保留。这一条能同时验证 ViewHolder 回收、状态保存、DiffUtil 刷新粒度三个点。
第三条,数据变更验收。在第五组中间插入一条数据,观察是否只有该组刷新、其他组不动、滚动位置不跳。这一步是检验分层设计有没有真落地的照妖镜,如果整页闪一下,说明刷新粒度没收住。
第四条,性能数据。用系统的布局检查工具看一帧的耗时,重点看内层绑定阶段有没有同步操作。不用追求精确数字,只要确认没有明显长帧,答辩时能说清「我压测过、瓶颈在哪、怎么规避」就够。
| 验收项 | 操作 | 合格标准 |
|---|---|---|
| 滚动归属 | 三种方向手势各五遍 | 行为与约定表一致,无粘滞 |
| 位置恢复 | 跨组滑动再回滚 | 内层滚动位置保留 |
| 局部刷新 | 单组插入一条数据 | 仅该组刷新,位置不跳 |
| 快速滑动 | 连续快速甩动 | 无明显长帧、无错位 |
| 边界数据 | 空组、单条组 | 不崩溃、高度正常 |
答辩时被问得最多的两个问题,一个是「为什么不用拉平」,一个是「内层高度为什么写死」。前者按第 1 章的技术选型回答,讲清楚拉平的适用前提;后者讲测量成本和稳定性取舍,并说明如果要自适应会怎么做。能把取舍讲明白,比把功能跑通更能说明你真的做过。
另外,提前准备一份「已知限制」清单也很实用:内层数据量极大时的内存表现、组数超过两百时的首屏时间、自适应高度方案的压测结论。主动说出限制,评委会认为你对自己的方案有掌控,而不是在掩饰短板。
从那以后我每次做嵌套列表,都会先写那张交互约定表,再动布局文件,最后才碰 Adapter。顺序反过来的项目,几乎都会在滚动位置上返工。这套流程希望帮到你。
本文还有配套的精品资源,点击获取