第十九章:列表性能优化——RecyclerView缓存机制深度解析、DiffUtil与异步列表更新、ItemDecoration与嵌套滑动优化
列表性能优化,说白了就是让用户滑得爽。我见过太多App,数据量一上来就卡成PPT。今天咱们就聊聊RecyclerView这套东西,怎么把它调教得服服帖帖。
一、RecyclerView缓存机制:到底在缓存什么?
很多人以为RecyclerView的缓存就是ViewHolder复用。其实没那么简单。它内部有四级缓存,每一级都有自己的使命。
核心观点:RecyclerView的缓存设计,本质上是「用空间换时间」的经典案例。每一级缓存都在试图避免调用onCreateViewHolder和onBindViewHolder。
1.1 四级缓存结构
| 缓存级别 | 名称 | 存储内容 | 访问速度 |
|---|---|---|---|
| 第一级 | mAttachedScrap | 屏幕内可见的ViewHolder | 极快(无创建、无绑定) |
| 第二级 | mCachedViews | 刚滑出屏幕的ViewHolder(默认2个) | 快(无创建、无绑定) |
| 第三级 | mViewCacheExtension | 开发者自定义缓存 | 取决于实现 |
| 第四级 | mRecyclerPool | 按ViewType分类的ViewHolder池(默认5个/池) | 慢(需要重新绑定) |
嗯,这里要注意。mCachedViews和mRecyclerPool的区别很多人搞混。mCachedViews里的ViewHolder是干净的,拿出来直接用,连onBindViewHolder都不用走。而mRecyclerPool里的ViewHolder需要重新绑定数据。
我个人习惯把mCachedViews的默认大小调大一点。比如在图片列表场景,默认2个太少了,滑快一点就会掉到Pool里,触发重新绑定。我一般设成5到8个。
// 增大缓存容量
RecyclerView recyclerView = findViewById(R.id.recycler_view);
recyclerView.setItemViewCacheSize(8);
// 或者通过LayoutManager设置
LinearLayoutManager layoutManager = new LinearLayoutManager(this);
layoutManager.setItemPrefetchEnabled(true);
recyclerView.setLayoutManager(layoutManager);
1.2 Prefetch机制:提前布局
RecyclerView在API 21之后引入了Prefetch机制。说白了就是,当手指还在滑动时,LayoutManager会提前创建并绑定即将进入屏幕的Item。这个机制默认是开启的。
我在项目中遇到过一个问题:列表快速滑动时,新出现的Item会白屏一下。后来发现是Prefetch被某个自定义LayoutManager给关了。打开之后,流畅度明显提升。
小技巧:如果你的列表Item布局比较复杂,可以考虑配合AsyncLayoutInflater做异步inflate。但要注意,AsyncLayoutInflater不能用在RecyclerView的onCreateViewHolder里,因为它是异步的,ViewHolder需要同步返回。
二、DiffUtil与异步列表更新
列表更新最怕什么?全量刷新。你想想看,数据变了几个字段,结果整个列表都notifyDataSetChanged,所有Item都重新绑定。这太浪费了。
DiffUtil就是来解决这个问题的。它能计算出新旧数据集的最小差异,然后只更新变化的部分。
2.1 DiffUtil的核心用法
public class UserDiffCallback extends DiffUtil.Callback {
private List<User> oldList;
private List<User> newList;
public UserDiffCallback(List<User> oldList, List<User> newList) {
this.oldList = oldList;
this.newList = newList;
}
@Override
public int getOldListSize() {
return oldList.size();
}
@Override
public int getNewListSize() {
return newList.size();
}
@Override
public boolean areItemsTheSame(int oldItemPosition, int newItemPosition) {
// 判断是否是同一个对象,通常用id
return oldList.get(oldItemPosition).getId() == newList.get(newItemPosition).getId();
}
@Override
public boolean areContentsTheSame(int oldItemPosition, int newItemPosition) {
// 判断内容是否相同
return oldList.get(oldItemPosition).equals(newList.get(newItemPosition));
}
@Nullable
@Override
public Object getChangePayload(int oldItemPosition, int newItemPosition) {
// 返回局部更新的payload,可以实现局部刷新
User oldUser = oldList.get(oldItemPosition);
User newUser = newList.get(newItemPosition);
Bundle diff = new Bundle();
if (!oldUser.getName().equals(newUser.getName())) {
diff.putString("name", newUser.getName());
}
if (oldUser.getAge() != newUser.getAge()) {
diff.putInt("age", newUser.getAge());
}
return diff;
}
}
2.2 异步计算DiffResult
DiffUtil的计算是在主线程进行的。如果列表数据量很大(比如上千条),计算过程会卡住主线程。这时候就需要异步计算。
我曾经在一个聊天记录列表里踩过这个坑。用户一次加载了500条消息,用DiffUtil更新时界面直接卡了200毫秒。用户体验非常差。
// 使用AsyncListDiffer,它内部用AsyncTask做异步计算
public class UserAdapter extends RecyclerView.Adapter<UserAdapter.ViewHolder> {
private AsyncListDiffer<User> differ = new AsyncListDiffer<>(this, new DiffUtil.ItemCallback<User>() {
@Override
public boolean areItemsTheSame(User oldItem, User newItem) {
return oldItem.getId() == newItem.getId();
}
@Override
public boolean areContentsTheSame(User oldItem, User newItem) {
return oldItem.equals(newItem);
}
});
public void submitList(List<User> list) {
differ.submitList(list); // 自动在后台线程计算diff
}
@Override
public int getItemCount() {
return differ.getCurrentList().size();
}
}
注意:AsyncListDiffer的submitList方法会丢弃旧列表的引用。如果你在外部还持有旧列表,可能会出现数据不一致。我建议把数据源统一交给Adapter管理,外部只通过submitList来更新。
三、ItemDecoration与嵌套滑动优化
ItemDecoration是个好东西,但用不好也会带来性能问题。很多人用它来做分割线、间距、分组头等。但每次绘制都走onDraw,如果绘制逻辑太重,就会掉帧。
3.1 高效的ItemDecoration实现
我个人习惯把ItemDecoration的绘制逻辑尽量简化。不要在onDraw里做复杂的计算,能预计算的提前算好。
public class SimpleDividerDecoration extends RecyclerView.ItemDecoration {
private int dividerHeight;
private Paint dividerPaint;
public SimpleDividerDecoration(int dividerHeight, int color) {
this.dividerHeight = dividerHeight;
dividerPaint = new Paint();
dividerPaint.setColor(color);
dividerPaint.setStyle(Paint.Style.FILL);
}
@Override
public void getItemOffsets(Rect outRect, View view, RecyclerView parent, RecyclerView.State state) {
// 最后一个Item不画分割线
if (parent.getChildAdapterPosition(view) != parent.getAdapter().getItemCount() - 1) {
outRect.bottom = dividerHeight;
}
}
@Override
public void onDraw(Canvas c, RecyclerView parent, RecyclerView.State state) {
int childCount = parent.getChildCount();
int left = parent.getPaddingLeft();
int right = parent.getWidth() - parent.getPaddingRight();
for (int i = 0; i < childCount - 1; i++) {
View child = parent.getChildAt(i);
int top = child.getBottom();
int bottom = top + dividerHeight;
c.drawRect(left, top, right, bottom, dividerPaint);
}
}
}
3.2 嵌套滑动:NestedScrolling的坑
嵌套滑动是Android里一个老大难问题。最常见的就是RecyclerView嵌套RecyclerView,或者CoordinatorLayout里套RecyclerView。
为什么会卡?因为嵌套滑动时,父控件和子控件都在处理同一个触摸事件,事件分发变得复杂。如果处理不当,会导致频繁的measure和layout。
我记得有一次,一个页面里嵌了三个RecyclerView,滑动时卡得不行。后来发现是每个RecyclerView都设置了setNestedScrollingEnabled(true),导致事件在多个层级间来回传递。
解决方案:
- 内层RecyclerView设置setNestedScrollingEnabled(false),让外层统一处理滑动
- 或者使用NestedScrollView + RecyclerView的组合,但要注意NestedScrollView会一次性加载所有子View
- 如果必须嵌套,考虑使用ViewPager2 + RecyclerView,ViewPager2内部做了优化
// 内层RecyclerView禁用嵌套滑动
innerRecyclerView.setNestedScrollingEnabled(false);
// 外层使用NestedScrollView时,RecyclerView要设置固定高度
// 或者使用LinearLayoutManager的setInitialPrefetchItemCount提前预加载
四、知识体系总览
下面这张图是我整理的列表性能优化核心脉络。你可以把它当作一个检查清单,每次做列表优化时对照着看。
避坑指南:我曾经在一个项目里发现,列表卡顿的根源不是RecyclerView本身,而是Item里的图片加载。每次绑定ViewHolder时都重新加载图片,导致内存抖动。后来用了Glide的缓存和缩略图功能,问题就解决了。所以,优化列表时别忘了检查Item内部的资源加载。
好了,关于列表性能优化就聊这么多。缓存机制、Diff更新、滑动优化,这三块是核心。你可以在实际项目中逐个对照检查,看看哪里还有优化空间。