14年Google发布了万众期待的Android 5.0 。随之而来的还有新的设计方案 Material Design。为了在5.0以下的版本中也兼容这种设计方案, Google在新的support包中放出了大量控件,这其中就包括我们今天要讲的RecyclerView。
这篇文章并不会讲RecyclerView怎么用,而且通过分析RecyclerView内部的运行机制,以便于能恰当、正确的使用RecyclerView。
先看看RecyclerView都包含什么
** 先想想RecyclerView有什么特点:**
- 可以显示列表、网格、瀑布流等布局
- 可以添加Item的动画
- Item 可以回收再利用
- 可以显示多种类型的 Item
- Item 支持拖拽操作
- 等等
那这么厉害的RecyclerView代码都有多复杂啊。
错了,一个好汉三个帮,弱蜀还有五虎上将呢,RecyclerView这么好用的控件怎么可能单枪匹马逞英雄?
** 那我们看看RecyclerView都有那些猛将:**
- LayoutManager
LayoutManager是用来管理RecyclerView的布局。RecyclerView的onMeasure 和 onLayout都会被LayoutManager全权代理。不同的LayoutManager展示的布局样式也不一样。android默认提供三种布局样式,我们也可以自定义特殊的布局样式。- ItemAnimator
ItemAnimator是处理在Item的add、remove、change的时候,展示相应的动画。RecyclerView是会带着一个默认动画的。- Adapter
Adapter 不用多说。根据数据适配不用的View。- Recycler
Recycler相当于Item的缓存池。- ChildHelper
由于RecyclerView在处理Item的操作时,会有动画。比如我们移除一个View.对于ItemAnimator来说,需要让这个View动起来。而对于RecyclerView来说,它希望可以移除View并且回收这个View。ChildHelper就是用来处理这个冲突。- AdapterHelper
AdapterHelper 维护一组UpdateOp。判断那些操作需要预处理,那些不需要,分别给出他们相应的动画执行顺序。由于移动会打断整体的连续性,所以把移动操作放在执行队列的最后面。- SnapHelper
SnapHelper 是控制RecyclerView滑动的。你控制他的滑动范围,可以控制Fling,也就是惯性停止的地方。- ItemTouchHelper
ItemTouchHelper 控制Item的手势,比如侧滑删除,比如拖动排序之类。- DiffUtil
DiffUtil通过对比两组数据前后的差异,提供RecyclerView局部刷新的能力。
接下来我会一一介绍它们
LayoutManager
LayoutManager作为布局先锋,负责RecyclerView内部Item的测量和布局。说白了就是,RecyclerView自己不再负责Measure、Layout,全权委托给LayoutManager来处理。这样做的好处就是职责清晰,开发者不但可以自由的使用列表、网格、瀑布流等常规的布局,还可以自定义LayoutManager来满足特殊的列表需求。比如:
上图中的这个复杂列表,在阿里系的APP上比较常见,比如优酷、天猫。大致结构是的:
public class Pager {
List<Card> mCards;
class Card {
List<Item> mItems;
class Item {
public int mId;
public String mName;
}
}
}
后端会返回List<Card> mCards 。其中每一个卡片,又是一个列表List<Item> mItems。这个时候LayoutManager就派上用场了,我们可以继承LinearLayoutManager,来处理每一个卡片如何布局,同时,我们需要卡片重的Item打平,这样就可以有效利用RecyclerView的缓存机制。在之后的系列文章中,我会详细解释。
阿里爸爸开源的复杂列表VLayout
这个是阿里开发的一个用于显示复杂列表的LayoutManager,有兴趣的可以看一眼。
** 总结来说,整个LayoutManager需要处理的任务如下:**
是否需要支持 wrap_content
如果支持,就需要先计算Adapter中所有item的大小,然后在计算RecyclerView自己的大小。整个过程比较消耗性能,迫不得已,不要使用。
预判动画
item 添加、删除、大小变化都可能触发动画,举个例子,如果RecyclerView使用默认的动画,删除Position为0的Item,其余的Item就会整体向上移动。这个时候就需要知道,item的偏移量,只之后真正的Layout做准备。
开始真正的Layout
真正的布局需要RecyclerView的大小,Item的起始位置,布局方向,每一次布局之后的偏移量。
处理一些滚动
如果想让我们RecyclerView滚动起来,就需要在LayoutManager来做特殊的处理。
自定义LayoutManger相对比较复杂。也不是我短短几句话就能讲清楚的,需要开发者不断的写Demo,查阅源码或者相关文章,才能完成一个完整的LayoutManger。下面介绍自定义LayoutManger必须知道的几个知识点:
public class DemoLayoutManager extends RecyclerView.LayoutManager {
@Override
public RecyclerView.LayoutParams generateDefaultLayoutParams() {
//返回ItemView的默认大小
return new RecyclerView.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT,
ViewGroup.LayoutParams.WRAP_CONTENT);
}
/**
* 处理Item布局的问题
*/
@Override
public void onLayoutChildren(RecyclerView.Recycler recycler, RecyclerView.State state) {
// 第一步:移除当前界面中的item,并添加到回收站中
detachAndScrapAttachedViews(recycler);
int xOffset = 0;
int yOffset = 0;
// 第二步:把所有的Item放在他们应该放的位置
for (int i = 0; i < getItemCount(); i++) {
View view = recycler.getViewForPosition(i); // 从回收站中取出View
addView(view);
measureChildWithMargins(view, 0, 0); // 计算View的大小
int width = getDecoratedMeasuredWidth(view);
int height = getDecoratedMeasuredHeight(view);
// 将view放置在正确的位置 (这个位置会收到ItemDecoration的影响)
layoutDecorated(view, xOffset, yOffset, xOffset + width, yOffset + height);
if (i % 6 == 5) {
xOffset = 0;
yOffset += height;
} else {
xOffset += width;
}
}
}
@Override
public boolean canScrollVertically() {
// 控制能否上下滚动
return true;
}
@Override
public int scrollVerticallyBy(int dy, RecyclerView.Recycler recycler, RecyclerView.State state) {
// 在滚动的时候移动view
offsetChildrenVertical(-dy);
return dy;
}
Recycler
Recycler作为回收站,Item的回收和复用都是由Recycler来控制的。那他是如何来处理的呢?
我们来看上面这张图。图中展示来RecyclerView复用ItemView的机制。其中 #、1、2、3、4 分别代表:
- "#":代表LayoutManager在处理布局时,需要从RecyclerView中获得每一个Item的View。getViewForPosition。
- "1": Scap 直译是废料 暂且理解为废品回收站。在Layout过程中暂时处于detached状态的Views。属于一级缓存。
- "2": Cache 代表当前已经不可见但并没有从布局中移除的View。
属于二级缓存。- "3":ViewCacheExtension 是留给开发者自定义的缓存池。属于第三级缓存。官方并没有给出默认示例。我在目前的开发中,也没有遇到使用这种缓存池的场景,如果大家有使用这种缓存池的场景,可以在留言中告诉我。
- "4":RecycledViewPool 最终的缓存池。也就是第四级缓存。RecycledViewPool提供按照不同Type缓存不用View的能力。
上面这个图,我稍微说明一下。
1.当RecyclerView需要更新数据的时候,包括 add、move、remove、change操作,如果当前数据在可视范围之内,就会直接从Scrap中获取。
2.上下小幅度的滑动的时候,就需要用到cache中缓存的View了。
3.如果大幅度滚动,cache中缓存数据不够用,或者调用了notifyDataSetChanged后,需要重新布局时,这时候就会调用Pool中的缓存。
4.值得注意的是,Scrap 和 cache 的数据,是不需要重新绑定的。除了ChangedScarp这个特例。
ItemAnimator
ItemAnimator主要是处理动画。这个动画主要添加、删除、移动的动画。国内开发者,需要特别绚丽多彩的动画并不多。同样我也没有遇到这种需求。我认为默认的动画就还不错。
如果你真的想自定义ItemAnimator。我推荐Github的一个开源库,大家可以参考参考。我之后有时间,也会详细介绍这方面的知识。
https://github.com/wasabeef/recyclerview-animators
但是需要注意,一个Item的操作,可能会触发多个动画,比如,在中间位置插入一条数据,这个时候,就会触发插入的动画,和原来这个位置以后的Item都向下移动的动画。
DiffUtil
DiffUtil是最近版本中推出的一个工具。主要是帮助RecyclerView提升刷新效率的问题。我们举一个例子来说这个问题。
这样的搜索功能是很常见的。每次输入不同的文字,都要给出该文字相对应的搜索热词推荐。如果直接使用notifyDataSetChanged(),就会导致整个RecyclerView发生RequestLayout。我们都知道RequsetLayout会引起整个View树重新遍历一边Measure和Layout。这样非常消耗性能。而且,RecyclerView重新加载时,只会从RecyclerViewPool中拿缓存的Item。RecyclerViewPool默认只会缓存5个Item。剩下的Item都需要重新走Create和inflate。之后他们还要重写计算宽高,重新计算布局。这个过程非常耗时。
为了提供性能,我们就可以使用DiffUtil来对比两组数据,得到数组A切换到数组B的最少移动步骤。
“寻找diff”这件事,被抽象成了“寻找图的路径”了。那么,“最短的直观的”diff对应的路径有什么特点呢?
路径长度最短(对角线不算长度)
先向右,再向下(先删除,后新增)
其实Myers算法是一个典型的”动态规划“算法,也就是说,父问题的求解归结为子问题的求解。要知道d=5时所有k对应的最优坐标,必须先要知道d=4时所有k对应的最优坐标,要知道d=4时的答案,必须先求解d=3,以此类推,和01背包问题很是相似。
public void dispatchUpdatesTo(final RecyclerView.Adapter adapter) {
dispatchUpdatesTo(new ListUpdateCallback() {
@Override
public void onInserted(int position, int count) {
adapter.notifyItemRangeInserted(position, count);
}
@Override
public void onRemoved(int position, int count) {
adapter.notifyItemRangeRemoved(position, count);
}
@Override
public void onMoved(int fromPosition, int toPosition) {
adapter.notifyItemMoved(fromPosition, toPosition);
}
@Override
public void onChanged(int position, int count, Object payload) {
adapter.notifyItemRangeChanged(position, count, payload);
}
});
}
在使用DiffUtil得到变化之后,我们可以调用RecyclerView的局部刷新机制。这样不需要RequestLayout。刷新效率非常高。
那DiffUtil的对比数据的效率怎么样呢。
这里有一组官方的数据:
100 items and 10 modifications: avg: 0.39 ms, median: 0.35 ms
100 items and 100 modifications: 3.82 ms, median: 3.75 ms
100 items and 100 modifications without moves: 2.09 ms, median: 2.06 ms
1000 items and 50 modifications: avg: 4.67 ms, median: 4.59 ms
1000 items and 50 modifications without moves: avg: 3.59 ms, median: 3.50 ms
1000 items and 200 modifications: 27.07 ms, median: 26.92 ms
1000 items and 200 modifications without moves: 13.54 ms, median: 13.36 ms
都在16ms以内,看来完成可以在主线程执行。
ItemDecoration
ItemDecoration 很简单。就是RecyclerView的装饰品。你可以想象RecyclerView是个小姑娘。ItemDecoration就是小姑娘的化妆品。
public void onDraw(Canvas c, RecyclerView parent, State state) {
//在画Item之前。
}
public void onDrawOver(Canvas c, RecyclerView parent, State state) {
//在画Item之后
}
public void getItemOffsets(Rect outRect, View view, RecyclerView parent, State state) {
// 设置边距
outRect.set(left, top, right, bottom);
}
** 上面这种图中,分别体现了上面的三个方法。**
- 设置边距,上图中的灰色区域就是变局,分别代表左、上、右、下的边界。
- 上图中红色999的便签,就是在onDrawOver中画动。
- 上图中红色五角星背景,则是onDraw画的。
值得注意的事,在给Item画装饰品的时候,一定要注意Item本身的位置。
@Override
public void onDrawOver(Canvas c, RecyclerView parent, RecyclerView.State state) {
super.onDrawOver(c, parent, state);
if (state.isMeasuring()) return;
c.save();
int childCount = parent.getChildCount();
for (int i = 0; i < childCount; i++) {
View child = parent.getChildAt(i);
int position = parent.getChildAdapterPosition(child);
// 获取你的标签中显示的数量
int count = parent.getAdapter().getItemTag();
if (count == 0) continue;
String strCount = String.valueOf(count);
float textWidth = 0;
if (count >= 10) {
textWidth = mTextPaint.measureText(strCount) - mRadius;
}
mRoundRectF.left = child.getRight() - textWidth - mRadius - mLeft;
mRoundRectF.top = child.getTop() - mRadius + mTop;
mRoundRectF.right = child.getRight() + mRadius - mLeft;
mRoundRectF.bottom = child.getTop() + mRadius + mTop;
c.drawRoundRect(mRoundRectF, mRadius, mRadius, mPopPaint);
Paint.FontMetrics fontMetrics = mTextPaint.getFontMetrics();
float baseline = child.getTop() - ((fontMetrics.descent + fontMetrics.ascent) / 2);
c.drawText(strCount, mRoundRectF.left + mRoundRectF.width() / 2, baseline + mTop, mTextPaint);
}
c.restore();
}
SnapHelper
有关SnapHelper的 请看这里。我之后会讲。
https://github.com/rubensousa/RecyclerViewSnap
ItemTouchHelper
有关ItemTouchHelper的 请看这里。我之后会讲。
https://github.com/iPaulPro/Android-ItemTouchHelper-Demo
AdapterHelper
以后补全
ChildHelper
在发生移除动画时,对于ViewGroup来说,由于动画还在发生,所以View并没有被真正的从ViewGroup中移除。而对于LayoutManager来说,这个View已经被移除,需要对他做回收处理。
这个时候,LayoutManager 操作View的时候,比如getChildAt()。这个方法并不是真正冲ViewGroup中获取,而是从ChildHelper维护的View队列中获取。
使用RecyclerView的注意点
1.如何RecyclerView的宽高不随着内容的变化而变化,就可以使用如下方法来提高性能
mRecyclerView.setHasFixedSize(true);
2.如果想提高RecyclerView的滑动流畅性,可以适度增加Cache的大小,默认大小是2。但是也不能太大,如果太大,会影响初始化的效率。
mRecyclerView. setItemViewCacheSize(5);
3.如果多个RecyclerView显示的Item一样。比如:
比如这种情况,就可以使用公告缓存池。
int type0 = 0;
int type1 = 1;
int type2 = 2;
RecyclerViewPool mPool = new RecyclerViewPool();
mPool.setMaxRecycledViews(type0, 10);
mPool.setMaxRecycledViews(type1, 10);
mPool.setMaxRecycledViews(type2, 10);
3.有时候我们会在Item上添加一些手势处理,比如最常见的侧滑删除,Item拖拽等等。在有些特殊的手机上,你会发现拖拽不灵敏,这个时候就可以用
mRecyclerView.setScrollingTouchSlop(RecyclerView.TOUCH_SLOP_PAGING);
4.RecyclerView 默认是带Item动画的。如果你不需要动画,或者性能要求严格,可以关闭动画。
mRecyclerView.setItemAnimator(null);
5.RecyclerView如果要现实图片,可以在惯性滚动的时候暂停图片加载,这样可以提升流畅度。
mRecyclerView.setOnFlingListener();
参考资料
1.http://v.youku.com/v_show/id_XMTU4MTQ1ODg2NA==.html?f=27314446&debug=flv
2.https://blog.jcoglan.com/2017/02/12/the-myers-diff-algorithm-part-1/
3.com.android.support:recyclerview-v7:25.3.1