Android RecyclerView嵌套列表:手势、测量与状态管理
简介围绕 RecyclerView 嵌套 RecyclerView 的 Android 实战案例面向已掌握基础列表开发、希望处理多层数据展示与嵌套滚动问题的开发者。案例以外层主列表搭配内层网格为典型结构涉及双适配器与 ViewHolder 复用、LinearLayoutManager 与 GridLayoutManager 配置、ItemDecoration 间隔处理、NestedScrolling 事件传递以及上拉下拉刷新、数据更新和性能优化思路可帮助读者理解复杂列表的拆分方式、滑动协同与复用策略。压缩包共 65 个文件包含 30 个 xml、10 个 java、9 个 png 及 gradle、properties 等工程配置整体约 260KB目录覆盖源码、资源与构建脚本便于直接导入 Android Studio 对照学习。目前已有 1248 人学习可作为嵌套列表、刷新交互与复用优化的参考样例读者能从中梳理内外层适配器的职责边界、滑动冲突的处理路径以及数据更新时的性能取舍适合用于课程设计、项目预研或相关功能二次开发前的方案比对。1. 从一张被拖慢的商品列表说起前阵子接手一个首页改版外层是纵向 RecyclerView每个区块里再横排一溜卡片。上线前一天被回归测试拦下来横着划卡片的时候整页会跟着上下抖划快了还会直接弹回顶部。改了三版才想明白RecyclerView 嵌套 RecyclerView 的难点从来不在写不出来而在三件事上——手势归谁处理、高度谁来测量、状态由谁保管。这个案例工程就是把这三件事拆开做成了能跑的最小复现纵向外层套横向内层一套纵向外层套不滚动的网格内层一套配上清晰的包结构和能直接运行的示例页。适合刚接手别人列表代码的人、正在做商品详情或社区楼层的人以及被「内层只显示一条」「滑两下就掉帧」折磨过的人。下面按我拆这份代码的顺序走一遍。2. 工程骨架三层包结构与 Adapter 的职责边界这份代码没有引入任何第三方列表库全部基于 androidx 的 RecyclerView 展开所以读起来不会有一层又一层的封装壳子。它的组织方式很朴素数据模型、适配器、宿主界面三层内层适配器和外层适配器物理隔离。朴素归朴素恰恰是这种结构最容易在别人手里改坏也最容易看出原作者有没有想清楚「谁负责什么」。2.1 目录怎么分层先把包结构摊开看落到文件级别其实只有六七个关键类路径职责model/FeedItem.kt外层列表的区块模型用 sealed class 区分轮播区、网格区、楼层区adapter/FeedAdapter.kt外层纵向列表的适配器负责分发区块类型adapter/inner/CardStripAdapter.kt内层横向卡片列表的适配器adapter/inner/ReplyGridAdapter.kt内层不滚动网格的适配器widget/HorizontalInnerRecyclerView.kt处理横向手势优先级的自定义 RecyclerViewwidget/NoScrollGridLayoutManager.kt让竖向内层高度被内容撑开的 LayoutManagerui/FeedActivity.kt宿主页组装数据、接收点击回调adapter目录下单独开了inner子包这个细节看着不起眼实际很省事。嵌套列表最容易出的问题是两个适配器的ViewHolder混用、getItemViewType常量撞号物理隔开之后review 的时候一眼就能看出谁在改内层、谁在动外层。宿主层只做两件事造数据、接回调。它不碰LayoutManager也不碰内层适配器这条边界建议保留。我见过不少工程在 Activity 里直接findViewById拿到内层 RecyclerView 再 setAdapter看着省事等到列表复用、页面重建的时候就是一堆空指针。2.2 数据模型把「区块」和「条目」拆成两层外层列表里每一项其实是一个区块区块自己带着一批子条目。用sealed class表达比用type: Int加Any强得多when分支写不全编译器直接报错加新类型的时候不会漏掉onCreateViewHolder。// model/FeedItem.kt sealed class FeedItem { // 每个区块必须有一个稳定 id后面保存内层滚动位置要靠它 abstract val id: Long data class BannerSection( override val id: Long, val title: String, val cards: ListCardItem ) : FeedItem() data class ReplySection( override val id: Long, val floor: Int, val replies: ListReplyItem ) : FeedItem() data class CardItem( val cardId: Long, val name: String, val coverUrl: String ) data class ReplyItem( val replyId: Long, val content: String ) }这段代码里最值得抄的是那个id。很多案例直接用position当键去缓存内层滚动位置插入一条数据之后全部错位表现出来就是「刷新后横向列表莫名其妙停在中间」。用业务侧的稳定 id 做键插入删除都不会串。data class的equals后面接 DiffUtil 时也能直接复用。子条目也单独建模CardItem、ReplyItem而不是塞成MapString, Any。看着多写了两个类实际省掉的是内层适配器里成片的类型转换和判空。2.3 外层 Adapter把内层 Adapter 挂在 ViewHolder 上这是整份代码里最关键的一个决定。内层适配器不是在外层onBindViewHolder里新建的而是作为外层ViewHolder的成员变量跟着 ViewHolder 一起生、一起死。// adapter/FeedAdapter.kt class FeedAdapter( private val onCardClick: (FeedItem.CardItem) - Unit ) : RecyclerView.AdapterRecyclerView.ViewHolder() { private val items mutableListOfFeedItem() fun submit(list: ListFeedItem) { items.clear() items.addAll(list) notifyDataSetChanged() } override fun getItemViewType(position: Int): Int when (items[position]) { is FeedItem.BannerSection - TYPE_BANNER is FeedItem.ReplySection - TYPE_REPLY } override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { val inflater LayoutInflater.from(parent.context) return when (viewType) { TYPE_BANNER - BannerHolder( inflater.inflate(R.layout.item_banner_section, parent, false), onCardClick ) else - ReplyHolder( inflater.inflate(R.layout.item_reply_section, parent, false) ) } } override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { when (val item items[position]) { is FeedItem.BannerSection - (holder as BannerHolder).bind(item) is FeedItem.ReplySection - (holder as ReplyHolder).bind(item) } } override fun getItemCount(): Int items.size companion object { private const val TYPE_BANNER 1 private const val TYPE_REPLY 2 } }onCreateViewHolder走一次内层适配器就建一次之后滑出屏幕再滑回来onBindViewHolder反复调用只是把新数据灌进已有的内层适配器。反过来写——每次 bind 都new CardStripAdapter()——后果是每次滑动都丢一个LayoutManager和一个RecycledViewPoolGC 频率直接拉满表现就是列表一快滑就一顿一顿。而且旧的内层滚动位置永远保不住用户横划到第三张上下滑一下再回来又从头开始了。getItemViewType里用when覆盖 sealed class 全部分支将来加一种区块这里不改编译器会警告属于免费的安全网。顺带说一句submit里那个notifyDataSetChanged()。案例里先用它是因为它足够直白能让人把注意力放在嵌套结构上真要上生产换成DiffUtil或ListAdapter这部分在第 6 章展开。3. 内层横向列表布局写法和绑定时机外层骨架清楚之后内层的活儿其实就三件布局怎么摆、适配器什么时候建、点击怎么传出去。三件事里最容易翻车的是第二件因为它的错误不会立刻报错而是以一种「用久了才卡」的方式出现。3.1 item 布局横向内层的高度必须给死横向滚动的 RecyclerView在竖直方向上不会自己算出该多高——它的子项再怎么长也只影响横向。所以外层 item 布局里内层 RecyclerView 的高度必须是一个确定值wrap_content下去大概率塌成 0什么都看不见。!-- res/layout/item_banner_section.xml -- LinearLayout xmlns:androidhttp://schemas.android.com/apk/res/android android:layout_widthmatch_parent android:layout_heightwrap_content android:orientationvertical android:paddingTop16dp TextView android:idid/tv_section_title android:layout_widthwrap_content android:layout_heightwrap_content android:layout_marginStart16dp android:textSize18sp android:textStylebold / !-- 横向列表高度必须写死wrap_content 会塌成 0 -- androidx.recyclerview.widget.RecyclerView android:idid/rv_inner_cards android:layout_widthmatch_parent android:layout_height132dp android:layout_marginTop12dp android:clipToPaddingfalse android:paddingStart16dp android:paddingEnd16dp android:scrollbarsnone / /LinearLayoutclipToPaddingfalse加上左右 padding是让第一张和最后一张卡片滑动时不被边缘裁掉横向列表必备。scrollbarsnone去掉那条常驻的滚动条视觉上更像内容流而不是一个控件。高度 132dp 是给固定尺寸卡片的如果卡片宽度要按屏幕算比如一屏露出 2.3 张暗示还能往右滑在宿主里算完再用layoutParams回填// 一屏露出两张半右边露出半张是在暗示可以横滑 val screenWidth resources.displayMetrics.widthPixels val horizontalPadding dp(16) * 2 val gap dp(12) val cardWidth ((screenWidth - horizontalPadding - gap * 2) / 2.3f).toInt()算出来的宽度同时给卡片根布局和内层 item 的itemView别只给一边否则会出现「卡片宽度对了但间距错位」。3.2 内层 Adapter 的创建时机与复用把内层适配器挂在 ViewHolder 上落地的写法是这样// FeedAdapter 内部的横向区块 ViewHolder inner class BannerHolder( itemView: View, onCardClick: (FeedItem.CardItem) - Unit ) : RecyclerView.ViewHolder(itemView) { private val title: TextView itemView.findViewById(R.id.tv_section_title) private val inner: RecyclerView itemView.findViewById(R.id.rv_inner_cards) // 关键adapter 与 holder 同生命周期只创建一次 private val adapter CardStripAdapter(onCardClick) init { inner.layoutManager LinearLayoutManager(itemView.context, RecyclerView.HORIZONTAL, false) inner.adapter adapter // 内层 item 尺寸固定省掉一次重新测量的开销 inner.setHasFixedSize(true) // 内层不需要增删动画滑起来更跟手 inner.itemAnimator null inner.addItemDecoration(SpaceDecoration(dp(12))) } fun bind(section: FeedItem.BannerSection) { title.text section.title adapter.submit(section.cards) // 复用回来时把横向偏移归零否则会带着上一个区块的位置 inner.scrollToPosition(0) } private fun dp(value: Int): Int (value * itemView.resources.displayMetrics.density).toInt() }init块里的四行配置每一行都在解决一个具体问题。setHasFixedSize(true)告诉 RecyclerView「我的尺寸不随内容变」外层每次布局时可以跳过一轮测量itemAnimator null关掉内层的增删动画因为横向列表的数据几乎总是整体替换开动画只会产生「卡片乱飞」的观感还会多一次布局遍历。SpaceDecoration是常见的等距 ItemDecoration比自己给每个卡片加 margin 更稳因为 margin 不会出现在首尾。bind里的scrollToPosition(0)是血泪经验。ViewHolder 是复用的第 8 个区块用的很可能就是第 3 个区块那个 holder横向偏移还留在原地用户看到的就是「刚滑到一半的卡片翻上去再翻下来位置全乱了」。第 4 章会讲怎么在归零和「记住用户滑到哪」之间做取舍。3.3 点击与数据回传别让回调穿过三层内层卡片的点击要传回宿主页面链路是「卡片 → 内层适配器 → 外层适配器 → Activity」。链路长但别用静态变量或者全局消息总线抄近路页面重建之后监听器挂着不放就是内存泄漏。// adapter/inner/CardStripAdapter.kt class CardStripAdapter( private val onClick: (FeedItem.CardItem) - Unit ) : RecyclerView.AdapterCardStripAdapter.CardHolder() { private val data mutableListOfFeedItem.CardItem() fun submit(list: ListFeedItem.CardItem) { data.clear() data.addAll(list) notifyDataSetChanged() } override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): CardHolder { val view LayoutInflater.from(parent.context) .inflate(R.layout.item_card, parent, false) return CardHolder(view) } override fun onBindViewHolder(holder: CardHolder, position: Int) { val item data[position] holder.bind(item) // 点击事件在 bind 里绑定item 随 holder 复用时自动更新 holder.itemView.setOnClickListener { onClick(item) } } override fun getItemCount(): Int data.size class CardHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { private val name: TextView itemView.findViewById(R.id.tv_name) private val cover: ImageView itemView.findViewById(R.id.iv_cover) fun bind(item: FeedItem.CardItem) { name.text item.name // 图片加载走项目里已有的图片库这里只固定占位尺寸 cover.layoutParams.height itemView.resources.displayMetrics.widthPixels / 3 } } }回调通过构造函数一路传下去FeedAdapter拿到之后原样递给BannerHolderBannerHolder再递给CardStripAdapter。宿主页面里就是一个 lambda// ui/FeedActivity.kt val adapter FeedAdapter { card - // 内层卡片被点击这里拿到的是完整的数据对象不是 position startActivity(DetailActivity.intent(this, card.cardId)) }传对象而不是传 position 是另一个小习惯。position 在列表刷新后立刻失效回调里再list[position]很容易越界或者拿到错的数据。4. 滑动、测量与状态嵌套列表的三个硬骨头前面两章搭出来的是一个「能显示」的嵌套列表「能用」还差三关。这三关的共同点是出问题时不会有异常堆栈只有一个不对劲的视觉现象排查全凭经验。4.1 手势归谁方向判断比一把梭更靠谱横向内层嵌在纵向外层里手指斜着划的时候两个 RecyclerView 都想处理这次滑动。默认行为是父容器先拿到onInterceptTouchEvent判定为竖直滑动就直接拦截内层的横滑就废了。粗暴解法是在内层的ACTION_DOWN里直接parent.requestDisallowInterceptTouchEvent(true)把父容器锁死。这么写横滑是好了但用户在卡片上往下滑想翻页时会发现滑不动——父容器被锁着得松手再按一次。正确做法是按下时先锁移动时按主方向判断要不要继续锁// widget/HorizontalInnerRecyclerView.kt class HorizontalInnerRecyclerView JvmOverloads constructor( context: Context, attrs: AttributeSet? null, defStyle: Int 0 ) : RecyclerView(context, attrs, defStyle) { 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 // 先假设这次是横滑锁住父容器 parent?.requestDisallowInterceptTouchEvent(true) } MotionEvent.ACTION_MOVE - { val dx abs(e.x - downX) val dy abs(e.y - downY) // 竖直位移更大就把手势还给外层否则继续由内层接管 parent?.requestDisallowInterceptTouchEvent(dx dy) } MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL - parent?.requestDisallowInterceptTouchEvent(false) } return super.onInterceptTouchEvent(e) } }三个参数值得说清楚downX/downY记录的是按下点每次新的ACTION_DOWN都要重置否则第二次滑动会用上一次的坐标做基准dx dy这个判断加了个很小的容差会更稳比如dx dy * 1.2f防止用户手抖时方向反复横跳ACTION_UP和ACTION_CANCEL都必须释放锁漏掉CANCEL会导致下拉刷新一直转圈不结束。竖向内层如果本身不该滚动只作为布局容器更省事的做法是直接isNestedScrollingEnabled false配合下一节的测量方案连手势冲突都不会发生。4.2 竖向内层的测量wrap_content 为什么塌成 0网格形态的内层比如楼层下面的回复九宫格如果设成wrap_content高度经常会发现只显示一行或者干脆什么都不显示。原因是 RecyclerView 在滚动方向上默认按「无限长」处理测量时拿到UNSPECIFIED或AT_MOST就按可用空间给自己定高内容撑不开它。两种解法。一种是固定高度适合行数确定的场景比如永远最多两行。另一种是重写LayoutManager.onMeasure先不限高量一遍拿到真实高度再把这个高度交回去// widget/NoScrollGridLayoutManager.kt class NoScrollGridLayoutManager( context: Context, spanCount: Int ) : GridLayoutManager(context, spanCount) { override fun onMeasure( recycler: RecyclerView.Recycler, state: RecyclerView.State, widthSpec: Int, heightSpec: Int ) { if (state.itemCount 0) { super.onMeasure(recycler, state, widthSpec, heightSpec) return } // 第一步不限高量一次让所有子项参与测量 val unlimited View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED) super.onMeasure(recycler, state, widthSpec, unlimited) // 第二步用真实高度作为精确值再量一次但不超过父容器给的上限 val realHeight measuredHeight val maxHeight View.MeasureSpec.getSize(heightSpec) val finalHeight if (heightSpec 0) realHeight else minOf(realHeight, maxHeight) super.onMeasure( recycler, state, widthSpec, View.MeasureSpec.makeMeasureSpec(finalHeight, View.MeasureSpec.EXACTLY) ) } }配合inner.isNestedScrollingEnabled false使用效果是「全部子项一次性铺开、自己不滚动、高度跟着条目数量走」。注意第一步的UNSPECIFIED会让所有子项都参与测量条目特别多的时候比如几百条回复这一下会很贵所以案例里网格内层只用在条目数可控的场景多了就改成「展开更多」的分页。minOf(realHeight, maxHeight)这一行别省。不限高量出来的高度可能远超屏幕不加限制的话外层 item 会被撑得极长出现「一屏只看到一条」的诡异效果。4.3 滚动位置复用回来别带着上一家的偏移第 3 章bind里那句scrollToPosition(0)保证了不错乱但也牺牲了体验用户横划看了几张卡片上下滚一段再回来位置没了。更好的做法是按区块 id 存偏移量绑定的时候还原。// FeedAdapter 里维护一份区块 id - 横向偏移 的映射 private val innerOffsets SparseLongArray() inner class BannerHolder(...) : RecyclerView.ViewHolder(itemView) { private var currentSectionId: Long -1L fun bind(section: FeedItem.BannerSection) { currentSectionId section.id title.text section.title adapter.submit(section.cards) // 还原这个区块上次的横向偏移没有记录就停在起点 val offset innerOffsets.get(section.id, 0L).toInt() (inner.layoutManager as LinearLayoutManager) .scrollToPositionWithOffset(0, offset) } fun saveOffset() { if (currentSectionId -1L) return // computeHorizontalScrollOffset 返回已滚动的像素数比记 position 更精确 innerOffsets.put(currentSectionId, inner.computeHorizontalScrollOffset().toLong()) } }在onViewRecycled里调saveOffset()把位置存下来再让 holder 被回收override fun onViewRecycled(holder: RecyclerView.ViewHolder) { if (holder is BannerHolder) holder.saveOffset() super.onViewRecycled(holder) }用computeHorizontalScrollOffset()而不是记第一个可见项的下标是因为用户可能停在两张卡片中间记下标会把位置「吸附」到整卡位置来回几次就漂移了。用像素偏移还原更接近用户真实看到的状态。代价是SparseLongArray会一直增长。区块数量有限一般几十个问题不大如果是无限加载的流就得在列表底部回收时清理掉远端条目对应的键否则它就是个缓慢长大的内存表。5. 避坑与排查五个我确实踩过的坑这一章按「现象 → 原因 → 解决」写都是嵌套列表里出现频率最高的几个遇到对不上号的时候可以直接拿来比对。内层一条都不显示或者只显示一行。现象是布局里明明有数据界面上要么空白一片要么只露出第一行。原因基本锁定在测量上竖向内层的layout_height写了wrap_contentRecyclerView 在滚动方向上按可用空间给自己定高子项撑不开它。解决办法是第 4.2 节那个NoScrollGridLayoutManager或者干脆给内层一个固定高度并让内容自己滚动。排查时可以用 Layout Inspector 看内层的measuredHeight是 0 或者等于一行的高度就是这个原因。横滑时整页跟着上下抖。现象是手指斜着划页面上下位移和卡片横滑同时发生松手后还会回弹。原因是内外两层都在消费同一串MotionEvent父容器没有正确让位。解决办法是第 4.1 节的自定义 RecyclerView按下时锁父容器、移动时按主方向决定去留。有个容易漏的点如果外层套了下拉刷新控件ACTION_UP里必须释放锁否则刷新头会一直挂着不收回。刷新数据后内层滚动位置全乱图片也跟着错位。现象是下拉刷新一次横向列表停在各种奇怪的位置或者卡片上的图和文字对不上。原因有两个叠加ViewHolder 复用没有重置内层偏移以及notifyDataSetChanged让所有 holder 重新绑定却保留了旧的滚动状态。解决分两步bind里先按区块 id 还原偏移第 4.3 节再把notifyDataSetChanged换成DiffUtil让没变的区块不重新绑定。图片错位单独看的话检查图片库的tag是否用了稳定的cardId。列表越滑越卡滑到后面明显掉帧。现象是前几屏还行滑到二三十条之后开始顿。原因通常不是嵌套本身而是onBindViewHolder里做了重活每次 bind 都新建内层适配器、每次 bind 都重新addItemDecoration、或者图片按原图尺寸解码。解决办法是把内层适配器提到 ViewHolder 成员里第 3.2 节ItemDecoration放在init块里只加一次图片固定一个接近实际显示尺寸的目标宽高。查的时候开一下 GPU 渲染分析柱状图里超标的帧基本能对上是 bind 耗时还是图片解码。内层卡片点了没反应或者点到了外层。现象是点击内层卡片触发的是外层 item 的点击事件或者内层卡片完全不响应。原因分两种内层itemView没设clickable事件冒泡到了外层或者外层 item 根布局设了android:clickabletrue但没有focusable焦点分配异常。解决办法是内层itemView.isClickable truesetOnClickListener会自动置位外层如果确实需要整体点击就在内层的触摸回调里requestDisallowInterceptTouchEvent(true)或者在onTouchEvent里返回true吃掉事件别指望布局属性解决。6. 二次开发把这个案例改成能复用的组件案例本身是个复现工程真要落到业务里还得往上抽一层。我一般按这个顺序改先把「区块 子列表」这层结构泛型化再把数据刷新换成DiffUtil最后把内层状态回传做成接口。泛型化的核心是把内层适配器抽象成一个InnerAdapterT外层只依赖接口不依赖具体实现interface InnerAdapterT { fun submit(list: ListT) fun saveState(): Long fun restoreState(offset: Long) }CardStripAdapter和ReplyGridAdapter各实现一份外层 ViewHolder 只持有一个InnerAdapter*新增一种内层形态不用改外层代码。这套写法在区块类型超过四五种之后收益很明显之前每加一种都得复制一遍onCreateViewHolder的分支。状态回传建议用接口而不是 lambda 层层透传尤其是回调超过三个的时候interface SectionCallback { fun onCardClick(card: FeedItem.CardItem) fun onSectionVisible(sectionId: Long) fun onMoreClick(sectionId: Long) }宿主实现这个接口FeedAdapter构造时传进去内层需要哪个就调哪个。比一路 lambda 传下去清楚也方便在 Fragment 重建时重新挂载。刷新这块ListAdapter加DiffUtil.ItemCallback是最省事的做法把submit换成submitList就行。注意areItemsTheSame比较的是idareContentsTheSame比较整个对象前者决定「是不是同一条」后者决定「要不要重绘」。写反了会出现「数据变了但界面不动」的玄学现象排查起来很费时间。验证方法我固定跑三步一是 Layout Inspector 看内层的层级有没有异常嵌套正常情况下内层不应该被包在 ScrollView 里二是开 GPU 渲染分析滑两屏看有没有连续超标的帧三是用adb shell dumpsys gfxinfo 包名 framestats抓一段滑动数据重点看 95 分位的帧耗时比肉眼判断准。三步都过基本可以认为嵌套结构没埋雷。有个习惯从那以后我一直保持着任何嵌套列表的代码提交前都要在onViewRecycled里打一行日志确认回收确实发生了、状态确实被保存了。因为嵌套列表出问题的方式往往是静默的——没有崩溃、没有警告只是某个位置莫名其妙不对等用户反馈上来再查成本翻好几倍。希望帮到你。本文还有配套的精品资源点击获取