
简介面向 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内层独立滑动支持不支持内层独立加载数据支持可单独分页不支持需自行拆分数据源布局性能较低二次测量成本高单LayoutManagerAdapter可维护性各列表独立结构清晰position偏移计算易出错代码量中等偏大选型口诀先回答“内层要不要自己滑”要就嵌套不要就摊平。信息流首页的“猜你喜欢”模块我通常会先做摊平试试数据量小、展示固定时摊平足够只有运营要求模块内左右横滑独立分页时才换成嵌套布局。3. 最小可跑案例把横滑商品列表嵌入纵向卡片Feed3.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: ListGoods )注意Goods.id是给第6章DiffUtil用的这里先留着不亏。业务里如果确实没有唯一ID可以用nameprice拼一个临时key千万别用position当ID后面更新数据时错位会查得怀疑人生。3.2 父Adapter与子Adapter的骨架代码父Adapter负责纵向卡片列表子Adapter负责卡片内部的横向商品列表。两个Adapter结构一样区别只在LayoutManager方向。class CardAdapter( private val cards: ListGoodsCard ) : RecyclerView.AdapterCardAdapter.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: ListGoods ) : RecyclerView.AdapterGoodsAdapter.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:androidhttp://schemas.android.com/apk/res/android android:layout_widthmatch_parent android:layout_heightwrap_content android:orientationvertical TextView android:idid/tvCardTitle android:layout_widthmatch_parent android:layout_heightwrap_content android:padding12dp android:textSize16sp android:textStylebold / androidx.recyclerview.widget.RecyclerView android:idid/rvGoods android:layout_widthmatch_parent android:layout_height160dp android:clipToPaddingfalse android:paddingHorizontal12dp android:overScrollModenever / /LinearLayoutclipToPaddingfalse配合paddingHorizontal12dp让商品项横向滑动时能滑到卡片左右边缘的padding区域视觉效果更自然这是横滑列表的通用做法。overScrollModenever关掉内层列表的边缘光晕嵌套场景里两层光晕叠加很难看。3.4 跑通后必做确认 ViewHolder 真的复用了案例跑通后先别急着加业务做一次验证在内层Adapter里加日志看滑动过程中onCreateViewHolder的调用次数。class GoodsAdapter( private val goodsList: ListGoods ) : RecyclerView.AdapterGoodsAdapter.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包裹RecyclerViewNestedScrollView本身是一个滚动容器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)告诉RecyclerViewitem内容变化不会改变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导致每次都要测量全部itemonBindViewHolder里做了耗时操作比如图片加载入口ViewHolder缓存太小导致快速滑动时频繁创建。第3章的日志验证法派上用场如果onCreateViewHolder调用次数随滑动距离无限增长就是缓存命中率太低。解决按优先级处理。先改固定高度再关itemAnimator然后setItemViewCacheSize调整缓存最后检查bind里有没有做重活。图片加载务必放到ImageLoader库里异步处理不要在bind里做任何磁盘或网络IO。还有一个小坑如果内层商品item有圆角或者阴影尽量避免在onDraw里新建Paint和Path这些割裂会在嵌套的二次绘制下放大卡顿。5.3 翻车三滚动回来内层列表位置错乱现象父列表滑到很下面的卡片再滑回来之前横向滚过的商品列表回到了初始位置或者和另一张卡片的位置“串”了。原因ViewHolder被回收复用后新数据bind上来时RecyclerView不会自动恢复内层列表的滚动位置。外层卡片滑出屏幕内层holder进缓存滑回来时取出的可能是另一个position的holder横向滚动位置自然丢了严重时商品item数据错乱出现“A卡片的标题配B卡片的商品”。解决核心思路是把内层列表的滚动位置存起来用完恢复。惯用做法是用SparseArrayInt按卡片position保存横向滑动偏移在bind时恢复。class CardAdapter( private val cards: ListGoodsCard ) : RecyclerView.AdapterCardAdapter.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保存比HashMapInteger, 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的低效这一章给出替代方案ListAdapterAsyncListDiffer让内层商品列表在数据更新时自动做差异计算只刷新变化的item。内层Adapter改成ListAdapterclass GoodsDiff : DiffUtil.ItemCallbackGoods() { 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 : ListAdapterGoods, 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的onBindViewHolderoverride fun onBindViewHolder(holder: GoodsVH, position: Int, payloads: ListAny) { 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本身不复杂复杂的是一旦没用对排查成本远比一开始写对高得多。写之前花十分钟把高度、缓存、刷新策略定下来后面能省好几个通宵。希望帮到你。本文还有配套的精品资源点击获取