我们为什么要使用RecyclerView

前两天看到葱花同学的文章RecyclerView 和 ListView 使用对比分析,加上之前在公司做code reiview的时候也遇到过RecyclerView和ListView比较的问题,所以想在这里写一篇文章。

这篇文章的重点不会放在两者使用上的不同,因为前面提到的RecyclerView 和 ListView 使用对比分析这篇文章已经写的非常完美了,我就不重复造轮子了,主要想说明的问题和标题一样,就是我们开发者[为什么]要使用RecyclerView去代替ListView。

前言

说起RecyclerView,大家的第一反应就是它是一个威力加强版的ListView,所以在很多时候,我们会对其造成一定的误解——什么?界面顿卡了?上RecyclerView!什么?组件出bug了?上RecyclerView!等等等等…….

首先我们还是先看看Google官方是如何定义RecyclerView的。

The RecyclerView widget is a more advanced and flexible version of ListView. This widget is a container for displaying large data sets that can be scrolled very efficiently by maintaining a limited number of views. Use the RecyclerView widget when you have data collections whose elements change at runtime based on user action or network events.

这段解释意境很清楚,我们确实可以把它看作一个[advanced]的ListView,但是这里我想说的是,千万不要把RecyclerView看成能和ListView[等价替换]的一个组件,更不要把它看做是拯救你滑动组件的救星。

使用过RecyclerView的同学都知道,这个组件是[比较难用]的,为什么这么说呢,我们来想想原来使用ListView的时候,好像一切都已经封装好了,一个api就能添加header和footer,点击事件有对应的listener等等。而RecyclerView好像什么都没有,没有点击事件,没有header和footer,甚至连最起码的divider都没有,什么都要自己写,用起来简直让人抓狂,好像它唯一的优点就是那个无法从代码中体现的[性能]了。

其实并不是这样的,这篇文章也是想让阐述这样的一个观点,让大家知道Google之所以这样做是有他们这样做的道理的。

首先,让我们从性能这一方面入手,看看两个组件在它们各自[回收机制]上有什么区别。

收回机制比较

首先先看ListView的,想要搞清楚回收机制,我们当然要去看它的onTouchEvent中对应的ACTION_MOVE方法。

