先说明一句,做安卓开发的,只要你的列表超过三行,你就绕不开RecyclerView。这个控件从Android 5.0时代替代ListView开始,就成了几乎所有App的列表基石。但现实是,很多人在项目里只是把它当“能滑动的ListView”用:Adapter里getItemViewType没写过、DiffUtil没碰过、NestedScrolling一窍不通、列表稍微复杂点就卡成PPT。这篇文章我就结合自己这几年在真实项目里趟过的坑,把RecyclerView高级用法拆开揉碎讲清楚,并且附上一个能直接跑起来的实战Demo,从原理到实践一次讲透。适合已经会用RecyclerView写基础列表、但想进一步提升性能和复杂场景应对能力的Android开发者。
1. 整体设计:从“能显示”到“用得顺”
开始敲代码之前,我建议大家先建立一个认知:RecyclerView不是万能的,但用不好它的代价是巨大的。这个控件设计得很巧妙,它把数据管理、布局测量、视图回收、动画刷新这几件事彻底解耦了。你要做的不是“往里面塞View”,而是按照它的规则,把四件套——Adapter、LayoutManager、ItemDecoration、ItemAnimator——各司其职地组织起来。
1.1 为什么要用RecyclerView而不是继续用ListView
很多人问过我这个问题。我的回答通常很简单:如果你还在用ListView写新项目,那基本等于还在用非智能手机。ListView的问题在于它把所有职责都揉在一起——你很难精细控制item的复用策略,滚动手势和嵌套滚动的兼容性也很差,更重要的是它默认不支持布局的横向/网格/瀑布流切换,换个布局方式要重写一堆逻辑。
RecyclerView的架构非常清晰:LayoutManager决定item怎么排列,ItemDecoration负责分割线和装饰,ItemAnimator处理增删改的动画,Adapter只管数据和View的绑定。这种分工意味着你可以在不破坏其他模块的前提下,单独替换或优化某一部分。比如线上发现item间距不对,你不需要去动Adapter代码,只需要修改ItemDecoration的偏移量就行,这是非常典型的“低耦合、高内聚”设计思路。
1.2 高级用法的核心难点
高级和基础的区别,不在于你会不会写一个Adapter,而在于你如何处理下面这四类问题:
- 多类型Item的复杂布局:比如一个首页信息流,里面有轮播图、图文卡片、视频卡片、广告位、小工具入口,它们的布局完全不同,高度也各不相同。你需要通过
getItemViewType做类型分发,并且确保不同类型之间的View不会复用串数据。 - 数据变化的精准刷新:不要动不动就
notifyDataSetChanged(),那个方法会强制整个列表重绘,在数据量大的时候体验极差。你需要掌握DiffUtil、ListAdapter,甚至AsyncListDiffer来实现精准的局部刷新和动画。 - 列表与滚动容器之间的嵌套冲突:ScrollView套RecyclerView是新手最爱踩的坑之一,你明明只滑外层,内层列表却把触摸事件抢走了。这里要理解
NestedScrolling机制,或者干脆换用CoordinatorLayout+AppBarLayout来做联动。 - 性能与卡顿优化:item里有复杂布局、大图、频繁的数据更新,容易造成掉帧。你需要学会用
setHasFixedSize()、setItemViewCacheSize(),以及避免在onBindViewHolder里做耗时操作。
这套思路里,我觉得最值得花心思啃的是第一类和第二类,这俩是RecyclerView“高级”的分水岭。后面实战部分我会详细演示这两块怎么落地。
2. 核心细节解析与实操要点
我记得第一次在正式项目里用RecyclerView做多类型列表时,代码写得又长又臭——一个Adapter里堆了四五个viewType,到处是if-else和switch-case。维护了俩月,新来的同事接手时看代码直摇头。后来我重构了整套方案,核心思路就一句话:用Delegate模式拆分每个Item的职责。
2.1 用Delegate模式管理多类型Item
所谓Delegate模式,就是把每一种Item的“创建View”和“绑定数据”逻辑,单独抽成一个Delegate类。Adapter本身只负责分发:根据getItemViewType找到对应的Delegate,然后让Delegate去干活。这样做的好处是,你新增一种Item类型时,不需要改动现有代码,只要新增一个Delegate类并在Adapter里注册一下就行——这很符合开闭原则。
我通常这样设计接口:
interface ViewHolderDelegate<T> { fun getViewType(): Int fun createViewHolder(parent: ViewGroup): RecyclerView.ViewHolder fun bindViewHolder(holder: RecyclerView.ViewHolder, item: T, position: Int) }然后Adapter在onCreateViewHolder里根据viewType从注册表里找Delegate,在onBindViewHolder里也交给Delegate处理。你不要小看这层抽象,实践里,一个复杂首页往往有8到12种item类型,用Delegate维护比在Adapter里堆逻辑省心太多。
另外,在创建Delegate实例时,我喜欢用Map<Int, ViewHolderDelegate<*>>来组织,key就是viewType。这样查找时间复杂度是O(1),性能也好。等会儿实战里我会给出一个完整的Delegate模式Demo。
2.2 DiffUtil数据刷新:从“全量重绘”到“精准打击”
DiffUtil是官方在support library 25.0.0之后推出的工具类,专门用来计算新旧数据集的差异,然后生成精准的更新操作。我把它理解成“git diff的列表版”——你给它的新旧数据,它告诉你哪些item插入了、哪些删除了、哪些移动了、哪些内容变了。拿到这个结果,RecyclerView只需要做最小幅度的“手术”就行,不用整条列表推倒重来。
使用DiffUtil时,你需要实现一个DiffUtil.Callback,重写四个核心方法:
getOldListSize()和getNewListSize():返回新旧数据集的长度。areItemsTheSame(oldItem, newItem):判断两个item是否是同一个对象。通常对比id。areContentsTheSame(oldItem, newItem):在areItemsTheSame返回true的前提下,判断两个item的内容是否一致。比如有一个字段变了,这里返回false,DiffUtil就知道这个item需要刷新。
这里有一个最容易被忽略的细节:areItemsTheSame和areContentsTheSame不是一回事。前者回答“这是不是同一行”,后者回答“这一行的内容有没有变”。如果你的列表是可折叠的、带展开状态的,那么“展开状态”也要纳入areContentsTheSame的判断范围,否则用户点了展开,DiffUtil却认为内容没变,界面纹丝不动。
另外,数据量大的时候(比如5000条以上),DiffUtil的计算比较耗时,建议用AsyncListDiffer放到后台线程去算,算完再通知UI刷新。
2.3 缓存机制:RecyclerView流畅的“发动机”
RecyclerView的缓存机制是整个控件性能好的根基。它有四级缓存,按查找顺序依次是:mAttachedScrap、mCachedViews、mViewCacheExtension、mRecycledViewPool。
很多初学者只知道RecyclerView会“回收复用”,但说不清到底怎么复用的。简单说,mCachedViews是一个默认容量为2的缓存(如果你没有主动设置,默认就是2),它缓存了还没被回收、但已经滑出屏幕的ViewHolder。当用户往回滑的时候,优先从这里面直接拿出来用,因为ViewHolder还跟原来的数据绑定着,几乎可以零成本复用。
mRecycledViewPool则是更底层的复用池。它会按照viewType区分,每种viewType的ViewHolder缓存在自己的ArrayList里。区别在于,mRecycledViewPool里的ViewHolder已经和旧的position、数据解绑了,拿出来需要重新bindViewHolder绑定数据。默认每个viewType可以缓存5个(如果我没记错的话,DEFAULT_MAX_SCRAP是5),你可以在创建RecyclerView时自己调整。
我在项目里常见的优化手段是:对于item高度固定、结构简单的列表,调用setHasFixedSize(true),告诉RecyclerView“item尺寸不会变”,这样它就不用每次更新数据都重新计算item尺寸了。对于图片信息流这种item高度不固定的,就不能开这个,否则内容会错乱。
还有一点,如果你在onBindViewHolder里做了findViewById之外的耗时操作,就算缓存再快也会卡。这里给大家一个实操建议:onBindViewHolder里只做数据绑定,不要做任何inflate、不要做Bitmap解码、不要做网络请求、不要做文件IO。这些都是合成onBind大忌。
2.4 ItemDecoration:自定义分割线的高级玩法
分割线是RecyclerView最容易“看着简单、做起来翻车”的部分。ItemDecoration有3个核心方法:onDraw、onDrawOver、getItemOffsets。
getItemOffsets:给item的四个方向插入偏移量。在插入偏移量之后,item的可用空间会被压缩,这个偏移量不会阻挡触摸事件,也不会做任何绘制。onDraw:在item绘制之前被调用。它绘制的内容会被item覆盖。onDrawOver:在item绘制之后被调用。它绘制的内容会覆盖在item上面。
有了这三个方法,你不仅可以画分割线,还能实现很多花哨效果,比如给特定类型的item加缩进、做吸顶效果、画圆角背景等。
我自己的习惯是:如果只是需要一条颜色简单、高度1px的分割线,我直接用Android官方提供的DividerItemDecoration就够了;但如果你需要的是那种“带圆点、带渐变、只画在某几行之间”的复杂分割线,那就必须自己写一个RecyclerView.ItemDecoration。写的时候特别要注意getItemOffsets和onDraw中的坐标系——item是带着padding的,绘制时要考虑parent的padding,不然分割线会错位。
2.5 监听器的正确注入姿势
新手写RecyclerView的点击事件,通常是在onBindViewHolder里给itemView设置OnClickListener。这在数据量小的时候没毛病,但数据量大、item滑动频繁时,频繁创建监听器总归有点浪费。其实可以用一个公共的OnClickListener,通过getBindingAdapterPosition()拿到position,再通过item的id去匹配要处理的对象。
另外一个很多老开发都会忽略的问题是:onBindViewHolder里创建的匿名内部类会持有外部Context引用,稍不注意就会内存泄漏。所以我的底线是——不要在onBindViewHolder里写复杂的匿名内部类,尤其是涉及网络请求回调的。尽量用view的setTag或者统一监听器来解决。
3. 实操过程与核心环节实现
说了这么多理论,下面我们动手写一个完整的实例Demo。这个Demo模拟了一个购物App的商品推荐流,包含横滑banner、普通商品卡片、带倒计时的秒杀模块、以及一个“最近浏览”的拖拽排序模块。听起来复杂,但用Delegate模式拆解后,每个模块都清晰得很。
3.1 项目准备与依赖
我的开发环境是Android Studio Hedgehog(2023.1.1版本之后的都行),Kotlin版本1.9.0,编译SDK 34。你在自己的环境里,需要确保build.gradle.kts里有这些依赖:
dependencies { implementation("androidx.recyclerview:recyclerview:1.3.2") implementation("androidx.appcompat:appcompat:1.6.1") implementation("androidx.constraintlayout:constraintlayout:2.1.4") implementation("com.github.bumptech.glide:glide:4.16.0") }Glide主要是用来加载图片的,你也可以换Coil、Picasso,看团队习惯。布局这一块我用的是LinearLayoutManager,如果你要做瀑布流,那就换成StaggeredGridLayoutManager,但注意瀑布流里item高度不能固定,否则瀑布效果出不来。
3.2 数据模型设计
数据模型是列表的“地基”,我习惯用一个HomeItem接口来抽象所有类型的数据:
interface HomeItem { val itemId: Long val viewType: Int }然后每种数据类型都实现这个接口。这样做的好处是,Adapter的泛型可以统一为HomeItem,Delegate通过isInstanceOf做类型转换就行。
这里拿秒杀商品举例:
data class SeckillItem( override val itemId: Long, val productName: String, val price: Double, val originalPrice: Double, val endTimeStamp: Long ) : HomeItem { override val viewType: Int = SeckillDelegate.VIEW_TYPE }注意,我让每个数据类自己暴露viewType,而不是在Adapter里用when分支判断,这样哪怕后面新加十种类型,Adapter一行都不用改。
3.3 Delegate框架的编写
这是整个Demo的核心,我建议你把这段代码吃透。
先定义Delegate接口:
interface Delegate<in T : HomeItem, out VH : RecyclerView.ViewHolder> { val viewType: Int fun createViewHolder(parent: ViewGroup): VH fun bindViewHolder(holder: VH, item: T, position: Int) }然后,Adapter内部用SparseArray<Delegate<HomeItem, *>>来保存所有注册的Delegate:
class HomeAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>() { private val delegates = SparseArray<Delegate<HomeItem, *>>() private val items = mutableListOf<HomeItem>() fun registerDelegate(delegate: Delegate<HomeItem, *>) { delegates.put(delegate.viewType, delegate) } fun submitList(newList: List<HomeItem>) { items.clear() items.addAll(newList) notifyDataSetChanged() } override fun getItemViewType(position: Int): Int { return items[position].viewType } override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { return delegates.get(viewType).createViewHolder(parent) } @Suppress("UNCHECKED_CAST") override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { val delegate = delegates.get(getItemViewType(position)) (delegate as Delegate<HomeItem, *>).bindViewHolder(holder, items[position], position) } override fun getItemCount(): Int = items.size }这个Adapter看着很短,但它已经具备了扩展性。以后新增一种Item,你只需要三件事:写一个数据类实现HomeItem、写一个Delegate实现Delegate接口、在初始化时调用registerDelegate。其他的逻辑它一概不关心。这里有个小技巧:SparseArray是Android特有的稀疏数组,在key是整数的时候,查找效率比HashMap高。它的核心是二分查找,所以数据量大的时候性能优势很明显。
3.4 具体Delegate实现
下面我们来看一个具体的Delegate,比如普通商品卡片。它要做的事就是inflate布局、绑定数据。
class ProductDelegate : Delegate<ProductItem, ProductDelegate.ProductVH> { override val viewType: Int = ProductItem.VIEW_TYPE override fun createViewHolder(parent: ViewGroup): ProductVH { val view = LayoutInflater.from(parent.context) .inflate(R.layout.item_product, parent, false) return ProductVH(view) } override fun bindViewHolder(holder: ProductVH, item: ProductItem, position: Int) { holder.tvName.text = item.productName holder.tvPrice.text = "¥${item.price}" Glide.with(holder.ivCover.context) .load(item.coverUrl) .placeholder(R.drawable.placeholder) .into(holder.ivCover) } class ProductVH(view: View) : RecyclerView.ViewHolder(view) { val ivCover: ImageView = view.findViewById(R.id.ivCover) val tvName: TextView = view.findViewById(R.id.tvName) val tvPrice: TextView = view.findViewById(R.id.tvPrice) } }你可能觉得这代码太“简单”了,没什么“高级”的感觉。但我恰恰认为,好的架构就是让复杂的业务逻辑长得很简单。每类item的逻辑都被隔离在自己的Delegate里,互不干扰。出bug的时候,你是直接定位到对应Delegate,而不是在几百行的Adapter里翻。
3.5 秒杀倒计时Item的实现细节
秒杀模块是整个Demo里最“高级”的点了。它要在item里显示倒计时,而且每秒刷新一次,但不能直接notifyDataSetChanged,不然会触发整个列表重绘。
我的做法是:在秒杀Item的Delegate里持有一个CountDownTimer,它只更新TextView的时间文本,不去触碰Adapter的数据层。
class SeckillDelegate : Delegate<SeckillItem, SeckillDelegate.SeckillVH> { private val timerMap = HashMap<Int, CountDownTimer>() override fun bindViewHolder(holder: SeckillVH, item: SeckillItem, position: Int) { holder.tvName.text = item.productName holder.tvPrice.text = "¥${item.price}" holder.tvOriginalPrice.text = "¥${item.originalPrice}" // 先取消旧的计时器,避免多个position复用同一个计时器导致UI错乱 timerMap[holder.adapterPosition]?.cancel() timerMap.remove(holder.adapterPosition) val timer = object : CountDownTimer(item.endTimeStamp - System.currentTimeMillis(), 1000) { override fun onTick(millisUntilFinished: Long) { holder.tvCountdown.text = formatTime(millisUntilFinished) } override fun onFinish() { holder.tvCountdown.text = "已结束" } } timer.start() timerMap[holder.adapterPosition] = timer } class SeckillVH(view: View) : RecyclerView.ViewHolder(view) { val tvName: TextView = view.findViewById(R.id.tvName) val tvPrice: TextView = view.findViewById(R.id.tvPrice) val tvOriginalPrice: TextView = view.findViewById(R.id.tvOriginalPrice) val tvCountdown: TextView = view.findViewById(R.id.tvCountdown) } }这里为了简单我用HashMap存计时器。但你有两个必须注意的坑:
第一,当item滑出屏幕被回收时,ViewHolder的onViewDetachedFromWindow会触发,你最好在这个方法里取消对应position的计时器,否则计时器会继续跑,更新一个不可见的View,白白浪费CPU,还可能因为ViewHolder被复用到别的item上而导致数据错乱。第二,CountDownTimer内部是用Handler+ 消息队列实现的,如果你在非主线程调用,必须切回主线程。
顺便补充一句,item.endTimeStamp - System.currentTimeMillis()这一步通常建议放在后台计算好再传给item,不要在UI线程里做复杂计算。但像这种简单减法问题不大,真正的高级列表应该在数据层就把所有需要展示的格式化字段都算好、排好,UI层只做“读取显示”,不做“计算处理”。
3.6 横滑Banner + 嵌套滚动
现在很多App首页,最上面就是一个横向滑动的Banner。有人用ViewPager2做,有人用RecyclerView横向LinearLayoutManager做。用RecyclerView做,好处是可以复用LayoutManager和ItemDecoration的生态,和列表融为一体的感觉更自然。
我有一次在项目里就直接用RecyclerView实现了Banner:
val bannerAdapter = BannerAdapter(bannerItems) val bannerRecyclerView = findViewById<RecyclerView>(R.id.bannerRecyclerView) val layoutManager = LinearLayoutManager(this, LinearLayoutManager.HORIZONTAL, false) bannerRecyclerView.layoutManager = layoutManager bannerRecyclerView.adapter = bannerAdapter它和竖向列表的本质区别,就是LinearLayoutManager的方向参数设为HORIZONTAL。剩下的数据绑定、缓存复用逻辑完全一致。
那这里的“高级”在哪呢?高级在嵌套滚动的协作。
如果你的Banner外层只有一个竖向RecyclerView,那它们之间默认就可以正常工作——因为RecyclerView内部实现了NestedScrollingChild接口,子布局可以主动让渡触摸事件给父布局。但如果你把这个Banner放到ScrollView或者NestedScrollView里,就很容易出现“手势冲突”:明明想上下滑整个页面,Banner却拦截了手势往左右滑。
解决方案通常有两个方向:
- 方法一:在Banner的
RecyclerView的onInterceptTouchEvent里判断手势方向,竖直滑动手势交给父容器,水平滑动手势自己处理。核心代码如下:
override fun onInterceptTouchEvent(ev: MotionEvent): Boolean { if (ev.action == MotionEvent.ACTION_DOWN) { lastX = ev.x lastY = ev.y } else if (ev.action == MotionEvent.ACTION_MOVE) { val dx = ev.x - lastX val dy = ev.y - lastY if (abs(dy) > abs(dx)) { return false // 竖直滑动,交给父容器 } } return super.onInterceptTouchEvent(ev) }- 方法二:用
CoordinatorLayout+AppBarLayout+CollapsingToolbarLayout,把标题栏和Banner做成联动效果。这也是我在项目里最常用的方案。CoordinatorLayout会自动协调子View的嵌套滚动事件,你几乎不用写任何手势冲突代码,就能实现“上滑时标题栏折叠、下滑时标题栏展开”的效果。
很多新手一听CoordinatorLayout就觉得复杂,其实它本质就是一个增强版的FrameLayout,它的下属View可以通过定义Behavior来响应嵌套滚动事件。RecyclerView作为NestedScrollingChild时,AppBarLayout的Behavior会自动拦截部分滚动事件,从而让标题栏做出折叠动作。这套机制你不需要完全搞懂底层,按官方模板写就能跑。
3.7 拖拽排序与滑动删除
拖拽排序是另一种常见的高级需求。比如电商App的“最近浏览”板块,用户长按某个item可以拖到别的顺序,或者左滑删除。
RecyclerView的ItemTouchHelper就是为此而生的。用法很简单,核心是继承ItemTouchHelper.Callback:
class SimpleItemTouchHelperCallback( private val adapter: HomeAdapter ) : ItemTouchHelper.Callback() { override fun isLongPressDragEnabled(): Boolean = true override fun isItemViewSwipeEnabled(): Boolean = true override fun getMovementFlags(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder): Int { val dragFlags = ItemTouchHelper.UP or ItemTouchHelper.DOWN val swipeFlags = ItemTouchHelper.START or ItemTouchHelper.END return makeMovementFlags(dragFlags, swipeFlags) } override fun onMove(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder, target: RecyclerView.ViewHolder): Boolean { val fromPosition = viewHolder.bindingAdapterPosition val toPosition = target.bindingAdapterPosition adapter.onItemMove(fromPosition, toPosition) return true } override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) { adapter.onItemDismiss(viewHolder.bindingAdapterPosition) } }这里,我踩过一个非常隐蔽的坑:onMove拿到的position和adapterPosition在某些情况下不是同一个值。因为我用HeaderView做了吸顶,导致position偏移了1。整改方案是统一用bindingAdapterPosition或者absoluteAdapterPosition,千万不能混淆。在做拖拽排序的列表时,建议你的Adapter始终维护一份mutableList作为数据源,拖拽完成后立即更新这个list的顺序,否则刷新时数据会全部乱掉。
4. 常见问题与排查技巧实录
写RecyclerView这几年,我总结了一批高频问题以及对应的排查方法。这些问题有些是原理性的,有些是使用习惯导致的。我把它们整理成了一张速查表,方便你以后踩坑时快速定位。
| 症状 | 可能原因 | 解决方案 |
|---|---|---|
| 列表滚动卡顿、掉帧 | onBindViewHolder里做了耗时操作,比如解码Bitmap、网络请求、复杂布局calculate | 把耗时操作移到后台线程,图片用Glide异步加载,item布局尽量用ConstraintLayout减少层级 |
| 列表数据更新后位置错乱、闪屏 | 使用了notifyDataSetChanged()刷新大数据集,或者item高度不定但没有setHasFixedSize(false) | 改用DiffUtil或ListAdapter做精准刷新,检查是否需要在数据更新前后锁定scrollPosition |
| item被复用时显示旧数据 | onBindViewHolder里没有对TextView、ImageView等控件做重置 | 绑定数据前先重置所有需要展示的控件,比如图片先设置占位图,文本先设置为空字符串 |
| 点击事件拿到的position不对 | 在onBindViewHolder里使用了getAdapterPosition(),在异步回调触发时position已经变化 | 使用getBindingAdapterPosition()(或absoluteAdapterPosition),并且在使用前判断是否等于NO_POSITION |
| 列表嵌套后内层Item无法点击 | 父容器拦截了触摸事件,或者item里嵌套了可点击的RecyclerView没有消费事件 | 检查父容器onInterceptTouchEvent,必要时在子View上调用requestDisallowInterceptTouchEvent(true) |
| 分割线位置错乱 | ItemDecoration的getItemOffsets里没有正确处理parent.getChildAdapterPosition(child) | 用getChildAdapterPosition判断当前position,根据需求决定是否加偏移量 |
| 拖拽排序后数据错乱 | ItemTouchHelper直接移动了View但没有同步数据源顺序 | 在onMove中同步修改Adapter内的mutableList,再调用notifyItemMoved |
| RecyclerView高度无法被包裹内容 | 在NestedScrollView里,RecyclerView的wrap_content高度计算不正确 | 尽量避免ScrollView套RecyclerView,改用NestedScrollView+android:fillViewport="true",或者用CoordinatorLayout做联动 |
4.1 一个真实的嵌套卡顿案例
有一回,我给一个商品详情页做了个结构:顶部是商品图片轮播,中间是图文详情,下面是一个“大家都在看”的推荐列表。产品要求整个页面一起滚,所以我最初用了NestedScrollView里面套RecyclerView,结果测试一跑,滑动极其卡顿,fps直接掉到40左右。
排查过程是这样的:第一步,我用Systrace抓trace,发现卡顿集中在RecyclerView的layoutChunk环节,说明是测量和布局耗时。第二步,我怀疑item布局太复杂——每个推荐商品卡片里有圆角图片、价格、标签、按钮,层级很深。第三步,我用Layout Inspector验证,发现item层级有7层,measure和layout确实很重。
最终的解决办法有三点:一是把item布局重构成ConstraintLayout为主的扁平结构,层级从7层降到3层;二是给推荐列表单独开一个RecyclerView,保证它的复用机制正常工作;三是对外层容器做了个非常重要的改造——不用NestedScrollView,改用CoordinatorLayout+AppBarLayout+RecyclerView的形式,让整个页面就是一个大的RecyclerView,把“图文详情”作为其中一个type的item处理。这个改动之后,流畅度立刻恢复,fps回到60。
这里我特别想说的是:千万不要用ScrollView/NestedScrollView直接套RecyclerView。因为NestedScrollView不知道RecyclerView内部复用了多少个item,它会把所有item全部inflate出来测量一遍,等于把RecyclerView的回收复用机制废了。你要是被迫用这种嵌套结构,一定要用nestedScrollingEnabled="false",让RecyclerView自身不消费滚动事件,全部交给外层滚动,并且item数量不能太多。说白了,这是“能用但绝不推荐”的方案,优化空间很有限。
4.2 图片加载导致的闪烁问题
另一个高频问题是:快速滑动图片列表时,item先显示一个旧的图片,过一会才显示正确的新图片。这本质上是RecyclerView的ViewHolder复用机制导致的——同一个ViewHolder滑出去再滑进来,它里面还残留着之前的数据,如果你没有及时重置,用户就会看到“闪一下旧图”。
解决办法很简单:在onBindViewHolder里先用占位图把ImageView设置好,再用Glide去加载新图。
holder.ivCover.setImageResource(R.drawable.placeholder) Glide.with(holder.ivCover.context) .load(item.coverUrl) .placeholder(R.drawable.placeholder) .into(holder.ivCover)不要小看这一行setImageResource,它可以有效避免图片“串图”。另外,如果你给RecyclerView设置了ItemAnimator,在快速刷新时,旧View的动画可能还没执行完毕就被移除了,也会造成闪烁。建议在图片类列表上使用DefaultItemAnimator时,把supportsChangeAnimations设为false,或者干脆设置itemAnimator = null(如果你不需要增删动画)。
4.3 使用ItemDecoration做吸顶效果时的一个大坑
吸顶效果,在很多信息流App里很常见:列表分了很多组,每一组的头一个item是一个“组标题”,滑动时组标题会吸在列表顶部,直到下一组标题把它顶上去。
实现这个效果的一个常用方案,就是在ItemDecoration的onDrawOver里,找到当前可见的第一个属于“组标题”的item,把它的内容绘制在列表顶部。思路不复杂,但有一个坑:onDrawOver在RecyclerView的onDraw之后执行,所以你必须自己计算好位置。如果你只是简单地把绘制内容固定在顶部,而忽略了“下一组标题”与当前吸顶标题之间的覆盖关系,就会出现两个标题重叠的bug。
我当时调试这个bug调了一个多小时,最后发现是因为我在getItemOffsets里给每个组标题的item设置了偏移量,但onDrawOver里绘制时没有考虑这个偏移量,导致绘制位置和实际item位置不一致。解决办法是:在getItemOffsets和onDrawOver里统一使用同一个方法计算吸顶的Y坐标,保证两种绘制逻辑的坐标系一致。
如果你不想自己造轮子,可以考虑使用PinnedHeaderItemDecoration之类的第三方库,但第三方的库遇到特殊需求时往往需要改源码,所以现有团队如果需求量不大,我一般还是建议自己写。毕竟ItemDecoration相关的代码,一次写好了,后面长期复用,收益很高。
5. 实战复盘与踩坑心得
完整的Demo代码很长,上面已经展示了核心模块,下面我挑几个容易“一看就会、一写就废”的点,展开聊聊。
5.1 为什么我坚持用Delegate而不是写一个万能Adapter
很多框架型项目里,你会看到一种“万能Adapter”,什么类型都能通过一个bean塞进去。我自己以前也用过,刚开始很爽,但到了后期——尤其是那种十几个页面的项目里——你改一个字段,几十个调用点都要跟着改,想死的心都有。Delegate模式看起来要建很多类,但每个类都很轻,数据流的边界也清晰,出bug时定位很快。
一个真实数据对比:我上一家公司有个首页,从最初一个600多行的Adapter,重构为Delegate模式后,每个Delegate也就80到120行,Adapter缩到了50行。后来需求迭代,需要新增两个模块,我只写了两个新Delegate,各注册一行,三分钟搞定,改完老代码一行没动。这个体验是无可替代的。
5.2notifyDataSetChanged的危害比你想象的更大
notifyDataSetChanged会让RecyclerView重走一遍完整刷新流程:清空当前屏幕所有ViewHolder,重新绑定所有item,即使你只改了一个字段,也会把全部item都重绘。如果在数据量5000+的信息流里频繁调用,用户滑动时就会明显感到“跳动”和“卡顿”。
我处理数据刷新的习惯是这样:能用ListAdapter就用ListAdapter,它内置了AsyncListDiffer,你只需要提交一个新列表,剩下的差异计算和动画都由框架帮你完成。这个开销没有你想象的那么大,实测下来,5000条数据的diff计算放在后台线程,时间在几十毫秒以内,完全可接受。如果你的数据量小于100条,那直接notifyItemChanged也行。
5.3 手写一个“加载更多”的完整流程
列表的分页加载,我见过无数种写法,有的是在Adapter里加一个状态item,有的是用第三方库LoadMoreWrapper。我的建议是,如果你已经用了Delegate模式,那“加载更多”本身也可以做成一个Delegate——它有一种特殊的viewType,展示上滑到底时显示的loading动画、到底没有更多时的“没有更多了”提示、以及网络异常时的“点击重试”。
具体流程是:监听RecyclerView的滑动事件,在onScrolled里判断是否滚动到了最后一项,如果是且当前不在加载状态,就触发加载回调。等新数据回来后,先移除“加载更多”这个占位item,再插入真实数据。
这里有两个细节值得注意。第一,加载更多这个占位item不应该算在真正的数据源里,否则你向上滑动时,item position会多出一个偏移。我会维护一个单独的isLoadingMore布尔值和一个loadMoreState常量,在getItemCount时判断要不要多加一项。第二,滑动到底部的判断公式要写对:layoutManager.findLastVisibleItemPosition() >= itemCount - 1,但如果你用了GridLayoutManager,要考虑spanCount的影响,建议用findLastCompletelyVisibleItemPosition()来判断“完全可见”的最后一个,避免最后一行只露出一半时就触发加载。
顺带说一句,在AsyncListDiffer的时代,加载更多的数据合并其实是一个很麻烦的活儿。如果直接用submitList,它会把整个列表重新diff一遍。我的做法是:如果是“加载下一页”,就在原有list基础上addAll,然后再submitList。如果diff数据量过大,考虑用submitList的commitCallback来做UI提示。
5.4 空页面和异常状态怎么处理
一个完整的电商商品推荐流,肯定要处理加载中、空数据、网络异常、下拉刷新这些状态。我推荐用Delegation的思路,把空数据也当成一种特殊的“item类型”。这样,你不用在Activity或Fragment里维护View.GONE/View.VISIBLE的逻辑,只需要在数据源里插入一个EmptyItem,Delegate里展示对应的空状态布局即可。
如果用SwipeRefreshLayout搭配RecyclerView做下拉刷新,注意SwipeRefreshLayout的setOnRefreshListener里要重置数据源,并且在刷新完成后调用swipeRefresh.isRefreshing = false。如果你在刷新过程中还在往上加载更多,要加锁防止并发,否则数据顺序会乱掉。
5.5 高级进阶:自绘ItemDecoration实现水印或浮层
如果你不满足于分割线和吸顶效果,ItemDecoration还有潜力可挖。比如给图片类的item加一个蒙层、在item上绘制水印文字、给某个分类下的item背景添加不同的颜色等。
举个例子,我做过一个清宫表风格的列表,每个item右下角要有一个“周推荐”角标。直接把这个角标加到item布局里当然能做,但遇到需要“只在特定条件下显示角标”的场景,在布局里写就非常费劲。如果用ItemDecoration,你可以在onDrawOver里拿到当前可见的所有child,依次判断它们的position对应的数据是否有角标需求,有就用Canvas画一个圆形背景和文字到对应位置。这种做法的好处是,角标不占item布局的复杂度,动态控制也方便。
6. 性能优化与调试工具
RecyclerView卡顿问题,光靠“感觉”是没法定位的。我建议每个做列表优化的Android开发,都掌握下面这几个工具,不然你优化代码基本就是乱枪打鸟。
6.1 Layout Inspector和GPU渲染分析
AS自带的Layout Inspector可以看当前页面的View层级,我通常用它来检查item布局是否层级过深。如果你在Layout Inspector里发现某个item的View树深度超过5层,就要考虑用ConstraintLayout或者自定义View来压层级了。层级越深,一次性measured和layout的耗时越长,在列表滚动时会放大成卡顿。
GPU渲染分析则能帮你定位“掉帧到底是绘制耗时还是布局耗时”。选中Profile GPU Rendering,你会看到三种颜色的竖条:红色表示绘制,蓝色表示执行渲染线程,黄色表示上传纹理。如果蓝色条特别长,一般意味着onDraw绘制过多,比如你的item里有大量阴影、圆角裁剪,可以考虑用锐利的bitmap代替。如果黄色条过长,说明有大量的图片上传,尽量用Glide的override()调整尺寸,避免加载过大的原图。
6.2 Systrace:从系统层面定位掉帧
Systrace的脚本在Android SDK的platform-tools/systrace目录下,新版本AS推荐用Android Studio Profiler里的System Trace代替。使用Systrace时,重点关注RecyclerView相关的标记,比如RV onBindViewHolder、RV layout、RV getViewType等。如果你的frame里这些标记耗时超了,说明列表的绑定或布局是瓶颈;如果这些标记不长,但总帧时间超了,那可能是View的绘制或其他系统组件在抢CPU。
我经验里最典型的案例就是:在onBindViewHolder里调用了View.invalidate()或者频繁地改变数据,导致整个item被多次重绘。修复方式是把多次setText、setImage合到一次提交里,利用LayoutTransition的抑制功能减少无效布局。
6.3 常用的优化配置清单
最后,我给出一份我自己常用的RecyclerView优化配置清单,你可以在项目里直接参考:
recyclerView.apply { setHasFixedSize(true) // 如果item高度固定 setItemViewCacheSize(20) // 增大缓存个数,滑动更快回显 setDrawingCacheEnabled(true) // 如果item绘制很复杂,可以开启 setDrawingCacheQuality(View.DRAWING_CACHE_QUALITY_HIGH) // 不常用,谨慎开启 isNestedScrollingEnabled = true // 父容器是NestedScrollView时设为false,否则true itemAnimator?.changeDuration = 0 // 如果图片/数据频繁变化,关掉change动画 }同时,对Adapter侧,我会严格控制getItemCount复杂度,不要让itemCount的计算涉及数据库查询或网络判断。自定义LayoutManager的时候,一定要想清楚你是否需要重写canScrollVertically和canScrollHorizontally,这俩方法返回值会影响外部容器对滚动事件的处理。
其实RecyclerView这套架构弄明白了,你以后再去做任何复杂列表,都能保持一个比较清晰的思路。我之前也给不少组里的新人做过培训,发现90%以上的“列表卡顿”问题,归根到底不是RecyclerView的锅,而是用的人没有理解它的复用机制和刷新策略。希望这篇文章能帮你绕开那些我也曾经踩过的坑,少走点弯路。
最后再分享一个我自己刻在脑门上的原则:任何时候都不要在onBindViewHolder里做“看不见的工作”。绑定什么就显示什么,显示什么就必须是数据层准备好、格式化好的成品。列表的UI层只做“搬运工”,不做“加工厂”。按这个思路写RecyclerView,你的代码大概率不会烂到哪里去。