尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

RecyclerView嵌套RecyclerView实战:高度测量、滑动冲突与性能优化全攻略

RecyclerView嵌套RecyclerView实战:高度测量、滑动冲突与性能优化全攻略 简介RecyclerView嵌套RecyclerView是Android开发中处理多层级列表的常见需求这套案例资源面向具备一定RecyclerView基础、希望掌握内外层列表联动的开发者围绕适配器、布局管理器、数据绑定与嵌套滚动展开适用于新闻分类、商品分组等场景。压缩包共65个文件260KB以30个XML布局文件、10个Java源码、Gradle构建脚本及PNG图片资源为主工程结构完整可直接导入Android Studio运行调试。资源包含外层LinearLayoutManager与内层GridLayoutManager的搭配实现、ViewHolder中内嵌RecyclerView的初始化写法、ItemDecoration间隔设置以及SwipeRefreshLayout与NestedScrolling机制协调上下拉刷新和事件分发的关键代码并针对嵌套列表性能提出复用优化与DiffUtil使用建议。已有1243人学习下载适合通过实际工程快速上手嵌套列表开发并进一步理解RecyclerView的复用机制与滑动协作原理。 最近在做一个类似外卖点餐的页面时碰到了典型的列表套列表场景左侧是一级分类右侧每个分类下面挂着各自的商品列表。一开始我把整个页面用了一个大列表所有数据拍平结果代码变得又笨又难维护。后来和团队重新梳理把右侧改成了RecyclerView嵌套RecyclerView的结构问题一下子清晰了。这篇博文就把这次改造里踩过的坑、可复用的代码和性能优化的思路完整记录下来希望能帮到正在被“列表套列表”折磨的人。先说清楚我讲的这种RecyclerView嵌套RecyclerView是指外层一个纵向列表它的item布局内部再放一个RecyclerView用来展示一组子列表。它最典型的使用场景就是外卖菜单、店铺列表里展示商品分类、旅行App的“日期景点”两级结构、直播App的“栏目房间列表”等。这类业务形态都有一个共同点数据天然就是分组的每组内部的子项还需要横向或纵向滚动展示。嵌套方案可以把“分组的外层逻辑”和“子项的内层逻辑”彻底拆开每个Adapter只干一件事后期无论改层级还是新增样式成本都低很多。那这篇内容适合谁看我默认你至少已经写过一两个普通的RecyclerView列表知道Adapter和ViewHolder是什么但还没系统处理过“列表里嵌套列表”的问题。我会从方案选型、数据结构、完整代码、高度测量、滑动冲突、性能优化这些方面一层一层讲保证你看完能直接照着落地不用再翻几十个网页凑答案。1. 项目场景与嵌套方案的前因后果1.1 什么时候会出现“列表套列表”“列表套列表”听着挺绕实际业务里却是高频需求。举个我在项目里遇到的例子用户进入一个社区活动页面页面上方是一排热门标签下方按时间顺序展示多场活动每场活动下面又关联着这个活动的多个报名人员。如果把这些全部揉在一个列表里最简单粗暴的做法是给Adapter写三四种viewType根据当前position判断渲染哪种布局。但这样一来Adapter里的onBindViewHolder会出现一个巨长的when分支每一类数据的逻辑全堆在一起后续想独立调整某一类样式工作量直接翻倍。嵌套方案的设计思路正好相反外层Adapter只管“组”内层Adapter只管“组里的子项”。每次新增一种分组样式就加一组内层Adapter和布局每次调整子项展示只改内层文件。两边的代码各扫门前雪职责边界非常清晰。这也是我后来坚持用嵌套而不是单列表viewType方案的核心原因。1.2 为什么不选单列表viewType或第三方库单列表viewType方案并不是不能用如果分组数量很少、每组结构完全固定、子项也不要求独立滚动它确实更轻量。但一旦出现“每组下面还要横向滑动子列表”“多个内层列表共用一套滑动逻辑”“数据量大需要内层懒加载”这些诉求时单列表方案会陷入两个麻烦外层缓存机制被破坏。所有分组和子项混合在同一个Adapter里一旦某个位置数据变化很容易触发整页刷新滑动性能和体验都不理想。子项独立的生命周期难以处理。内层子项需要有自己的滚动、点击、曝光统计时在一个扁平的Adapter里全得靠position换算逻辑绕到让人头大。市面上也有MultiType这类第三方库可以按类型注册不同的ItemViewBinder省去自己写viewType判断。我在团队里用过正常分组场景确实挺方便但遇到“内层子项数量不确定、高度不固定、还要动态刷新”的情况第三方库反而多了一层抽象不如直接手写嵌套来得直接。所以我最终的结论是内层内容展示越独立、越复杂嵌套方案性价比越高如果只是简单的一层分组就不需要嵌套保持简单即可。2. 数据建模与Adapter拆分设计2.1 顶层数据结构怎么设计写代码之前先把数据结构理清楚。我推荐用组合关系而不是扁平化结构来表达分组数据这样无论后端返回还是本地Mock映射关系都很自然。下面是我在代码里实际使用的两个实体类// 外层分组实体 data class GroupEntity( val groupId: String, val groupName: String, val children: MutableListChildEntity ) // 内层子项实体 data class ChildEntity( val childId: String, val name: String, val desc: String, val extraInfo: String? null )GroupEntity内部持有一个MutableListChildEntity这是整个嵌套方案的数据基石。外层GroupAdapter在绑定ViewHolder时直接把这个children列表丢给内层ChildAdapter数据传递天然顺畅。这里有个细节children为什么用MutableList而不是不可变List因为后续做局部刷新时子项经常需要动态增删。如果改成不可变列表每次更新都得新建整个GroupEntity手段别扭不说还容易漏掉引用关系。所以我的习惯是凡是会被局部更新的子列表统一用MutableList只有那些从不变化的分组字段才用不可变类型。2.2 Adapter职责如何拆分职责拆分是嵌套方案的核心。我一般遵循这样一个原则外层Adapter只回答“我有哪些分组、每个分组长什么样、分组的头尾如何绘制”内层Adapter只回答“这个分组里有哪些子项、子项长什么样、子项点击做什么”。两边不互相越权。具体到代码分层上我会拆成三个类GroupAdapter管理ListGroupEntity创建外层GroupVH在onBindViewHolder里把子列表交接给子层Adapter。ChildAdapter管理某一个分组下的ListChildEntity负责所有子项的创建和绑定。布局文件item_group.xml承载分组标题内层RecyclerViewitem_child.xml承载子项内容。这样做还有一个额外好处将来如果某个分组下面不是列表而是一张横向滚动的图片我只需要在这个分组对应的GroupVH里换成别的控件不需要动其他代码。每个分组的数据和展示都可以按需组合项目后期扩展灵活度非常高。2.3 内层Adapter与外层ViewHolder的桥接内层Adapter和外层ViewHolder的配合是嵌套实现中程序员容易写乱的地方。最容易犯的错是在GroupAdapter.onBindViewHolder()里每次都new ChildAdapter()并且每次都调用rvChildren.setAdapter()。这样的写法在快速滑动时会造成大量Adapter重建RecyclerView忙于回收和创建就会出现肉眼可见的卡顿。我的做法是在GroupVH中只做一次Adapter的初始化和绑定后续数据变化只走ChildAdapter.update()inner class GroupVH(itemView: View) : RecyclerView.ViewHolder(itemView) { private val tvGroupTitle: TextView itemView.findViewById(R.id.tvGroupTitle) private val rvChildren: RecyclerView itemView.findViewById(R.id.rvChildren) private var childAdapter: ChildAdapter? null fun bind(group: GroupEntity) { tvGroupTitle.text group.groupName if (childAdapter null) { childAdapter ChildAdapter(group.children) rvChildren.adapter childAdapter } else { childAdapter?.update(group.children) } } }第一次bind时创建Adapter并设置之后复用ViewHolder时只更新数据。RecyclerView.ViewHolder本来就支持复用这样写可以最大限度避免无意义的对象创建。注意这里我没有在bind里重复设置LayoutManager因为LayoutManager可以在onCreateViewHolder里固定好避免每次绑定时都走一遍布局初始化逻辑。3. 布局搭建与核心代码实现3.1 外层布局与item布局XML先看外层Activity/Fragment的主布局就是一个普通的RecyclerView这里不再赘述。关键是两个item布局。item_group.xml外层分组item?xml version1.0 encodingutf-8? LinearLayout xmlns:androidhttp://schemas.android.com/apk/res/android android:layout_widthmatch_parent android:layout_heightwrap_content android:orientationvertical TextView android:idid/tvGroupTitle android:layout_widthmatch_parent android:layout_heightwrap_content android:padding12dp android:textSize16sp android:textStylebold / androidx.recyclerview.widget.RecyclerView android:idid/rvChildren android:layout_widthmatch_parent android:layout_heightwrap_content android:overScrollModenever / /LinearLayoutitem_child.xml内层子项item?xml version1.0 encodingutf-8? LinearLayout xmlns:androidhttp://schemas.android.com/apk/res/android android:layout_widthmatch_parent android:layout_heightwrap_content android:orientationhorizontal android:padding8dp TextView android:idid/tvChildName android:layout_width0dp android:layout_heightwrap_content android:layout_weight1 android:textSize14sp android:textColor#333333 / TextView android:idid/tvChildDesc android:layout_widthwrap_content android:layout_heightwrap_content android:textSize12sp android:textColor#999999 / /LinearLayout有两点需要注意内层RecyclerView高度必须设成wrap_content这个后面会详细讲为什么外层item根布局不要轻易设成固定高度否则数据多时会显示不全。3.2 内层RecyclerView的LayoutManager选择内层RecyclerView的LayoutManager选择直接决定子项展示方向子项纵向排列时使用LinearLayoutManagerdirection设为VERTICAL。子项需要横向滑动时使用LinearLayoutManagerdirection设为HORIZONTAL。子项是九宫格或瀑布流时用GridLayoutManager或StaggeredGridLayoutManager。这里有个容易被忽略的坑内层是横向列表时外层的高度匹配逻辑和纵向时完全不同。横向列表可以设置固定高度比如100dp避免每个子项高度不一致导致测量麻烦纵向列表如果子项行数较多推荐用动态高度测量下一小节会专门说。3.3 核心代码完整实现下面给出完整可运行的代码先看外层分组Adapterclass GroupAdapter( private val groups: MutableListGroupEntity ) : RecyclerView.AdapterGroupAdapter.GroupVH() { fun update(newGroups: ListGroupEntity) { groups.clear() groups.addAll(newGroups) notifyDataSetChanged() } override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): GroupVH { val view LayoutInflater.from(parent.context) .inflate(R.layout.item_group, parent, false) return GroupVH(view) } override fun onBindViewHolder(holder: GroupVH, position: Int) { val group groups[position] holder.bind(group) } override fun getItemCount(): Int groups.size inner class GroupVH(itemView: View) : RecyclerView.ViewHolder(itemView) { private val tvGroupTitle: TextView itemView.findViewById(R.id.tvGroupTitle) private val rvChildren: RecyclerView itemView.findViewById(R.id.rvChildren) private var childAdapter: ChildAdapter? null init { rvChildren.layoutManager LinearLayoutManager(itemView.context) rvChildren.itemAnimator null } fun bind(group: GroupEntity) { tvGroupTitle.text group.groupName if (childAdapter null) { childAdapter ChildAdapter(group.children) rvChildren.adapter childAdapter } else { childAdapter?.update(group.children) } } } }然后看内层子项Adapterclass ChildAdapter( private val items: MutableListChildEntity ) : RecyclerView.AdapterChildAdapter.ChildVH() { fun update(newItems: ListChildEntity) { items.clear() items.addAll(newItems) notifyDataSetChanged() } override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ChildVH { val view LayoutInflater.from(parent.context) .inflate(R.layout.item_child, parent, false) return ChildVH(view) } override fun onBindViewHolder(holder: ChildVH, position: Int) { val item items[position] holder.tvChildName.text item.name holder.tvChildDesc.text item.desc // 这里可以继续绑定图片、点击事件等 } override fun getItemCount(): Int items.size inner class ChildVH(itemView: View) : RecyclerView.ViewHolder(itemView) { val tvChildName: TextView itemView.findViewById(R.id.tvChildName) val tvChildDesc: TextView itemView.findViewById(R.id.tvChildDesc) } }最后在页面里设置外层列表val rvGroup findViewByIdRecyclerView(R.id.rvGroup) rvGroup.layoutManager LinearLayoutManager(this) rvGroup.adapter GroupAdapter(buildMockData())整套代码跑起来页面会按分组展示所有子列表。如果没有动态测量高度你大概率会遇到下面讲的那个经典问题。3.4 高度显示异常的修复只显示一个item怎么办这是嵌套RecyclerView被问得最多的问题内层列表数据有10条结果页面上只显示了一条或者只显示到屏幕高度就截断了。原因其实不难理解RecyclerView在wrap_content高度下默认测量逻辑是按可见区域来算的它没有像LinearLayout那样自动展开全部子项。而外层又不可能把所有不等高的子项都测量完整于是内层只展示了一部分。最直接的解决方案是重写LinearLayoutManager的onMeasure方法强制遍历所有item并累加高度class WrapContentLinearLayoutManager( context: Context, orientation: Int RecyclerView.VERTICAL, reverseLayout: Boolean false ) : LinearLayoutManager(context, orientation, reverseLayout) { 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 widthMode View.MeasureSpec.getMode(widthSpec) if (widthMode ! View.MeasureSpec.EXACTLY) { super.onMeasure(recycler, state, widthSpec, heightSpec) return } val heightMode View.MeasureSpec.getMode(heightSpec) if (heightMode View.MeasureSpec.AT_MOST || heightMode View.MeasureSpec.UNSPECIFIED) { var totalHeight 0 for (i in 0 until state.itemCount) { val itemView recycler.getViewForPosition(i) measureChildWithMargins(itemView, widthSpec, heightSpec) totalHeight getDecoratedMeasuredHeight(itemView) } setMeasuredDimension( View.MeasureSpec.getSize(widthSpec), totalHeight paddingTop paddingBottom ) } else { super.onMeasure(recycler, state, widthSpec, heightSpec) } } }使用方式是在GroupVH初始化时换成这个自定义LayoutManagerrvChildren.layoutManager WrapContentLinearLayoutManager(itemView.context)这样每个分组的内层RecyclerView都会按照子项实际内容展开外层可以正常滚动。要提醒的是这个方案适合子项数量不太大的场景如果某个分组能有几百条子项这种遍历测量的方式就比较吃力了建议改成单列表的viewType或改用ConcatAdapter做懒加载。4. 滑动卡顿与性能优化的底层逻辑4.1 滑动冲突的处置思路嵌套之后最让人头疼的就是滑动冲突。最常见的情况是内层列表可以上下滑动但用户滑动内层时外层页面也跟着滑两个列表抢触摸事件界面体验就很怪异。处置思路第一步是搞清楚内层到底需不需要独立滑动。如果像我这种全部使用WrapContentLinearLayoutManager把内层完全展开的方案那么内层高度等于内容总高度内层自身根本滚不动事件天然只会落到外层上也就谈不上冲突。如果你保留内层自身的滚动比如限制内层最大高度后内部滚动则需要判断内层的滚动边界rvChildren.setOnTouchListener { _, event - when (event.action) { MotionEvent.ACTION_DOWN - { // 判断当前是否滚动到顶部或底部 val layoutManager rvChildren.layoutManager as LinearLayoutManager val firstVisible layoutManager.findFirstVisibleItemPosition() val lastVisible layoutManager.findLastVisibleItemPosition() val canScrollUp firstVisible ! 0 val canScrollDown lastVisible ! layoutManager.itemCount - 1 // 如果内层已经到顶或到底让外层接管滚动 rvChildren.parent?.requestDisallowInterceptTouchEvent(canScrollUp || canScrollDown) } } false }这个思路的要点是内层还能滚就自己消化事件内层滚到头了就让外层接棒。很多文章会推荐直接setNestedScrollingEnabled(false)这确实能解决一部分冲突但它会禁用掉嵌套滚动机制如果后续还要做滑动的联动效果就会受限。我的经验是先明确业务诉求能用布局解决的问题不要用事件劫持来解决。4.2 让内层滑动与外层联动更顺滑当内层需要保留滚动时我推荐用RecyclerView原生的NestedScrolling机制来配合而不是手写一堆onInterceptTouchEvent。RecyclerView本身实现了NestedScrollingChild接口只要你用的外层容器也是RecyclerView或NestedScrollView系统会自动做一部分嵌套协调。在实践里我通常会做三件事来提升顺滑度内层RecyclerView设置setNestedScrollingEnabled(false)主要应用于内层完全展开的场景关闭后外层滑动不再被内层干扰。外层RecyclerView设置setHasFixedSize(true)前提是内外层高度确定或已经动态测量完成这可以避免每次notifyDataSetChanged都重新measure。给内层的RecyclerView统一设置RecyclerView.RecycledViewPool这样不同分组里的子列表可以共享ViewHolder缓存减少创建View的开销。关于共享ViewPool我贴一下关键写法val sharedPool RecyclerView.RecycledViewPool() rvGroup.setRecycledViewPool(sharedPool) // 在GroupVH初始化内层列表时 rvChildren.setRecycledViewPool(sharedPool)这样做之后即使页面有几十个分组每个分组都持有自己的内层列表它们复用的是同一个缓存池滑动体验会顺畅不少。4.3 性能优化的几个实战手段嵌套RecyclerView的性能问题不只是滑动冲突更多时候是卡顿和内存占用。我总结下来的优化要点大致如下内层Adapter选择notifyItemRangeInserted或DiffUtil做局部刷新避免整页notifyDataSetChanged。嵌套场景里一个分组数据发生变化如果直接全量刷新外层所有分组都会被重绑浪费很大。关闭不需要的item动画。在分组数量多、子项频繁变化的场景我经常直接rvChildren.itemAnimator null减少布局动画带来的额外计算。避免在onBindViewHolder里做耗时操作。比如图片加载、圆角裁剪、字符串格式化最好都提前处理好或者放到合适的异步线程里。使用setPreloadItems或setInitialPrefetchItemCount时注意数据量内层列表开始时不要一次预取太多。控制嵌套层级。内层列表里不要再套列表三层以上基本属于代码异味性能断崖式下跌。遇到这种情况宁可换Fragment容器或者重新梳理UI结构。我在实测中发现外层500个分组、每个分组平均10个子项共5000个子项时如果不做任何优化滑动能明显感觉到丢帧但做了共享ViewPool和局部刷新后帧率基本能稳定在舒适区间内。这个数据供你参考。5. 常见问题排查与避坑实录5.1 高频问题速查表我在写嵌套列表这半个月里遇到并修复过的问题整理成了表格方便你以后直接对照排查。问题现象根本原因解决方案内层列表只显示一条或显示不全内层RecyclerView高度测量不完整使用自定义WrapContentLinearLayoutManager动态测量或设置内层item固定高度外层列表滑动卡顿明显内层Adapter频繁重建、没有复用缓存复用ViewHolder和Adapter内层共享RecycledViewPool内层上下滑动和外层页面抢手势两层的触摸事件处理冲突判断内层滚动边界requestDisallowInterceptTouchEvent控制事件归属数据刷新后列表位置跳动使用全量notifyDataSetChanged改用DiffUtil或notifyItemRangeChanged做局部刷新某些分组数据为空时页面留白空分组没有占位处理在外层Adapter中对空分组显示空态布局或直接从数据源过滤空组滑动时出现子项内容闪动图片加载或绑定状态没做复用处理在onBindViewHolder里清空残留状态图片库使用占位图和缩放策略5.2 崩溃与数据刷新问题嵌套列表最容易崩溃的地方是数据刷新时序。我在开发时踩到过一次IndexOutOfBoundsException崩溃原因是在子线程修改了MutableList然后主线程直接notifyDataSetChanged。RecyclerView的数据源一致性要求极高任何情况下都不要在非UI线程直接改Adapter持有的List否则轻则数据错乱重则直接崩溃。另一个高频崩溃是IllegalArgumentException: Scrapped or attached views may not be recycled这个多数出现在内层列表被快速滑动时Adapter数据更新和RecyclerView回收动作并发。我的处理习惯是涉及内层数据变化时尽量通过主线程Handler统一调度并保证先更新数据源再通知Adapter不要让刷新动作打断正在进行的滑动回收流程。数据刷新这件事如果能用DiffUtil就用DiffUtil。内层子项数量通常较大Diff机制能计算出最小变更集不仅滑动体验更顺还能避免很多和刷新相关的隐性问题。示例可以看官方文档的ListAdapter配合AsyncListDiffer用起来非常顺手。5.3 实测造一份900个子项的数据能流畅跑吗为了验证这套方案的工程可行性我Mock了一份数据外层30个分组每个分组30个子项总共900个子项。测试机型就是普通中端安卓机布局全部采用动态测量展开滑动时统计掉帧情况。实测结果单纯滑动外层列表流畅度没有问题做了局部刷新后展开单个分组和更新子项也都比较跟手。但如果我把每个分组的内层Adapter都独立创建、并且每次notifyDataSetChanged在快速上下划动时就能明显感到顿挫。所以性能瓶颈不在嵌套这种结构本身而在你是否合理复用了资源。这也是我为什么反复强调GroupVH里只初始化一次Adapter的原因。项目中的一点收尾心得做这个嵌套列表改造最深的体会是RecyclerView嵌套RecyclerView不是什么黑科技但它对代码组织能力的要求很高。数据结构的合理性决定了后续所有改动的复杂度Adapter职责拆分是否清晰决定了每来一个需求时你的心情。如果你也准备在项目里用这种方案建议第一版就把动态测量、共享ViewPool、局部刷新这三件事做进去不要等出问题了再补。最后分享一个小技巧调试嵌套列表时可以在开发环境里把RecyclerView的android:clipChildren和android:clipToPadding设为false配合getViewTreeObserver()打印每个item的真实高度排查显示问题会直观很多。嵌套列表的坑基本都集中在高度和事件这两块把这两个问题拿捏住了剩下的就都是水到渠成的事。本文还有配套的精品资源点击获取
返回列表