ListView继承自AbsListView,所以其滑动逻辑会在AbsListView中处理,最终会走到trackMotionScroll这个方法。

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
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
boolean trackMotionScroll(int deltaY, int incrementalDeltaY) {
final int childCount = getChildCount();
if (childCount == 0) {
return true;
}

final int firstTop = getChildAt(0).getTop();
final int lastBottom = getChildAt(childCount - 1).getBottom();

final Rect listPadding = mListPadding;

// "effective padding" In this case is the amount of padding that affects
// how much space should not be filled by items. If we don't clip to padding
// there is no effective padding.
int effectivePaddingTop = 0;
int effectivePaddingBottom = 0;
if ((mGroupFlags & CLIP_TO_PADDING_MASK) == CLIP_TO_PADDING_MASK) {
effectivePaddingTop = listPadding.top;
effectivePaddingBottom = listPadding.bottom;
}

// FIXME account for grid vertical spacing too?
final int spaceAbove = effectivePaddingTop - firstTop;
final int end = getHeight() - effectivePaddingBottom;
final int spaceBelow = lastBottom - end;

final int height = getHeight() - mPaddingBottom - mPaddingTop;
if (deltaY < 0) {
deltaY = Math.max(-(height - 1), deltaY);
} else {
deltaY = Math.min(height - 1, deltaY);
}

if (incrementalDeltaY < 0) {
incrementalDeltaY = Math.max(-(height - 1), incrementalDeltaY);
} else {
incrementalDeltaY = Math.min(height - 1, incrementalDeltaY);
}

final int firstPosition = mFirstPosition;

// Update our guesses for where the first and last views are
if (firstPosition == 0) {
mFirstPositionDistanceGuess = firstTop - listPadding.top;
} else {
mFirstPositionDistanceGuess += incrementalDeltaY;
}
if (firstPosition + childCount == mItemCount) {
mLastPositionDistanceGuess = lastBottom + listPadding.bottom;
} else {
mLastPositionDistanceGuess += incrementalDeltaY;
}

final boolean cannotScrollDown = (firstPosition == 0 &&
firstTop >= listPadding.top && incrementalDeltaY >= 0);
final boolean cannotScrollUp = (firstPosition + childCount == mItemCount &&
lastBottom <= getHeight() - listPadding.bottom && incrementalDeltaY <= 0);

if (cannotScrollDown || cannotScrollUp) {
return incrementalDeltaY != 0;
}

final boolean down = incrementalDeltaY < 0;

final boolean inTouchMode = isInTouchMode();
if (inTouchMode) {
hideSelector();
}

final int headerViewsCount = getHeaderViewsCount();
final int footerViewsStart = mItemCount - getFooterViewsCount();

int start = 0;
int count = 0;

if (down) {
int top = -incrementalDeltaY;
if ((mGroupFlags & CLIP_TO_PADDING_MASK) == CLIP_TO_PADDING_MASK) {
top += listPadding.top;
}
for (int i = 0; i < childCount; i++) {
final View child = getChildAt(i);
if (child.getBottom() >= top) {
break;
} else {
count++;
int position = firstPosition + i;
if (position >= headerViewsCount && position < footerViewsStart) {
// The view will be rebound to new data, clear any
// system-managed transient state.
child.clearAccessibilityFocus();
mRecycler.addScrapView(child, position);
}
}
}
} else {
int bottom = getHeight() - incrementalDeltaY;
if ((mGroupFlags & CLIP_TO_PADDING_MASK) == CLIP_TO_PADDING_MASK) {
bottom -= listPadding.bottom;
}
for (int i = childCount - 1; i >= 0; i--) {
final View child = getChildAt(i);
if (child.getTop() <= bottom) {
break;
} else {
start = i;
count++;
int position = firstPosition + i;
if (position >= headerViewsCount && position < footerViewsStart) {
// The view will be rebound to new data, clear any
// system-managed transient state.
child.clearAccessibilityFocus();
mRecycler.addScrapView(child, position);
}
}
}
}

mMotionViewNewTop = mMotionViewOriginalTop + deltaY;

mBlockLayoutRequests = true;

if (count > 0) {
detachViewsFromParent(start, count);
mRecycler.removeSkippedScrap();
}

// invalidate before moving the children to avoid unnecessary invalidate
// calls to bubble up from the children all the way to the top
if (!awakenScrollBars()) {
invalidate();
}

offsetChildrenTopAndBottom(incrementalDeltaY);

if (down) {
mFirstPosition += count;
}

final int absIncrementalDeltaY = Math.abs(incrementalDeltaY);
if (spaceAbove < absIncrementalDeltaY || spaceBelow < absIncrementalDeltaY) {
fillGap(down);
}

if (!inTouchMode && mSelectedPosition != INVALID_POSITION) {
final int childIndex = mSelectedPosition - mFirstPosition;
if (childIndex >= 0 && childIndex < getChildCount()) {
positionSelector(mSelectedPosition, getChildAt(childIndex));
}
} else if (mSelectorPosition != INVALID_POSITION) {
final int childIndex = mSelectorPosition - mFirstPosition;
if (childIndex >= 0 && childIndex < getChildCount()) {
positionSelector(INVALID_POSITION, getChildAt(childIndex));
}
} else {
mSelectorRect.setEmpty();
}

mBlockLayoutRequests = false;

invokeOnItemScrollListener();

return false;
}

逻辑非常的多,我们看主要的。这个方法的两个入参scrollY表示从开始滑动的时候到当前滑动的y轴的距离,incrementalDeltaY表示滑动时y轴的增量值,这个值可以通过判断正负来确定是向上滑还是向下滑。这里我们以一个方向为准,如果整个滑动是向下滑的,也就是局部变量down为true,那么在这种情况下,如果child.getBottom() < top,也就是说bottom值小于top值,那么说明在向下滑动这种场景下,这个child已经滑出屏幕不在显示范围内了,于是,AbsListView就调用了mRecycler.addScrapView(child, position);这个函数将其加入到了mRecycler中。这里有一个很重要的点,就是mRecycler,它也是AbsListView和它的子类(ListView GridView等等)能正常运作最主要原因。

