0%

Android-RecyclerView笔记

概述

RecyclerView 作为替代 ListView 的组件,得益于 RecyclerView 的灵活性和可定制程度高的特性,除此之外 RecyclerView 的预取机制以及缓存机制也是一大亮点。相关类:

  • LayoutManager: 布局管理器,用来决定 View 如何填充 RecyclerView, 接管 RecyclerView 的 Measure, Layout, Draw 过程。
  • RecyclerView.Adapter: 负责提供 View 所需要的数据集以及管理 View 的创建和数据绑定。
  • ViewHolder: View 持有者,负责 View 的初始化。
  • Recycler: 缓存逻辑。

首先看一下 RecyclerView 的基础使用:

1. 依赖: androidx.recyclerview:recyclerview:1.1.0

2. 设置LayoutManager

1
2
3
4
mRecyclerView = findViewById(R.id.recycle_view);
LinearLayoutManager layoutManager = new LinearLayoutManager(this)
layoutManager.setOrientation(LinearLayoutManager.VERTICAL);
mRecyclerView.setLayoutManager(layoutManager);

3. 自定义Adapter

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
29
30
31
32
33
mRecyclerView.setAdapter(adapter);

public class MyAdapter extends RecyclerView.Adapter<MyAdapter.ViewHolder>{
public String[] datas = null;

public MyAdapter(String[] datas) {
this.datas = datas;
}

@Override
public ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
View view = LayoutInflater.from(parent.getContext()).inflate(R.layout.item, parent, false);
return new ViewHolder(view);
}

@Override
public void onBindViewHolder(ViewHolder holder, int position) {
holder.mTextView.setText(datas[position]);
}

@Override
public int getItemCount() {
return datas.length;
}

public static class ViewHolder extends RecyclerView.ViewHolder {
public TextView mTextView;
public ViewHolder(View view){
super(view);
mTextView = (TextView) view.findViewById(R.id.text);
}
}
}

4. ItemDecoration

RecyclerView 可以通过 addItemDecoration() 设置分割线,可以通过继承 RecyclerView.ItemDecoration 类自定义分割线。

5. ItemAnimator

RecyclerView 可以通过 setItemAnimator(ItemAnimator animator) 来设置添加和移除时的动画效果,需要注意更新数据集时要用 notifyItemInserted 与 notifyItemRemoved, 不能使用 notifyDataSetChanged, 否则没有动画效果。

缓存工作流程

  • 列表首次填充时,它会创建并在列表的任意一侧绑定一些视图持有者。例如,如果视图显示的是列表位置 0 到 9,则 RecyclerView 会创建并绑定这些视图持有者,还可能创建并绑定位置 10 的视图持有者。这样,如果用户滚动列表,则下一个元素可随时显示出来。
  • 当用户滚动列表时,RecyclerView 会根据需要创建新的视图持有者。它还会保存已在屏幕外滚动的视图持有者,以便重复使用。如果用户切换滚动方向,则之前在屏幕外滚动的视图持有者可以立即恢复。另一方面,如果用户继续沿同一方向滚动,屏幕外停留时间最长的视图持有者可以重新绑定到新数据。无需创建视图持有者,也不需要扩充其视图;应用只更新视图的内容以匹配其绑定到的新项。

缓存原理

数据结构

在 RecyclerView.Recycler 类中定义了缓存相关的数据结构:

1
2
3
4
5
6
7
8
9
10
11
public final class Recycler {
// Scrap缓存
final ArrayList<ViewHolder> mAttachedScrap = new ArrayList<>();
ArrayList<ViewHolder> mChangedScrap = null;
// Cache缓存
final ArrayList<ViewHolder> mCachedViews = new ArrayList<ViewHolder>();
// 缓存池
RecycledViewPool mRecyclerPool;
// 自定义缓存
private ViewCacheExtension mViewCacheExtension;
}

这里大概可以分为 4 种类别:

  • Scrap 缓存:对应当前已加载的视图,也就是屏幕中的视图;
  • Cache 缓存:刚刚移出屏幕的Item,默认大小是2个,缓存中的Item匹配后可以直接展示,主要用于解决RecyclerView滑动抖动时的情况,还有用于保存Prefetch的ViewHoder。根据FIFO原则先把老的数据放入下一级缓存中(如RecycledViewPool),然后添加新数据;
  • ViewCacheExtension 缓存:用来给开发者自定义的缓存,通过type和position来查找缓存,开发者可通过实现 getViewForPositionAndType 方法来自定义;
  • RecycledViewPool 缓存:默认的缓存数量是5个,优先级没有Cache缓存高,同时缓存中的Item需要再次调用onBindViewHolder()方法;

