18、列表优化(上):RecyclerView缓存机制、DiffUtil使用、ItemDecoration优化、嵌套滑动问题
列表优化这个话题,说实话,是Android性能优化里最“接地气”的一块。你想想看,用户每天打开App,看到的第一个界面大概率就是个列表。如果列表卡顿、闪烁、滑动不跟手,那体验基本就毁了。我这些年做性能优化,光是在列表上踩过的坑,就能写满一个小本子。
今天我们先聊四个核心点:缓存机制、DiffUtil、ItemDecoration优化、嵌套滑动。这几个点搞明白了,列表性能至少能提升50%。
一、RecyclerView的缓存机制——你真的了解它吗?
很多人觉得RecyclerView的缓存就是ViewHolder复用,其实没那么简单。它内部有四级缓存,每一级的作用和访问速度都不一样。
核心观点:理解四级缓存,才能写出不卡顿的列表。
1. 四级缓存结构
| 缓存级别 | 名称 | 作用 | 访问速度 |
|---|---|---|---|
| 第一级 | mAttachedScrap | 屏幕内可见的ViewHolder,用于布局恢复 | 最快 |
| 第二级 | mCachedViews | 最近移出屏幕的ViewHolder,默认缓存2个 | 快 |
| 第三级 | mViewCacheExtension | 开发者自定义缓存,一般不用 | 由实现决定 |
| 第四级 | mRecyclerPool | 按ViewType分类的ViewHolder池,默认每个类型缓存5个 | 较慢 |
我个人的习惯是,优先保证mCachedViews命中。为什么?因为mCachedViews里的ViewHolder不需要重新绑定数据,直接拿来用就行。而mRecyclerPool里的ViewHolder虽然复用了布局,但必须重新调用onBindViewHolder。
小技巧:如果你的列表项布局都一样,可以适当调大mCachedViews的容量。调用setItemViewCacheSize(size)即可。我一般设成4-6个,效果不错。
2. 一个常见的误区
很多人以为notifyDataSetChanged()只是刷新数据,其实它会清空mCachedViews。这意味着所有屏幕外的ViewHolder都要重新创建或从Pool里取。我在项目中遇到过,一个列表频繁调用notifyDataSetChanged,导致滑动时明显卡顿。后来改成notifyItemChanged()或DiffUtil,问题就解决了。
注意:不要滥用notifyDataSetChanged。它是最简单的方式,但也是性能最差的。
二、DiffUtil——让列表更新“精准”起来
DiffUtil是Google官方提供的一个工具类,用来计算两个列表的差异。它的核心思想是:只更新变化的部分,而不是全量刷新。
1. 基本用法
使用DiffUtil需要实现一个DiffUtil.Callback,里面有两个关键方法:
class MyDiffCallback(
private val oldList: List<Item>,
private val newList: List<Item>
) : DiffUtil.Callback() {
override fun getOldListSize(): Int = oldList.size
override fun getNewListSize(): Int = newList.size
// 判断两个item是否是同一个对象(比如id相同)
override fun areItemsTheSame(oldPos: Int, newPos: Int): Boolean {
return oldList[oldPos].id == newList[newPos].id
}
// 判断两个item的内容是否相同
override fun areContentsTheSame(oldPos: Int, newPos: Int): Boolean {
return oldList[oldPos] == newList[newPos]
}
}
然后通过DiffUtil.calculateDiff()计算差异,再分发到Adapter:
val diffResult = DiffUtil.calculateDiff(MyDiffCallback(oldList, newList))
adapter.submitList(newList) // 如果是ListAdapter,直接调用submitList
diffResult.dispatchUpdatesTo(adapter)
2. 性能考量
DiffUtil的算法复杂度是O(N+M),其中N和M分别是新旧列表的长度。如果列表项超过1000,计算时间可能会超过16ms,导致掉帧。我建议:
- 列表项少于200:直接用DiffUtil,没问题。
- 列表项200-1000:可以考虑在子线程计算DiffResult。
- 列表项超过1000:建议分页加载,或者用更轻量的方案(比如只更新可见项)。
个人经验:我曾经在一个聊天列表里用DiffUtil,列表项有5000多条。每次发送消息都要计算差异,卡得不行。后来改成只更新最后一条,配合局部刷新,流畅多了。
3. AsyncListDiffer vs DiffUtil
如果你用的是ListAdapter,它内部已经封装了AsyncListDiffer,会自动在子线程计算差异。我个人更推荐这种方式,省心又高效。
三、ItemDecoration优化——别让装饰成为负担
ItemDecoration是用来给列表项添加分割线、间距、背景等装饰的。但很多人不知道,不合理的ItemDecoration会严重拖慢列表性能。
1. 常见问题
- 频繁创建对象:在
onDraw或getItemOffsets里new对象,导致GC频繁。 - 过度绘制:装饰层覆盖了列表项,导致GPU重复渲染。
- 计算复杂:在
getItemOffsets里做耗时计算,阻塞主线程。
2. 优化建议
我总结了几条实用的优化点:
- 复用对象:把Paint、Rect等对象声明为成员变量,不要在方法里创建。
- 减少绘制区域:只在需要的地方绘制,不要全屏画。
- 使用轻量级装饰:能用
divider属性解决的,就别写自定义Decoration。
class MyDecoration : RecyclerView.ItemDecoration() {
private val paint = Paint().apply {
color = Color.LTGRAY
strokeWidth = 1f
}
override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: State) {
// 只给非最后一个item添加间距
if (parent.getChildAdapterPosition(view) != parent.adapter?.itemCount?.minus(1)) {
outRect.bottom = 16
}
}
override fun onDraw(c: Canvas, parent: RecyclerView, state: State) {
// 只绘制可见区域的分割线
for (i in 0 until parent.childCount) {
val child = parent.getChildAt(i)
if (parent.getChildAdapterPosition(child) != parent.adapter?.itemCount?.minus(1)) {
val y = child.bottom.toFloat()
c.drawLine(child.left.toFloat(), y, child.right.toFloat(), y, paint)
}
}
}
}
注意:onDraw方法会被频繁调用,每次滑动都会触发。所以里面的逻辑一定要轻量,不要做任何分配内存的操作。
四、嵌套滑动问题——一个老生常谈的坑
嵌套滑动,说白了就是一个可滑动的View里面又套了一个可滑动的View。最常见的场景是:NestedScrollView里套RecyclerView。
1. 为什么会有问题?
默认情况下,两个可滑动View会争夺触摸事件。你手指一滑,外层想滑,内层也想滑,结果就是卡顿、滑动不流畅、甚至完全滑不动。
2. 解决方案
我遇到过好几种场景,分别有不同的解法:
| 场景 | 推荐方案 | 说明 |
|---|---|---|
| NestedScrollView + RecyclerView | RecyclerView设置setNestedScrollingEnabled(false) |
让RecyclerView不拦截滑动,全部交给外层处理 |
| RecyclerView嵌套RecyclerView | 内层RecyclerView固定高度,或使用setHasFixedSize(true) |
避免内层高度动态变化导致频繁测量 |
| ViewPager2 + RecyclerView | 使用requestDisallowInterceptTouchEvent控制 |
在水平滑动时禁止垂直拦截 |
3. 一个我踩过的坑
我曾经在NestedScrollView里放了一个RecyclerView,列表项有图片。滑动时总是卡顿,排查了半天才发现是RecyclerView的onMeasure被反复调用。因为NestedScrollView需要知道RecyclerView的高度,而RecyclerView的高度又取决于内容。这就形成了一个测量循环。
解决方案很简单:给RecyclerView设置一个固定高度,或者用setHasFixedSize(true)告诉系统“我的高度不会变”。
建议:如果列表项不多(比如少于10个),直接用LinearLayout代替RecyclerView,反而更简单高效。
知识体系总览
下面这张图总结了本章的核心知识点,方便你对照理解:
嗯,以上就是列表优化上篇的全部内容。缓存机制是基础,DiffUtil是利器,ItemDecoration要轻量,嵌套滑动要谨慎。把这四点吃透了,你的列表性能基本就稳了。
下一章我们会继续聊列表优化的其他方面,比如异步加载、预加载、布局优化等。到时候再细说。
公众号:蓝海资料掘金营,微信 deep3321