RecyclerView 详解

RecyclerView 的优势

  • 默认支持 Linear、Grid、Staggered Grid 三种布局
  • 友好的 ItemAnimator 动画 API
  • 强制实现 ViewHolder
  • 解耦的架构设计
  • 相比 ListView 更好的性能

View Holder

View holder 是什么

       下图是 ListView 中没有实现 View Holder 的 getView(),findViewById() 方法会执行很多次,列表在不断滑动,每次 getView() 时,都会执行。findViewById() 的底层实现为 DFS(深度优先搜索算法),时间复杂度为O(!n)

没有实现 View Holder 的 getView()

       下图是实现了 View Holder 的 getView(),这也是 View Holder 名字的来历,用来保存 view 引用的容器类。每当创建 convertView 时,同时创建一个对应的 ViewHolder 对象,然后将 convertView 以及它的子 view 全部存入该对象,同时将 convertView 和 ViewHolder 通过 View.setTag() 方法绑定起来。当 convertView 为空时才会执行 findViewById,如果不为空,即该 convertView 是被复用的,之前用过被回收,此时再次返回来使用,直接通过 getTag() 将 ViewHolder 取出来。下面数据绑定的过程全部是通过 holder 里面的子 view 来完成的。

实现了 view holder 的 getView()

       Item View 和 View Holder 是一一对应的关系。创建一个 Item View 时,同时创建一个 View Holder,并且将 View Holder 放到 Item View 里面。后面用到 View Holder,拿到 Item View 再将它取出来。是否复用 Item View 和使用 View Holder 没有关系,即便不使用 View Holder,一样在复用 Item View,只是 findViewById() 比较耗性能。

       在 RecyclerView 中,View Holder 除了解决 findViewById() 的性能问题,还保存了一些和 Item 相关的信息,比如 Item 的位置信息。 RecyclerView 的 View Holder 和 ListView 的设计理念是一样的,防止重复 findViewById(),提升效率。

View holder 最佳实践

       对于 viewType 种类多的 View ,可以将具体的绑定逻辑写到对应的 ViewHolder 里面,如下代码所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
static class UserViewHolder extends RecyclerView.ViewHolder {
ImageView avatar;
TextView name;

UserViewHolder(@NonNull View itemView) {
super(itemView);
avatar = itemView.findViewById(R.id.avatar);
name = itemView.findViewById(R.id.name);
}

void bindTo(User user){
// bind data to UI
}
}

// public void onBindViewHolder(UserViewHolder holder, int position) {
// holder.bindTo(userList.get(position));
// }

Recyclerview 缓存机制

ListView 的缓存

       ListView 通过一个 RecycleBin 的类来管理 Item View,在 RecycleBin 中有两层缓存,第一层是 Active View,第二层是 Scrap View。需要 View 时,先从 Active View 中寻找并返回,如果没有,再从 Scrap View 中找。Active View 即“活动”的 View,在屏幕内部的 View,滑动过程中用户看到的 View;Scrap View 是“废弃”的 View,指的是已经从屏幕当中移出去的 View,已经被回收,放入了 RecycleBin 中。如果两层缓存都没找到,则 Create View,调用用户自己创建 View。

ListView 缓存图示1

ListView 缓存图示2

       Android 的刷新频率是16.6ms,ListView 在屏幕渲染时,会将所有的 Item View 给清空掉,然后再根据最新状态渲染到页面上。被清除掉的这部分 Item 事实上除了位置不同,其它的都相同,这部分 Item 不需要重新绑定数据,ListView 就会跳过 Adapter 的 getView() 的调用。凡是重新复用的不需要绑定数据的 Item View 会跳过 getView() 的调用,凡是调用了 getView() 都需要进行数据绑定的操作。

Recyclerview 的缓存

       Recyclerview 通过 Recycler 来管理缓存,ListView 缓存的是 Item View,即那个 View 对象,Recyclerview 缓存的是 ViewHolder。但二者没有本质区别,因为 Item View 和 View Holder 是一一对应相互绑定的。

       Recyclerview 有四层缓存,如果需要一个 View,会沿着1-4的顺序找,最上层的 Scrap 对应的是 ListView 的 Active View ,对于 Recyclerview 来说,Scrap 就是屏幕内的 Item View。ViewCaCheExtension 这一层,是用户自定义的 Cache 策略,如果用户没有定义,则直接到第四层。如果没有,最终会调用到 Adapter 的 onCreateViewHolder,用户创建 Item View 和 View Holder 绑定起来,返回给系统。

Recyclerview 缓存图示1

Recyclerview 缓存图示2

       屏幕内的第一层缓存 Scrap View 是可以直接复用的,不需要重新绑定数据。这种可以直接复用的 View 是通过数据集的 position 来找到对应的 View 的。Recyclerview 会将刚刚移出屏幕的几个(默认是2,可以自己设置) Item View 放入到 Cache 缓存中,它和第一层是差不多的,可以直接复用,也是通过 position 找到 Cache 中的 Item View,不需要重新绑定。ViewCaCheExtension 用户自定义的缓存,使用不多,RecycledViewPool 是所有被废弃的 Item View 的池子,它里面 Item View 上的数据都是“脏”的,它是通过 View Type 来找缓存的,因为数据是脏的,所以需要重新绑定数据,也就是说不走 onCreateViewHolder,但是会走 onBindViewHolder。