1
final RecycleBin mRecycler = new RecycleBin();

可以看到它其实是一个RecycleBin对象。

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
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
class RecycleBin {

........
private ArrayList<View> mCurrentScrap;
private ArrayList<View>[] mScrapViews;
........

void addScrapView(View scrap, int position) {
final AbsListView.LayoutParams lp = (AbsListView.LayoutParams) scrap.getLayoutParams();
if (lp == null) {
return;
}

lp.scrappedFromPosition = position;

// Remove but don't scrap header or footer views, or views that
// should otherwise not be recycled.
final int viewType = lp.viewType;
if (!shouldRecycleViewType(viewType)) {
return;
}

scrap.dispatchStartTemporaryDetach();

// The the accessibility state of the view may change while temporary
// detached and we do not allow detached views to fire accessibility
// events. So we are announcing that the subtree changed giving a chance
// to clients holding on to a view in this subtree to refresh it.
notifyViewAccessibilityStateChangedIfNeeded(
AccessibilityEvent.CONTENT_CHANGE_TYPE_SUBTREE);

// Don't scrap views that have transient state.
final boolean scrapHasTransientState = scrap.hasTransientState();
if (scrapHasTransientState) {
if (mAdapter != null && mAdapterHasStableIds) {
// If the adapter has stable IDs, we can reuse the view for
// the same data.
if (mTransientStateViewsById == null) {
mTransientStateViewsById = new LongSparseArray<View>();
}
mTransientStateViewsById.put(lp.itemId, scrap);
} else if (!mDataChanged) {
// If the data hasn't changed, we can reuse the views at
// their old positions.
if (mTransientStateViews == null) {
mTransientStateViews = new SparseArray<View>();
}
mTransientStateViews.put(position, scrap);
} else {
// Otherwise, we'll have to remove the view and start over.
if (mSkippedScrap == null) {
mSkippedScrap = new ArrayList<View>();
}
mSkippedScrap.add(scrap);
}
} else {
if (mViewTypeCount == 1) {
mCurrentScrap.add(scrap);
} else {
mScrapViews[viewType].add(scrap);
}

if (mRecyclerListener != null) {
mRecyclerListener.onMovedToScrapHeap(scrap);
}
}
}
}

可以看到刚刚说起的那个addScrapView方法,其中有一段方法:

1
2
3
4
5
if (mViewTypeCount == 1) {
mCurrentScrap.add(scrap);
} else {
mScrapViews[viewType].add(scrap);
}

根据ViewTypeCount去添加已经废弃的View。从这个方法就可以说明,ListView的回收机制是和ViewType有关的,每一个ViewType对应一个对应的废弃list。

现在我们可以知道,在AsbListView中,如果一个View已经滑出了屏幕,那么说明它已经被废弃(scrap)了,就会被添加到RecycleBin中(transient的View除外,这个忽略)。那么我们什么时候去取出这里面存储的View呢?让我们回到onTouchEvent函数。

我们可以看到在trackMotionScroll函数中有一个fillGap方法。

1
abstract void fillGap(boolean down);

这是一个抽象方法,这是合理的,因为AbsListView的子类显示情况都不一样,ListView有ListView的显示,GridView有GridView的显示,所以需要它们自己去判断。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
void fillGap(boolean down) {
final int count = getChildCount();
if (down) {
int paddingTop = 0;
if ((mGroupFlags & CLIP_TO_PADDING_MASK) == CLIP_TO_PADDING_MASK) {
paddingTop = getListPaddingTop();
}
final int startOffset = count > 0 ? getChildAt(count - 1).getBottom() + mDividerHeight :
paddingTop;
fillDown(mFirstPosition + count, startOffset);
correctTooHigh(getChildCount());
} else {
int paddingBottom = 0;
if ((mGroupFlags & CLIP_TO_PADDING_MASK) == CLIP_TO_PADDING_MASK) {
paddingBottom = getListPaddingBottom();
}
final int startOffset = count > 0 ? getChildAt(0).getTop() - mDividerHeight :
getHeight() - paddingBottom;
fillUp(mFirstPosition - 1, startOffset);
correctTooLow(getChildCount());
}
}