入缓存

缓存机制是从滑动开始的,在滑动的时候会触发 View 的绘制流程。RecyclerView 的绘制是委托给 LayoutManager 的,在 LinearLayoutManager 中,可以找到相关的代码:

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
29
30
31
@Override
public void onLayoutChildren(RecyclerView.Recycler recycler, RecyclerView.State state) {
// ...
// 处理 Scrap 缓存以及 detach 的 ViewHolder
detachAndScrapAttachedViews(recycler);
// ...
}

// 暂时 detach 并 scrap(废弃) 所有当前 attached 的子 View, View 将被废弃到给定的 Recycler 中
// Recycler 可能更喜欢在其他先前回收的 view 之前重用 scrap views
public void detachAndScrapAttachedViews(@NonNull Recycler recycler) {
final int childCount = getChildCount();
for (int i = childCount - 1; i >= 0; i--) {
final View v = getChildAt(i);
scrapOrRecycleView(recycler, i, v);
}
}

private void scrapOrRecycleView(Recycler recycler, int index, View view) {
final ViewHolder viewHolder = getChildViewHolderInt(view);
if (viewHolder.isInvalid() && !viewHolder.isRemoved() && !mRecyclerView.mAdapter.hasStableIds()) {
// 处理屏幕外的item
removeViewAt(index);
recycler.recycleViewHolderInternal(viewHolder);
} else {
// 处理在屏幕中的item
detachViewAt(index);
recycler.scrapView(view);
mRecyclerView.mViewInfoStore.onViewDetached(viewHolder);
}
}

在 scrapOrRecycleView() 方法中有两个逻辑分支,分别处理在屏幕中的 Item 以及需要回收的 Item, 先看如何处理屏幕中的Item:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// Mark an attached view as scrap.
void scrapView(View view) {
final ViewHolder holder = getChildViewHolderInt(view);
if (holder.hasAnyOfTheFlags(ViewHolder.FLAG_REMOVED | ViewHolder.FLAG_INVALID)
|| !holder.isUpdated() || canReuseUpdatedViewHolder(holder)) {
// 没有更新 或者 可以被复用(传入了payload)
holder.setScrapContainer(this, false);
mAttachedScrap.add(holder);
} else {
// 需要更新 放到 mChangedScrap 中
if (mChangedScrap == null) {
mChangedScrap = new ArrayList<ViewHolder>();
}
holder.setScrapContainer(this, true);
mChangedScrap.add(holder);
}
}

这里主要处理的就是将ViewHolder加入Scrap缓存中。上面canReuseUpdatedViewHolder方法中,如果在局部更新方法里传入的 payload 不为空,则返回true接下来分析需要回收的情况:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
void recycleViewHolderInternal(ViewHolder holder) {
// ...
if (forceRecycle || holder.isRecyclable()) {
if (mViewCacheMax > 0 && !holder.hasAnyOfTheFlags(ViewHolder.FLAG_INVALID
| ViewHolder.FLAG_REMOVED | ViewHolder.FLAG_UPDATE | ViewHolder.FLAG_ADAPTER_POSITION_UNKNOWN)) {
// Retire(退役) oldest cached view
int cachedViewSize = mCachedViews.size();
if (cachedViewSize >= mViewCacheMax && cachedViewSize > 0) {
recycleCachedViewAt(0);
cachedViewSize--;
}

int targetCacheIndex = cachedViewSize;
// ...
mCachedViews.add(targetCacheIndex, holder);
cached = true;
}
if (!cached) {
addViewHolderToRecycledViewPool(holder, true);
recycled = true;
}
}
// ...
}

recycleViewHolderInternal() 方法中主要是对 Cache 缓存和 RecycledViewPool 缓存进行处理。判断依据主要是根据 ViewHolde 的标志位进行判断:

  • 对于 Cache 缓存:缓存池满了以后就移出第1个然后加入新的缓存数据。
  • 对于 RecycledViewPool 缓存:主要根据ViewHolder的标志位进行判断(比如ViewHolder有FLAG_REMOVED标志)就会加入到RecycledViewPool缓存。

