ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

RecyclerView高级用法与性能优化:多类型布局与DiffUtil实战

RecyclerView高级用法与性能优化:多类型布局与DiffUtil实战 先说明一句做安卓开发的只要你的列表超过三行你就绕不开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机制或者干脆换用CoordinatorLayoutAppBarLayout来做联动。性能与卡顿优化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 ViewHolderDelegateT { 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实例时我喜欢用MapInt, 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的内容是否一致。比如有一个字段变了这里返回falseDiffUtil就知道这个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 Hedgehog2023.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的泛型可以统一为HomeItemDelegate通过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 Delegatein T : HomeItem, out VH : RecyclerView.ViewHolder { val viewType: Int fun createViewHolder(parent: ViewGroup): VH fun bindViewHolder(holder: VH, item: T, position: Int) }然后Adapter内部用SparseArrayDelegateHomeItem, *来保存所有注册的Delegateclass HomeAdapter : RecyclerView.AdapterRecyclerView.ViewHolder() { private val delegates SparseArrayDelegateHomeItem, *() private val items mutableListOfHomeItem() fun registerDelegate(delegate: DelegateHomeItem, *) { delegates.put(delegate.viewType, delegate) } fun submitList(newList: ListHomeItem) { 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 DelegateHomeItem, *).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 : DelegateProductItem, 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 : DelegateSeckillItem, SeckillDelegate.SeckillVH { private val timerMap HashMapInt, 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实现了Bannerval bannerAdapter BannerAdapter(bannerItems) val bannerRecyclerView findViewByIdRecyclerView(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) }方法二用CoordinatorLayoutAppBarLayoutCollapsingToolbarLayout把标题栏和Banner做成联动效果。这也是我在项目里最常用的方案。CoordinatorLayout会自动协调子View的嵌套滚动事件你几乎不用写任何手势冲突代码就能实现“上滑时标题栏折叠、下滑时标题栏展开”的效果。很多新手一听CoordinatorLayout就觉得复杂其实它本质就是一个增强版的FrameLayout它的下属View可以通过定义Behavior来响应嵌套滚动事件。RecyclerView作为NestedScrollingChild时AppBarLayout的Behavior会自动拦截部分滚动事件从而让标题栏做出折叠动作。这套机制你不需要完全搞懂底层按官方模板写就能跑。3.7 拖拽排序与滑动删除拖拽排序是另一种常见的高级需求。比如电商App的“最近浏览”板块用户长按某个item可以拖到别的顺序或者左滑删除。RecyclerView的ItemTouchHelper就是为此而生的。用法很简单核心是继承ItemTouchHelper.Callbackclass 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做精准刷新检查是否需要在数据更新前后锁定scrollPositionitem被复用时显示旧数据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再调用notifyItemMovedRecyclerView高度无法被包裹内容在NestedScrollView里RecyclerView的wrap_content高度计算不正确尽量避免ScrollView套RecyclerView改用NestedScrollViewandroid:fillViewporttrue或者用CoordinatorLayout做联动4.1 一个真实的嵌套卡顿案例有一回我给一个商品详情页做了个结构顶部是商品图片轮播中间是图文详情下面是一个“大家都在看”的推荐列表。产品要求整个页面一起滚所以我最初用了NestedScrollView里面套RecyclerView结果测试一跑滑动极其卡顿fps直接掉到40左右。排查过程是这样的第一步我用Systrace抓trace发现卡顿集中在RecyclerView的layoutChunk环节说明是测量和布局耗时。第二步我怀疑item布局太复杂——每个推荐商品卡片里有圆角图片、价格、标签、按钮层级很深。第三步我用Layout Inspector验证发现item层级有7层measure和layout确实很重。最终的解决办法有三点一是把item布局重构成ConstraintLayout为主的扁平结构层级从7层降到3层二是给推荐列表单独开一个RecyclerView保证它的复用机制正常工作三是对外层容器做了个非常重要的改造——不用NestedScrollView改用CoordinatorLayoutAppBarLayoutRecyclerView的形式让整个页面就是一个大的RecyclerView把“图文详情”作为其中一个type的item处理。这个改动之后流畅度立刻恢复fps回到60。这里我特别想说的是千万不要用ScrollView/NestedScrollView直接套RecyclerView。因为NestedScrollView不知道RecyclerView内部复用了多少个item它会把所有item全部inflate出来测量一遍等于把RecyclerView的回收复用机制废了。你要是被迫用这种嵌套结构一定要用nestedScrollingEnabledfalse让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的逻辑只需要在数据源里插入一个EmptyItemDelegate里展示对应的空状态布局即可。如果用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你的代码大概率不会烂到哪里去。
返回列表