在这个函数中,通过down这个参数判断滑动方向,如果向下滑,则调用fillDown方法,否则调用fillUp方法。而在这两个方法中,都会去调用obtainView方法从对应的RecycleBin中取出scrapView填充到AbsListView中。

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
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
View obtainView(int position, boolean[] isScrap) {
Trace.traceBegin(Trace.TRACE_TAG_VIEW, "obtainView");

isScrap[0] = false;

// Check whether we have a transient state view. Attempt to re-bind the
// data and discard the view if we fail.
final View transientView = mRecycler.getTransientStateView(position);
if (transientView != null) {
final LayoutParams params = (LayoutParams) transientView.getLayoutParams();

// If the view type hasn't changed, attempt to re-bind the data.
if (params.viewType == mAdapter.getItemViewType(position)) {
final View updatedView = mAdapter.getView(position, transientView, this);

// If we failed to re-bind the data, scrap the obtained view.
if (updatedView != transientView) {
setItemViewLayoutParams(updatedView, position);
mRecycler.addScrapView(updatedView, position);
}
}

// Scrap view implies temporary detachment.
isScrap[0] = true;
return transientView;
}

final View scrapView = mRecycler.getScrapView(position);
final View child = mAdapter.getView(position, scrapView, this);
if (scrapView != null) {
if (child != scrapView) {
// Failed to re-bind the data, return scrap to the heap.
mRecycler.addScrapView(scrapView, position);
} else {
isScrap[0] = true;

child.dispatchFinishTemporaryDetach();
}
}

if (mCacheColorHint != 0) {
child.setDrawingCacheBackgroundColor(mCacheColorHint);
}

if (child.getImportantForAccessibility() == IMPORTANT_FOR_ACCESSIBILITY_AUTO) {
child.setImportantForAccessibility(IMPORTANT_FOR_ACCESSIBILITY_YES);
}

setItemViewLayoutParams(child, position);

if (AccessibilityManager.getInstance(mContext).isEnabled()) {
if (mAccessibilityDelegate == null) {
mAccessibilityDelegate = new ListItemAccessibilityDelegate();
}
if (child.getAccessibilityDelegate() == null) {
child.setAccessibilityDelegate(mAccessibilityDelegate);
}
}

Trace.traceEnd(Trace.TRACE_TAG_VIEW);

return child;
}

这下我们就清楚了,原来通过RecycleBin这样一个东西,就算有再多的item,AbsListView中永远只会存在那么几个,滑出屏幕的child回收,然后再重用,于是就尽可能的避免了oom的发生。

接下去,让我们看看RecyclerView是怎么做的,在RecyclerView的ACTION_MOVE方法中,实际会调用其内部的LayoutManager的scrollBy方法去完成滑动,这里我们看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
int scrollBy(int dy, RecyclerView.Recycler recycler, RecyclerView.State state) {
if (getChildCount() == 0 || dy == 0) {
return 0;
}
mLayoutState.mRecycle = true;
ensureLayoutState();
final int layoutDirection = dy > 0 ? LayoutState.LAYOUT_END : LayoutState.LAYOUT_START;
final int absDy = Math.abs(dy);
updateLayoutState(layoutDirection, absDy, true, state);
final int freeScroll = mLayoutState.mScrollingOffset;
final int consumed = freeScroll + fill(recycler, mLayoutState, state, false);
if (consumed < 0) {
if (DEBUG) {
Log.d(TAG, "Don't have any more elements to scroll");
}
return 0;
}
final int scrolled = absDy > consumed ? layoutDirection * consumed : dy;
mOrientationHelper.offsetChildren(-scrolled);
if (DEBUG) {
Log.d(TAG, "scroll req: " + dy + " scrolled: " + scrolled);
}
mLayoutState.mLastScrollDelta = scrolled;
return scrolled;
}