1
2
Scrap 和 Cache 通过 position 找到缓存,并且数据是“干净”的,所以不需要重新绑定数据;
RecycledViewPool 是通过 ViewType 找缓存的,数据是“脏”的,需要重新绑定数据。

ViewCacheExtension 使用场景

       广告卡片。列表内的广告数据可能和列表数据是分开的,二者需要请求不同的 API,而对于每一页,一共有4个广告,并且这些广告短期内不会发生变化。每次滑入一个广告卡片,一般情况下都需要重新绑定,广告卡片渲染比较耗时,影响性能。对于 Cache,它只关心 position,不关心 View Type,它缓存的是最近滑出去的 View,不可能缓存这四个广告卡片(四个广告卡片的距离相对来说比较远),RecycledViewPool 只关心 View Type,View 都需要重新绑定,而所有卡片都重新绑定,很耗性能。所以在 ViewCacheExtension 里保持4个广告 Card 缓存。这样不需要每次都绑定,可以拿来直接使用,从而提升效率。

列表中 item/广告的 impression 统计

       ListView 通过 getView() 统计是正确的,ListView 在 getView() 时,就是用户看到它的时候,看到一次调用一次,数值是正确的;而 Recyclerview 在 onBindViewHolder 方法中统计是错误的,在有些情况下,用户滑出屏幕又滑回来(前面提到的 Cache),不走 onBindViewHolder,这种情况下,有可能发生用户看了10次广告而统计了8次。Recyclerview 的 AdapterView 里面,有 onViewAttachedToWindow() 方法,View 进入可见区域,就会回调该方法,使用该方法来统计。

RecyclerView 性能优化策略

不要在 onBindViewHolder 里设置点击监听

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class SimpleAdapter extends RecyclerView.Adapter {

@Override
public ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {

}

@Override
public void onBindViewHolder(ViewHolder holder, int position) {
holder.itemView.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
...
}
});
}
}

       上面代码在 onBindviewholder 里设置点击监听器会导致重复创建对象,所以需要在 onCreateViewHolder 里设置点击监听,View、ViewHolder、View.OnClickListener 三者一一对应。类似如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class SimpleAdapter extends RecyclerView.Adapter {

@Override
public ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
final SimpleViewHolder holder = new SimpleViewHolder();
holder.itemView.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
...
}
});
}

@Override
public void onBindViewHolder(ViewHolder holder, int position) {

}
}

使用 LinearLayoutManager.setInitialPrefetchItemCount()

       用户滑动到横向滑动的 item Recyclerview 的时候(Recyclerview 嵌套,内部还是一个 Recyclerview),由于需要创建更复杂的 Recyclerview 以及多个子 view,可能会导致页面卡顿。由于 RenderThread 的存在,RecyclerView 会进行 prefetch(预加载)。对于嵌套 RecyclerView 要得到最佳性能,在内层 LayoutManager 上调用 LinearLayoutManager 的新方法 setInitialItemPrefetchCount()(v25.1可用)。它表示横向列表初次显示时可见的 item 个数。比如,如果一个垂直列表最少能显示3个以上的 item,调用 setInitialItemPrefetchCount(4)。

1
2
只有 LinearLayoutManager 有这个 API
只有嵌套在内部的 Recyclerview 使用才会生效

       如果自己实现 LayoutManager 的话,需要重写 LayoutManager.collectAdjacentPrefetchPositions(),因为 prefetch 开启的时候 RecyclerView 会调用这个方法,而它默认的实现什么也没做。同样当 RecyclerView 是嵌套在另一个 RecyclerView 中的时候,要想它的 LayoutManager 发生预取行为,也要实现 LayoutManager.collectInitialPrefetchPositions()。

RecyclerView.setHasFixedSize()

       如下面伪代码:

1
2
3
4
5
6
7
void onContentsChanged() {
if (mHasFixedSize) {
layoutChildren();
} else {
requestLayout();
}
}

       RecyclerView 有固定尺寸,则 layoutChildren,否则 requestLayout。如果 Adapter 的数据变化不会导致 RecyclerView 的大小变化 RecyclerView.setHasFixedSize(true)。当我们确定 Item 的改变不会影响 RecyclerView 的宽高的时候可以设置 setHasFixedSize(true),并通过 Adapter 的增删改插方法去刷新 RecyclerView,而不是通过 notifyDataSetChanged()。(其实可以直接设置为true,当需要改变宽高的时候就用 notifyDataSetChanged()去整体刷新一下)

多个 RecyclerView 共用 RecycledViewPool

       多个不同的 RecyclerView 之间共享 RecycledViewPool。代码如下:

1
2
3
4
RecyclerView.RecycledViewPool recycledViewPool = new RecyclerView.RecycledViewPool();
recycledView1.setRecycledViewPool(recycledViewPool);
recycledView2.setRecycledViewPool(recycledViewPool);
recycledView3.setRecycledViewPool(recycledViewPool);

DiffUtil

DiffUtil 使用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public abstract static class Callback {

public abstract int getOldListSize();

public abstract int getNewListSize();

public abstract boolean areItemsTheSame(int var1, int var2);

public abstract boolean areContentsTheSame(int var1, int var2);

@Nullable
public Object getChangePayload(int oldItemPosition, int newItemPosition) {
return null;
}
}

       这是给系统用于计算 Diff 的 Callback,以 User 为例,areItemsTheSame 指的是在逻辑上是否是同一个人,比如 ID 相同就是同一个人,即同一个 Item;areContentsTheSame 指两个 Item 的内容是否相同,只有在同一个人的情况下,再比较其中的内容是否相同。比如两个 User 是同一个人,但新的列表里面的 User 年龄修改了,返回 false;如果 areContentsTheSame 返回 false,同一个人但其中的一些属性不一样了,在 getChangePayload 方法中判断究竟是哪里发生了变化。

       DiffUtil.Callback 逻辑如下图所示:

DiffUtil.Callback 逻辑图

       java 示例代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
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) {
return oldList.get(oldItemPosition).id == newList.get(newItemPosition).id;
}
@Override
public boolean areContentsTheSame(int oldItemPosition, int newItemPosition) {
User oldUser = oldList.get(oldItemPosition);
User newUser = newList.get(newItemPosition);
return oldUser.id == newUser.id &&
oldUser.name.equals(newUser.name) &&
oldUser.profession.equals(newUser.profession);
}
}

       areContentsTheSame 中比较对象具体的内容,其实可以使用复写 equals 来简化代码,类似 oldUser.equals(newUser)。

       getChangePayload 方法如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class UserDiffCallBack extends DiffUtil.Callback {
...
private List<User> oldList;
private List<User> newList;
@Override
public Object getChangePayload(int oldItemPosition, int newItemPosition) {
User oldUser = oldList.get(oldItemPosition);
User newUser = newList.get(newItemPosition);
Bundle payload = new Bundle();
if (!oldUser.name.equals(newUser.name)) {
payload.putString(User.KEY_NAME, newUser.name);
}
if (!oldUser.profession.equals(newUser.profession)) {
payload.putString(User.KEY_PROF, newUser.profession);
}
if (payload.size() == 0) return null;
return payload;
}
}

       getChangePayload 方法并非必须实现的,如果不实现,就不会有 Item 内部的增量更新了,对于系统来说,就会复用整个 Item;如果需要实现该方法,如上代码对比两个 User 的差异,放入 Bundle 内,Bundle 不是必须的,此处因为 Bundle 是 key-value 的结构,所以使用它保存。

       Adapter 中使用 DiffUtil 的刷新方法如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class ShowcaseRVAdapter extends RecyclerView.Adapter<ShowcaseRVAdapter.UserViewHolder> {
private List<User> userList;

public ShowcaseRVAdapter(List<User> userList) {
this.userList = userList;
}

public void swapData(List<User> newList, boolean diff) {
if (diff) {
DiffUtil.DiffResult diffResult =
DiffUtil.calculateDiff(new UserDiffCallBack(userList, newList), false);
userList = newList;
diffResult.dispatchUpdatesTo(this);
} else {
userList = newList;
notifyDataSetChanged();
}
}
}

       两个参数是做完整绑定的方法,即全量更新,Item User里面的所有属性都需要绑定一次,而三个参数是做局部绑定的(以 name 为例),做增量更新,如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class ShowcaseRVAdapter extends RecyclerView.Adapter<ShowcaseRVAdapter.UserViewHolder> {
@Override
public void onBindViewHolder(UserViewHolder holder, int position) {
User user = userList.get(position);
holder.name.setText(user.name);
}

@Override
public void onBindViewHolder(UserViewHolder holder, int position, List<Object> payloads) {
if (payloads.isEmpty()) {
onBindViewHolder(holder, position);
} else {
Bundle payload = (Bundle) payloads.get(0);
for (String key : payload.keySet()) {
switch (key) {
case User.KEY_NAME:
holder.name.setText(payload.getString(key));
break;
}
}
}
}
}

在列表很大的时候异步计算 diff

  • 使用 Thread/Handler 将 DiffResult 发送到主线程
  • 使用 RxJava 将 calculateDiff 操作放到后台线程
  • 使用 Google 提供的 AsyncListDiffer(Executor)/ListAdapter(这个 ListAdapter 是新的 RecyclerView 包下的)

参考资料:
腾讯课堂 HenCoder
泡在网上的日子 RecyclerView的新机制:预取(Prefetch)

Fork me on GitHub