使用缓存

上面主要分析了 RecyclerView 中缓存数据的来源,接下来继续分析如何使用缓存。按照之前的思路,在 LayoutManager 执行 onLayoutChildren() 方法时会获取新的 Item, 此时就会触发 RecyclerView 的缓存机制:

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
29
30
31
32
33
@Override
public void onLayoutChildren(RecyclerView.Recycler recycler, RecyclerView.State state) {
// ...
// 处理 Scrap 缓存以及 detach 的 ViewHolder
detachAndScrapAttachedViews(recycler);
// ...
fill(recycler, mLayoutState, state, false);
// ...
}

int fill(RecyclerView.Recycler recycler, LayoutState layoutState, RecyclerView.State state, boolean stopOnFocusable) {
// ...
while ((layoutState.mInfinite || remainingSpace > 0) && layoutState.hasMore(state)) {
// ...
layoutChunk(recycler, state, layoutState, layoutChunkResult);
// ...
}
// ...
}

void layoutChunk(RecyclerView.Recycler recycler, RecyclerView.State state, LayoutState layoutState, LayoutChunkResult result) {
View view = layoutState.next(recycler);
// ...
}

View next(RecyclerView.Recycler recycler) {
if (mScrapList != null) {
return nextViewFromScrapList();
}
final View view = recycler.getViewForPosition(mCurrentPosition);
mCurrentPosition += mItemDirection;
return view;
}

可以看到 LayoutManager 是通过 fill() 方法来填充布局的,在这个过程中最终会通过 Recycler 的 getViewForPosition() 方法获取到需要添加的View。接下来就从 Recycler 中进行分析,而 Recycler.getViewForPosition() 最终会调用到 Recycler.tryGetViewHolderForPositionByDeadline() 方法:

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
29
30
31
32
33
34
35
36
37
38
39
40
41
42
ViewHolder tryGetViewHolderForPositionByDeadline(int position, boolean dryRun, long deadlineNs) {
boolean fromScrapOrHiddenOrCache = false;
ViewHolder holder = null;
// 0) If there is a changed scrap, try to find from there
if (mState.isPreLayout()) {
holder = getChangedScrapViewForPosition(position);
fromScrapOrHiddenOrCache = holder != null;
}
// 1) Find by position from scrap/hidden list/cache
if (holder == null) {
// 1) 获取ViewHolder(从Scrap或者Cache中)
holder = getScrapOrHiddenOrCachedHolderForPosition(position, dryRun);
// ...
}
if (holder == null) {
final int offsetPosition = mAdapterHelper.findPositionOffset(position);
final int type = mAdapter.getItemViewType(offsetPosition);
// 2) Find from scrap/cache via stable ids, if exists
if (mAdapter.hasStableIds()) {
// 2) 获取ViewHolder(从Scrap或者Cache中)
holder = getScrapOrCachedViewForId(mAdapter.getItemId(offsetPosition), type, dryRun);
// ...
}
if (holder == null && mViewCacheExtension != null) {
// 3) 获取ViewHolder(从ViewCacheExtension中)
final View view = mViewCacheExtension.getViewForPositionAndType(this, position, type);
// ...
}
if (holder == null) {
// 4) 获取ViewHolder(从RecycledViewPool中)
holder = getRecycledViewPool().getRecycledView(type);
// ...
}
if (holder == null) {
// 5) 新建ViewHolder
holder = mAdapter.createViewHolder(RecyclerView.this, type);
// ...
}
}
// ...
return holder;
}

从上面代码中可以看到获取 ViewHolder 的过程总共有5步:

  • 一级缓存:从 Scrap 或者 Cache 中返回布局和内容都有效的ViewHolder,按照position或者id进行匹配,命中一级缓存无需onCreateViewHolder和onBindViewHolder;
  • 二级缓存:从 ViewCacheExtension 缓存中取,也就是用户自定义的那部分缓存;
  • 三级缓存:返回布局有效,内容无效的ViewHolder。在 RecycledViewPool 中获取,这里是通过 ViewType 来查找 ViewHolder 的,如果 ViewType 匹配就会返回 ViewHolder,这个时候会再次执行 onBindViewHolder() 方法;
  • 如果上面的缓存中都没有则新建 ViewHolder, 会执行 createViewHolder 方法。