其中的fill方法最终会调用到layoutChunk方法,而在这个方法中,会去recycler中取废弃的View进行填充。说到这儿大家可以发现,其实RecyclerView和AbsListView的回收机制在大致框架上是一致的,就是通过一个类似Recycler的回收器去存储和获取废弃的View,下面让我们看看RecyclerView的回收器。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public final class Recycler {

..........

final ArrayList<ViewHolder> mAttachedScrap = new ArrayList<ViewHolder>();
private ArrayList<ViewHolder> mChangedScrap = null;
final ArrayList<ViewHolder> mCachedViews = new ArrayList<ViewHolder>();
private final List<ViewHolder> mUnmodifiableAttachedScrap = Collections.unmodifiableList(mAttachedScrap);
private int mViewCacheMax = DEFAULT_CACHE_SIZE;
private RecycledViewPool mRecyclerPool;
private ViewCacheExtension mViewCacheExtension;
private static final int DEFAULT_CACHE_SIZE = 2;

..........

可以看到在Recycler中存这么多的List,而在获取View的过程中,Recycler会先从scrap的List中获取,如果没有获取到就从cache中获取。另外还有ViewCacheExtension和RecycledViewPool这两个额外的选项需要开发者手动去设置。

从这里我们可以看出,RecyclerView提供了比AbsListView更加完善的回收机制,配以细节的优化和postOnAnimation方法所保证的”Android 16ms”机制,RecyclerView在滑动性能上确实会比AbsListView更出色。

但是虽然经过了优化,但是就像我前面说的RecyclerView并不是万能,对于一些非常复杂的item布局,一旦处理不好,RecyclerView所表现出来的性能也ListView是相差无几的,所以[使用RecyclerView代替ListView]并不仅仅应该体现在这里。

为什么要使用RV

在回答这个问题之前,让我们先思考另外一个问题,为什么在Google推出了RecyclerView并且经过了这么多个版本迭代之后,ListView没有被标明为deprecated呢?在ViewPager和HorizontalScrollView推出以后,Gallery就惨遭deprecated的命运,为毛ListView就没有呢?难道因为它是Google的亲儿子?

在我看来,RecyclerView虽然被标上了加强版AbsListView的标记,但是它们其实是两个不同的控件。AbsListView的子类们有着完善的功能,如果你的滑动组件只想简单的使用滑动显示这个功能,并且想轻松的使用divider,header,footer或者点击事件这些功能,那么使用AbsListView是完全没有问题的。

RecyclerView的重点应该放在[flexible]上,灵活是它[最大]的特点,由于AbsListView的功能完善,所以你想要定制它其实是很困难的,换句话说,AbsLisView已经强耦合了很多和[滑动,回收]无关的功能。这个时候RecyclerView的强大之处就显示出来了,LayoutManager,Adapter,ItemAnimator,ItemDecoration等等各司其职,这使得RecyclerView能够实现深度的定制化。系统提供的三种LayoutManager可以无缝衔接ListView和GridView,瀑布流的实现也变得无分简单。滑动删除和长按交换只需要添加几个类就可以实现。

除此之外,RecyclerView的动画配以局部刷新也是它比较出色的地方,在AbsListView时代,只有一个notifyDatasetChanged方法,想要做局部刷新需要自己去实现,动画更是难做,但是在RecyclerView中,有很多适配局部刷新的api,还有ItemAnimator这样的神器去支持动画,谁用谁知道。

至于点击事件,我在想Google将RecyclerView取名叫这个名字的原因就是想让这个组件只关注[Recycle],关于点击事件,在ViewHolder中添加是轻而易举的事,封装起来也不难,而且如果把这个逻辑写在组件内部,它的position和动画将会比较难处理,AbsListView里就花了比较多的精力去处理这一方面的逻辑。此外,在我们使用ListView的过程中,如果item中有可点击组件,例如button,那么点击事件的冲突也是一个让开发者很烦恼的事情,但是RecyclerView的好处就是把点击事件的控制权完全的交给开发者,避免了这样的痛苦。

最后,RecyclerView天生支持嵌套滑动,可以很好的配合NestedScrollView或者CoordinatorLayout,而AbsListView则是需要在一定的版本上才支持这个机制,这也算是RV的一个优势吧。