简介:面向 Android 开发者的 RecyclerView 嵌套 Recyclerview 实战案例,适用于需要展示多级数据列表的应用场景,如新闻分类与文章网格、电商二级商品陈列等。压缩包共包含 65 个文件,以 30 个 XML 布局文件、10 个 Java 源码文件及 9 张 PNG 图片为主,另有 gradle 构建脚本、属性配置和项目描述等文件,整体仅 260KB,便于快速下载与本地编译。目前已有 1244 人学习/下载。案例涵盖外层线性列表与内层网格布局的完整搭建,包括适配器与 ViewHolder 的创建、内层 RecyclerView 的动态绑定、ItemDecoration 分隔线自定义、NestedScrolling 嵌套滚动协调以及上拉下拉刷新处理,并给出了 DiffUtil 数据复用和性能优化建议。XML 文件定义了页面结构与通用样式,Java 文件实现了双适配器及滚动事件传递逻辑,PNG 截图可直观对照运行效果,方便逐步跟读代码。这套代码可直接导入 Android Studio 运行,适合正在接触复杂列表布局的初中级开发者快速上手。
1. RecyclerView嵌套RecyclerView案例:为什么信息流页面绕不开“列表套列表”
RecyclerView嵌套RecyclerView案例,几乎所有做信息流页面的开发都会撞上:首页Feed里卡片套着横滑商品、订单页里订单卡片套着多个包裹、评论区里“楼中楼”套着二级评论。很多人第一次写都会问同一个问题——两个同向列表叠在一起,滑动到底归谁管?答案是不用管,系统默认会让子列表先消费事件,消费不完的距离再交还给父列表;但“能滑”和“滑得顺、不卡顿、不出错”完全是两回事。这篇不聊官网上的HelloWorld,直接按工程落地顺序来:先讲清嵌套为什么被系统允许,再给最小可跑案例,然后说透三个必调参数和四个翻车现场,最后补一套局部刷新的进阶做法。适合刚写完单个列表、正要动手做信息流页面的Android开发;如果你已经在维护嵌套列表但总觉得卡,也能找到对应的坑。
2. 嵌套之所以能跑:NestedScrolling机制与两种实现模型的取舍
2.1 嵌套滚动机制:为什么“竖套竖”默认不会抢事件
理解嵌套之前,先有一个共识:RecyclerView实现了NestedScrollingChild接口,父级RecyclerView实现了NestedScrollingParent接口。手指在子列表上滑动时,事件流不是先给父列表,而是先落到子列表;子列表调用dispatchNestedPreScroll,把“我需要滑动的距离”先告诉父列表,父列表如果不消费,子列表才自己滚动;子列表滚到底或滚到顶、距离还有剩余时,再把剩余距离通过onNestedScroll回传给父列表。
这就是“竖套竖默认不冲突”的底层原因。两个RecyclerView方向一致时,系统按“子消费、父兜底”的顺序分发,滑动体验是连贯的;横套竖也一样,方向正交,系统直接按方向逻辑各管各的。很多初学者以为嵌套列表必须手动拦截事件,其实在纯RecyclerView之间,不需要写任何onInterceptTouchEvent。真正容易翻车的是NestedScrollView包裹RecyclerView,或者两个同向列表之间需要“联动”的场景,这两类后面第4章单独讲。
2.2 模型一:真多层RecyclerView嵌套
最常见的做法,也是本案例要落地的模型:父列表的item里再放一个RecyclerView,各自用独立的Adapter、LayoutManager和RecycledViewPool。
这个模型的优点很直接:结构清晰,内层列表可以独立滚动、独立加载数据,适合“商品卡片横滑”“评论楼中楼”这类交互。代价则是性能:每多一层RecyclerView,父列表在measure和layout阶段就要多算一遍子树的尺寸,嵌套两层时帧时间明显上升;同时内层列表的预取机制经常失效,快速滑动时新item要临时创建,掉帧就出在这里。
项目经验是:嵌套层级控制在两层,并且内层列表用固定高度而不是wrap_content,否则卡顿是必然的。内层数据量超过20个时,不建议继续用wrap_content,可以考虑用固定dp高度配合横向滑动。
2.3 模型二:单RecyclerView加多ViewType摊平
另一种做法是在同一个RecyclerView里用多个ViewType,把“卡片里的子列表”摊平成父列表里的连续item。比如一个卡片有10个商品,就在Adapter里连续生成10个item,用ViewType区分卡片头部和商品条目。
摊平的优点是性能上限最高:没有嵌套结构、没有二次测量、滑动时只有一个LayoutManager在干活。缺点是逻辑割裂:商品条目和卡片头部要拼成一段连续的position序列,数据变化时计算index偏移是常见Bug来源;而且一旦子列表需要独立滚动,这个模型直接做不了。
什么时候选哪种?我的判断标准是:内层列表需要独立手势、或数据独立分页加载的,用嵌套;内层只是“展示一批静态数据”的,用摊平。这个原则能挡掉大多数性能问题。
2.4 两种模型选型表
| 判断维度 | 多层RecyclerView嵌套 | 单RecyclerView多ViewType |
|---|---|---|
| 内层独立滑动 | 支持 | 不支持 |
| 内层独立加载数据 | 支持,可单独分页 | 不支持,需自行拆分数据源 |
| 布局性能 | 较低,二次测量成本 | 高,单LayoutManager |
| Adapter可维护性 | 各列表独立,结构清晰 | position偏移计算易出错 |
| 代码量 | 中等 | 偏大 |
选型口诀:先回答“内层要不要自己滑”,要就嵌套,不要就摊平。信息流首页的“猜你喜欢”模块,我通常会先做摊平试试,数据量小、展示固定时摊平足够;只有运营要求模块内左右横滑独立分页时,才换成嵌套布局。
3. 最小可跑案例:把横滑商品列表嵌入纵向卡片Feed
3.1 数据模型与依赖准备
这个案例做的是“纵向卡片列表,每张卡片里横向滑动的商品列表”,这也是嵌套RecyclerView最典型的使用场景。依赖只用到RecyclerView本身,不需要引入第三方库。
// build.gradle.kts 模块级 dependencies { implementation("androidx.recyclerview:recyclerview:1.3.2") implementation("androidx.constraintlayout:constraintlayout:2.1.4") }两个数据类分别代表商品和商品卡片:
data class Goods( val id: Long, val name: String, val price: String ) data class GoodsCard( val cardTitle: String, val goodsList: List<Goods> )注意Goods.id是给第6章DiffUtil用的,这里先留着不亏。业务里如果确实没有唯一ID,可以用name+price拼一个临时key,千万别用position当ID,后面更新数据时错位会查得怀疑人生。
3.2 父Adapter与子Adapter的骨架代码
父Adapter负责纵向卡片列表,子Adapter负责卡片内部的横向商品列表。两个Adapter结构一样,区别只在LayoutManager方向。
class CardAdapter( private val cards: List<GoodsCard> ) : RecyclerView.Adapter<CardAdapter.CardVH>() { class CardVH(val binding: ItemCardBinding) : RecyclerView.ViewHolder(binding.root) override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): CardVH { val binding = ItemCardBinding.inflate( LayoutInflater.from(parent.context), parent, false ) return CardVH(binding) } override fun getItemCount(): Int = cards.size override fun onBindViewHolder(holder: CardVH, position: Int) { val card = cards[position] holder.binding.tvCardTitle.text = card.cardTitle // 核心:内层RecyclerView在bind时装配 holder.binding.rvGoods.apply { layoutManager = LinearLayoutManager( context, LinearLayoutManager.HORIZONTAL, false ) adapter = GoodsAdapter(card.goodsList) } } }class GoodsAdapter( private val goodsList: List<Goods> ) : RecyclerView.Adapter<GoodsAdapter.GoodsVH>() { class GoodsVH(val binding: ItemGoodsBinding) : RecyclerView.ViewHolder(binding.root) override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): GoodsVH { val binding = ItemGoodsBinding.inflate( LayoutInflater.from(parent.context), parent, false ) return GoodsVH(binding) } override fun getItemCount(): Int = goodsList.size override fun onBindViewHolder(holder: GoodsVH, position: Int) { val goods = goodsList[position] holder.binding.tvName.text = goods.name holder.binding.tvPrice.text = goods.price } }父Adapter里那句adapter = GoodsAdapter(card.goodsList)是关键中的关键。每张卡片绑定时都会创建自己的子Adapter,如果卡片数量多、商品列表有更新,这里会在滑动时频繁产生新对象。所以下一步要做的,是把内层RecyclerView的复用机制真正跑起来。
3.3 布局文件里的两个细节
父item布局item_card.xml里,内层RecyclerView的高度不要用match_parent,否则卡片会撑满整屏;也不建议用wrap_content,因为RecyclerView的wrap_content在竖屏下会把所有商品量一遍高度,数据多时卡顿。常见做法是给一个固定高度,比如160dp。
<!-- item_card.xml 核心结构 --> <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/tvCardTitle" android:layout_width="match_parent" android:layout_height="wrap_content" android:padding="12dp" android:textSize="16sp" android:textStyle="bold" /> <androidx.recyclerview.widget.RecyclerView android:id="@+id/rvGoods" android:layout_width="match_parent" android:layout_height="160dp" android:clipToPadding="false" android:paddingHorizontal="12dp" android:overScrollMode="never" /> </LinearLayout>clipToPadding="false"配合paddingHorizontal="12dp",让商品项横向滑动时能滑到卡片左右边缘的padding区域,视觉效果更自然,这是横滑列表的通用做法。overScrollMode="never"关掉内层列表的边缘光晕,嵌套场景里两层光晕叠加很难看。
3.4 跑通后必做:确认 ViewHolder 真的复用了
案例跑通后先别急着加业务,做一次验证:在内层Adapter里加日志,看滑动过程中onCreateViewHolder的调用次数。
class GoodsAdapter( private val goodsList: List<Goods> ) : RecyclerView.Adapter<GoodsAdapter.GoodsVH>() { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): GoodsVH { Log.d("GoodsAdapter", "create view holder") // 其余同前 } override fun onViewRecycled(holder: GoodsVH) { super.onViewRecycled(holder) Log.d("GoodsAdapter", "view recycled, pos=${holder.bindingAdapterPosition}") } }验证方法是:快速左右反复滑动同一个卡片的内层列表,日志里create view holder出现的次数应该稳定在“屏幕可见item数+预取缓存数”左右,而不是随着滑动距离无限增加。如果确实稳定了,说明内层RecyclerView的回收复用机制正常工作;如果一直在创建,重点检查是不是每次bind都new了LayoutManager或adapter,这会让复用池失效。
提示:这里踩过坑。有人在
onBindViewHolder里为了“保险”每次都new一个LinearLayoutManager,结果复用池完全失效,每滑一个item都新建立布局,低端机上直接卡成PPT。LayoutManager和Adapter应该只创建一次,数据变化走notify或DiffUtil,不要重建组件。
4. 滑动冲突与三个必调参数:把嵌套“玄学”变成可预期行为
4.1 滑动冲突的真正源头
先破除一个误区:两个RecyclerView同向嵌套,默认不冲突;横竖交叉也不冲突。第2章已经讲了原因——NestedScrolling机制会按“子消费完再给父”的顺序传递滑动距离。
真正需要手动干预的典型场景有两个。第一个是NestedScrollView包裹RecyclerView:NestedScrollView本身是一个滚动容器,RecyclerView也是,两者会互相竞争滑动事件,表现为页面跳动、滑动卡顿,或者RecyclerView把NestedScrollView的滚动“吃掉”。第二个是两个同向RecyclerView需要“联动”,比如子列表滑到底时父列表才开始滑,这种联动需求不是默认行为,必须自己接管事件分发。
嵌套RecyclerView本身的滑动冲突,绝大多数情况不是事件冲突,而是性能冲突:测量成本、布局成本、复用失效。所以第4章的三个参数,本质都是冲着“让嵌套列表少干活”去的。
4.2 参数一:setNestedScrollingEnabled(false)
这个参数只在一个场景下必开:RecyclerView放在NestedScrollView等外部滚动容器里,并且希望整个页面跟随外部容器滑动,内层列表不拦截手势。
holder.binding.rvGoods.apply { // 关闭内层RecyclerView的嵌套滚动 setNestedScrollingEnabled(false) layoutManager = LinearLayoutManager(context, LinearLayoutManager.HORIZONTAL, false) adapter = GoodsAdapter(card.goodsList) }逻辑说明:调用后,内层RecyclerView不再参与NestedScrolling事件协商,它的触摸事件直接交给外层容器统一处理。常见做法是配合wrap_content把列表完整展开在NestedScrollView里,让RecyclerView“退化”成纯布局容器,所有滚动由外部接管。
但要注意边界:这个参数只适用于“内层不需要自己滑动”的场景。如果内层商品列表需要左右横滑,或者楼中楼评论需要独立滚动,setNestedScrollingEnabled(false)会直接把内层手势废掉,这种场景不要开。我的习惯是——能不开就不开,开了之后内层列表的惯性滚动、fling效果都会消失,交互上会比较“死”。
4.3 参数二:setItemViewCacheSize 与 LayoutManager 预取
RecyclerView默认每个ViewType的缓存大小是2,但嵌套场景下内层列表的缓存很容易不够用。原因在于内层item在外层item滚动出屏幕时,会连带着整个holder被回收,如果缓存只够装2个,快速滑动时就会频繁重建。
// 在内层RecyclerView装配时调整缓存 holder.binding.rvGoods.apply { setItemViewCacheSize(6) isDrawingCacheEnabled = false layoutManager = LinearLayoutManager(context, LinearLayoutManager.HORIZONTAL, false) adapter = GoodsAdapter(card.goodsList) }setItemViewCacheSize(6)的意思是,除了屏幕上的可见项外,额外保留6个离屏ViewHolder在内存里。数字不宜盲目调大,每个item如果是一张商品图,6个缓存就是6张图的内存占用;嵌套2层时这个数字还要乘内层item数量,内存压力会指数上升。一般经验值是4到8。
同时要注意父列表的onBindViewHolder里不要做耗时操作,比如图片加载、数据库读取。RecyclerView预取机制会在空闲时提前创建并绑定即将进入屏幕的item,如果bind太慢,预取就白做了,快速滑动照样掉帧。
4.4 参数三:共享 RecycledViewPool 与 setHasFixedSize
嵌套场景里,同一屏幕上有多张卡片,每张卡片内部都有一个内层RecyclerView。默认情况下,每张卡片的商品item各自维护自己的ViewHolder池,相当于同一套商品item被复制了N份缓存。共享RecycledViewPool能把这N份合并成1份。
// 全局只创建一次,不要在每个bind里new val sharedPool = RecyclerView.RecycledViewPool() // 父列表装配内层RecyclerView时 holder.binding.rvGoods.apply { setRecycledViewPool(sharedPool) setHasFixedSize(true) itemAnimator = null layoutManager = LinearLayoutManager(context, LinearLayoutManager.HORIZONTAL, false) adapter = GoodsAdapter(card.goodsList) }三个参数逐个说。setRecycledViewPool(sharedPool)让所有内层商品列表共用一个ViewHolder池,商品item滑出屏幕后,holder不会销毁,而是回到共享池里,下一个卡片需要时直接取出来绑定新数据。前提是共享池的item布局和viewType必须完全一致,否则会取出错误的holder——比如某天有个卡片模板改成了双列网格,viewType变了一个新值,旧池里的holder就永远不会被命中,共享池反而变成内存黑洞。
setHasFixedSize(true)告诉RecyclerView,item内容变化不会改变RecyclerView自身尺寸,于是它跳过重新measure的过程。嵌套场景下这个信号很重要,每张卡片内部都是横向固定高度,尺寸确实不变,可以放心开。
itemAnimator = null是性能取舍。嵌套列表的默认动画在布局变化时会产生额外的布局计算,信息流场景里用户对“商品位置移动的动画”感知很弱,反而卡顿感知很强,关掉是常规操作。
5. 嵌套RecyclerView避坑手册:4个高频翻车现场
5.1 翻车一:内层列表高度塌陷,只显示一条item
现象:内层RecyclerView在界面上只有一行商品,甚至完全看不到,外层卡片压缩成一条细线。
原因:RecyclerView的wrap_content高度依赖LayoutManager测量所有item才能确定最终高度。如果外层item数据在bind之后又发生了异步更新(比如图片加载完成触发notifyDataSetChanged),内层RecyclerView可能被重新measure时拿不到正确高度,于是塌陷。另一个常见诱因是,外层item的根布局用了match_parent高度放在NestedScrollView里,导致高度被压缩成剩余空间。
解决:把内层RecyclerView高度从wrap_content改成固定dp高度,这是治标最稳的方法;如果确实要动态高度,自定义一个LinearLayoutManager重写onMeasure,主动测量所有子item的高度之和再setMeasuredDimension。我一般会写一个通用的FixedLinearLayoutManager,只在确认内层数据量不超过20条时使用,超过20条基本不可控,要么固定高度,要么临时摊平。
5.2 翻车二:快速滑动掉帧,卡顿明显
现象:父列表快速滑动时,帧率掉到30fps以下,卡片进入屏幕时能看到明显的“一块一块”加载过程。
原因:这个是最常见的三个因素叠加——内层RecyclerView高度是wrap_content导致每次都要测量全部item;onBindViewHolder里做了耗时操作比如图片加载入口;ViewHolder缓存太小导致快速滑动时频繁创建。第3章的日志验证法派上用场:如果onCreateViewHolder调用次数随滑动距离无限增长,就是缓存命中率太低。
解决:按优先级处理。先改固定高度,再关itemAnimator,然后setItemViewCacheSize调整缓存,最后检查bind里有没有做重活。图片加载务必放到ImageLoader库里异步处理,不要在bind里做任何磁盘或网络IO。还有一个小坑:如果内层商品item有圆角或者阴影,尽量避免在onDraw里新建Paint和Path,这些割裂会在嵌套的二次绘制下放大卡顿。
5.3 翻车三:滚动回来,内层列表位置错乱
现象:父列表滑到很下面的卡片,再滑回来,之前横向滚过的商品列表回到了初始位置,或者和另一张卡片的位置“串”了。
原因:ViewHolder被回收复用后,新数据bind上来时,RecyclerView不会自动恢复内层列表的滚动位置。外层卡片滑出屏幕,内层holder进缓存;滑回来时取出的可能是另一个position的holder,横向滚动位置自然丢了;严重时商品item数据错乱,出现“A卡片的标题配B卡片的商品”。
解决:核心思路是把内层列表的滚动位置存起来,用完恢复。惯用做法是用SparseArray<Int>按卡片position保存横向滑动偏移,在bind时恢复。
class CardAdapter( private val cards: List<GoodsCard> ) : RecyclerView.Adapter<CardAdapter.CardVH>() { private val scrollOffsets = SparseIntArray() override fun onBindViewHolder(holder: CardVH, position: Int) { val card = cards[position] holder.binding.tvCardTitle.text = card.cardTitle holder.binding.rvGoods.apply { layoutManager = LinearLayoutManager(context, LinearLayoutManager.HORIZONTAL, false) adapter = GoodsAdapter(card.goodsList) // 恢复之前保存的横向偏移 if (scrollOffsets.get(position, -1) >= 0) { scrollBy(scrollOffsets.get(position), 0) } } } override fun onViewRecycled(holder: CardVH) { super.onViewRecycled(holder) val pos = holder.bindingAdapterPosition if (pos != RecyclerView.NO_POSITION) { // 保存当前横向偏移 scrollOffsets.put(pos, holder.binding.rvGoods.computeHorizontalScrollOffset()) } } }onViewRecycled在holder被回收时调用,此时内层RecyclerView还在,能取到当前滚动偏移;SparseIntArray按position保存,比HashMap<Integer, Integer>省内存。注意bindingAdapterPosition在列表滚动期间可能是-1,判断一下避免掉进坑里。这种做法同样适用于需要保存内层“当前滑到第几个item”的场景。
5.4 翻车四:异步更新数据时崩溃或item错乱
现象:页面数据异步返回后调用notifyDataSetChanged,偶尔直接崩溃,抛IllegalStateException: Scrapped or attached views may not be recycled;不崩溃时列表闪烁,内层商品显示错配。
原因:嵌套场景下,外层item可能正在被滑动回收,此时内层RecyclerView也处于layout状态;notifyDataSetChanged是全量刷新,会强制所有item重新bind,如果这个调用发生在主线程的layout过程中,RecyclerView内部状态不一致就会崩溃。全量刷新还会把共享池里的holder全部清掉重来,闪烁和错配随之而来。
解决:禁止在非UI线程调用任何notify系列方法,这是底线;数据回来后runOnUiThread或切主线程再刷新;同时用notifyItemRangeChanged代替notifyDataSetChanged,能缩小刷新范围就不要全量刷。嵌套场景下更推荐的做法是上DiffUtil,也就是第6章的内容,它只在数据真正变化时更新对应item,状态一致性最好。
注意:遇到“数据对不上”的Bug,先看一眼是不是多个内层RecyclerView共享了同一个Adapter实例。父列表卡片有10个,结果10个内层列表全共用一个
GoodsAdapter,数据必然串。每个内层RecyclerView要有自己独立的Adapter,可以共享的只有RecycledViewPool。
6. 进阶技巧:用DiffUtil和Payload做内层列表局部刷新
嵌套列表的刷新,核心原则是“谁变化就刷谁”。第5章说过notifyDataSetChanged的低效,这一章给出替代方案:ListAdapter+AsyncListDiffer,让内层商品列表在数据更新时自动做差异计算,只刷新变化的item。
内层Adapter改成ListAdapter:
class GoodsDiff : DiffUtil.ItemCallback<Goods>() { override fun areItemsTheSame(oldItem: Goods, newItem: Goods): Boolean { return oldItem.id == newItem.id } override fun areContentsTheSame(oldItem: Goods, newItem: Goods): Boolean { return oldItem == newItem } } class GoodsAdapter : ListAdapter<Goods, GoodsAdapter.GoodsVH>(GoodsDiff()) { class GoodsVH(val binding: ItemGoodsBinding) : RecyclerView.ViewHolder(binding.root) override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): GoodsVH { val binding = ItemGoodsBinding.inflate( LayoutInflater.from(parent.context), parent, false ) return GoodsVH(binding) } override fun onBindViewHolder(holder: GoodsVH, position: Int) { val goods = getItem(position) holder.binding.tvName.text = goods.name holder.binding.tvPrice.text = goods.price } }数据更新时,不再手动notifyDataSetChanged,而是调用submitList(newList)。AsyncListDiffer会在后台线程计算新旧列表的差异,然后精准调用notifyItemInserted、notifyItemRemoved等增量方法。这个过程自动完成,不需要自己写diff逻辑。areItemsTheSame返回true表示“还是同一个商品”,areContentsTheSame返回false表示“商品数据变了”,这套语义是DiffUtil的核心。
如果只改了价格这种单个字段,DiffUtil仍然会走一次完整bind。想再省一步,用带payloads的onBindViewHolder:
override fun onBindViewHolder(holder: GoodsVH, position: Int, payloads: List<Any>) { if (payloads.isEmpty()) { // 无payload,走完整绑定 onBindViewHolder(holder, position) } else { // 有payload,只更新价格 val newPrice = payloads.firstOrNull() as? String if (newPrice != null) { holder.binding.tvPrice.text = newPrice } } }提交时带payload:
// 示例:某个商品的price更新了 fun updatePrice(position: Int, newPrice: String) { // 第二个参数作为payload传给上面的onBindViewHolder notifyItemChanged(position, newPrice) }如果商品item本身有绑定图片或复杂布局,payload能免掉图片重载,局部刷新在嵌套列表里节省的不只是layout时间,还有内存抖动。我的习惯是,凡是嵌套场景里的内层Adapter,一律用ListAdapter;外层卡片列表如果数据量也大,同样换成ListAdapter——这是把两层列表从“能跑”推向“丝滑”最干净的一步。
回头看这些坑,翻得最多的还是高度塌陷和位置错乱。早期接过一个某公司电商首页的项目,内层商品列表用wrap_content加全量刷新,低端机上滑动掉帧严重,线上反馈一大片;后来改成固定高度、共享Pool加DiffUtil,问题基本绝迹。嵌套RecyclerView本身不复杂,复杂的是一旦没用对,排查成本远比一开始写对高得多。写之前花十分钟把高度、缓存、刷新策略定下来,后面能省好几个通宵。希望帮到你。
本文还有配套的精品资源,点击获取