View复用错乱

RecyclerView 复用错乱的原因是取了之前的 View 来放到新 item 上,之前 View 的状态一直保留着,所以就出现错乱了。根据数据的来源可分为两种情况:

同步数据源

保证在 onBindViewHolder 方法调用的时候,根据给定位置的 item 的状态给 View 赋值。

异步数据源

当数据来源于网络时,举个例子:假设 item 中有一个 ImageView, 每次调用 onBindViewHolder 的时候都给 ImageView 一个 url, 然后等待服务器的结果,加载图片。此时若滑动屏幕,使得这个 item 不可见了,新的可见的 item 复用了它,这样当网络请求结束后,展示的图片就是之前的了。

解决方法:可以在 onBindViewHolder 中对每个 item 设置一个 tag, 其值为 url, 当网络请求返回的时候,比较一下 url 是不是跟 tag 对应,对应上了才显示图片。或者 item 不可见了就取消服务器的请求,新的 item 再发起新的请求。

实例解析

出屏幕时候的情况-mCacheViews未满

cacheViews未满

  1. 当ViewHolder(position=0,type=1)出屏幕的时候,由于mCacheViews是空的,那么就直接放在mCacheViews里面(从0-N是由老到新)。此时ViewHolder在mCacheViews里面布局和内容都是有效的,因此可以直接复用。
  2. ViewHolder(position=1,type=2)同步骤1。

出屏幕时候的情况-mCacheViews已经满

cacheViews已满

  1. 当ViewHolder(position=2,type=1)出屏幕的时候由于一级缓存mCacheViews已经满了,因此然后移除mCacheViews里面最老的ViewHolder(position=0,type=1)到RecyclePool中,然后将ViewHolder(position=2,type=1)存入mCacheViews。此时被移除到RecyclePool的ViewHolder的内容会被标记为无效,当其复用的时候需要再次通过Adapter.bindViewHolder来绑定内容。
  2. ViewHolder(position=3,type=3)同步骤3。

进屏幕时候的情况

进屏幕时候的情况

  1. 当ViewHolder(position=3,type=3)进入屏幕绘制的时候,由于Recycler的mCacheViews里面找不到position匹配的View,同时RecyclerPool里面找不到type匹配的View,因此,其只能通过adapter.createViewHolder来创建ViewHolder,然后通过adapter.bindViewHolder来绑定内容。
  2. 当ViewHolder(position=11,type=1)进入屏幕的时候,发现RecyclerPool里面能找到type=1的缓存,因此直接从RecyclerPool里面取来使用。由于内容是无效的,因此还需要调用bindViewHolder来绑定布局。同时ViewHolder(position=4,type=3)需要出屏幕,会经历步骤3回收的过程。
  3. ViewHolder(position=12,type=3)同步骤6。

屏幕往下拉ViewHoder(position=1)进入屏幕的情况

屏幕往下拉进入屏幕的情况

  1. 由于mCacheView里面的有position=1的ViewHolder与之匹配,直接返回。由于内容是有效的,因此无需再次绑定内容。
  2. ViewHolder(position=0)同步骤8。

局部更新

在 RecyclerView 中有一系列局部更新的方法,这些方法带有默认的动画效果,具体效果是 DefaultItemAnimator 来控制和处理,就是因为动画效果,让开发时会出现一些意料之外的状况。

假设一个场景:上传照片,每个 ViewHolder 带有一张图片,然后上面有一个进度条,进度根据上传进度实时返回。如果使用 notifyItemChange() 来更新进度的话,会有两个问题:第一,发现每刷新一次,整个布局都会闪动一下。第二,进度的数值怎么传递才好呢?在 ViewHolder 的 Bean 对象中添加一个 progress 临时字段?另外,如果我们多次调用 notifyItemChange() 方法,条目会刷新多次吗?以及 notifyItemChange() 和 真正的局部刷新 同一个位置,ViewHolder 是同一个对象吗?

Adapter.notifyItemRangeChanged 方法会调用到 requestLayout() 方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public void notifyItemRangeChanged(int positionStart, int itemCount, @Nullable Object payload) {
for (int i = mObservers.size() - 1; i >= 0; i--) {
mObservers.get(i).onItemRangeChanged(positionStart, itemCount, payload);
}
}

// RecyclerViewDataObserver
@Override
public void onItemRangeChanged(int positionStart, int itemCount, Object payload) {
assertNotInLayoutOrScroll(null);
if (mAdapterHelper.onItemRangeChanged(positionStart, itemCount, payload)) {
triggerUpdateProcessor();
}
}

void triggerUpdateProcessor() {
if (POST_UPDATES_ON_ANIMATION && mHasFixedSize && mIsAttached) {
ViewCompat.postOnAnimation(RecyclerView.this, mUpdateChildViewsRunnable);
} else {
mAdapterUpdateDuringMeasure = true;
requestLayout();
}
}

这里当 mAdapterHelper.onItemRangeChanged 返回 true 才会接着往下走:

1
2
3
4
5
6
7
8
boolean onItemRangeChanged(int positionStart, int itemCount, Object payload) {
if (itemCount < 1) {
return false;
}
mPendingUpdates.add(obtainUpdateOp(UpdateOp.UPDATE, positionStart, itemCount, payload));
mExistingUpdateTypes |= UpdateOp.UPDATE;
return mPendingUpdates.size() == 1;
}

payload 参数在这里被包装为对象,放入 mPendingUpdates 这个集合中。因此可以证明即使调用 notify 多次,其实只有会触发一次requestLayout

接着看 RecyclerView.onLayout 方法,它直接调用了 dispatchLayout 方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
void dispatchLayout() {
mState.mIsMeasuring = false;
if (mState.mLayoutStep == State.STEP_START) {
dispatchLayoutStep1();
mLayout.setExactMeasureSpecsFrom(this);
dispatchLayoutStep2();
} else if (mAdapterHelper.hasUpdates() || mLayout.getWidth() != getWidth() || mLayout.getHeight() != getHeight()) {
mLayout.setExactMeasureSpecsFrom(this);
dispatchLayoutStep2();
} else {
// always make sure we sync them (to ensure mode is exact)
mLayout.setExactMeasureSpecsFrom(this);
}
dispatchLayoutStep3();
}

这里涉及到三个方法,直接说结论:

  • dispatchLayoutStep1: 在这里会执行上面介绍过的缓存机制。
  • dispatchLayoutStep2: 此时将 mInPreLayout 置为 false, 然后执行 LayoutManager 的 onLayoutChildren() 方法,所以不会执行 tryGetViewHolderForPositionByDeadline 中的第 0 步,不会从 mChangedScrap 中查找 ViewHolder。由于如果不需要更新或在局部更新方法里传入的 payload 不为空则 ViewHolder 会加到 mAttachedScrap 中,需要更新的且未传入 payload 则会放到 mChangedScrap 中,所以如果没传入 payload 则旧的 ViewHolder 无法被复用,它会从下面步骤中获取 ViewHolder 返回,刷新时它使用的不是同一个 ViewHolder;如果传入了 payload 则会复用。
  • dispatchLayoutStep3: 保存 View 信息,执行动画。如果 position 处的 oldHolder 和 newHolder 不同,则会执行相关动画效果(闪烁现象)。

总结

总结来说 RecyclerView 的缓存都是发生在 Layout 过程中,在这里对缓存的数据进行管理,包括缓存的增加、删除、查找等行为。

RecyclerView性能优化方向总结:

性能优化

一个 RecyclerView 的 item 往往由许多控件组成,当需要更新 item 中的某个状态时,如果调用 notifyItemChanged(int position) 则 item 中的每个控件都需要重新布局显示,无形中加大了内存和性能的损耗。其实就是使用了一个与之前不一样的 ViewHolder 对象。最常见的坑就是点击 item 的一个按钮,却引起同 item 的 ImageView 图片闪烁一下,可以使用 payload 参数(用来传递状态等)解决这个问题,此时更新时会复用之前的 ViewHolder 对象。

而如果需要更新整个 item 则可以不传 payload 参数。另外,多次调用 notifyItemChange() 方法,不会刷新多次,而是会合并。

如果调用了 notifyDataSetChanged 方法,即使数据源没有变化,默认只会有两个 item 重新走 onBindViewHolder 流程(mCachedViews),其余 item 会走 onCreateViewHolder 和 onBindViewHolder 流程。