转自RecyclerView系列之四实现回收复用
在上面文章自定义LayoutManager中讲解了LayoutManager的自定义,实现了界面的展示和滑动。熟悉RecyclerView的都知道其缓存机制,在添加缓存的回收与复用之前,先简单介绍下RecyclerView的缓存机制。
1、RecyclerView的回收复用原理
1.1、RecyclerView的回收
-
在滑动过程中,RecyclerView会将即将与之分离的ViewHolder放到mCachedViews和mRecyclerPool,可使用
removeAndRecycleView(View child, Recycler recycler)
函数进行回收。
这两级缓存的区别是:mCachedViews是第一级缓存,大小默认为2,数据结构是ArrayList。当其中数量超过2时,会根据FIFO的原则移除元素,并将移除的元素添加到mRecyclerPool中。
mRecyclerPool是一个缓存池,本质上它是SparseArray,key是itemViewType,value是一个ArrayList,value的大小默认为5。 -
除了上面说到两级缓存还有mAttachedScrap,在onLayoutChildren中会调用函数
detachAndScrapAttachedViews(recycler);
将屏幕上ViewHolder进行detach,并暂存到mAttachedScrap,再重新布局时从mAttachedScrap中取出,attach到RecyclerView上。
1.2、RecyclerView的复用
通过View view = recycler.getViewForPosition(position)
可以实现复用,根据源码可知,在RecyclerView中,总共有四级缓存,优先级:mAttachedScrap>mCachedViews>mViewCacheExtension>mRecyclerPool。
- mAttachedScrap:只保存当前屏幕中detach的ViewHolder,在重新布局时复用。
- mCachedViews:缓存的是刚从RecyclerView中移除的ViewHolder(通过
removeAndRecycleView(view, recycler)方法
),在复用时需要position或id匹配才能复用,所以只有在来回滑动过程中才会复用mCachedViews中的ViewHolder。如果不能匹配就需要从mRecyclerPool中取出ViewHolder并重新绑定数据。 - 复用mAttachedScrap、mCachedViews中的ViewHolder是需要精确匹配的,如果能匹配上可直接使用不需绑定数据,如果不能精确匹配,即使mAttachedScrap、mCachedViews中有缓存也不能取出使用,只能从mRecyclerPool中取出使用,并且需重绑数据。如果mRecyclerPool中没有缓存就需要调用onCreateViewHolder进行创建。
2、几个函数
-
public void detachAndScrapAttachedViews(Recycler recycler)
仅用于onLayoutChildren中,在布局前将屏幕上的ViewHolder从RecyclerView中detach掉,将其放在mAttachedScrap中,以供重新布局时使用。 -
View view = recycler.getViewForPosition(position)
当我们需要填充布局时,就可以调用该方法,从四个缓存容器中取出合适的View,然后添加到RecyclerView中。 -
removeAndRecycleView(child, recycler)
该函数仅用于在滑动过程中,在滚动时,将滚出屏幕的ViewHolder进行remove并添加到mCachedViews或mRecyclerPool中。
可以看到,正是这三个函数的使用,可以让我们自定义的LayoutManager具有复用功能。
另外,还有几个常用,但经常出错的函数: -
int getItemCount()
得到的是Adapter中总共有多少数据要显示,也就是总共有多少个item -
int getChildCount()
得到的是当前RecyclerView在显示的item的个数,所以这就是getChildCount()与 getItemCount()的区别 -
View getChildAt(int position)
获取某个可见位置的View,需要非常注意的是,它的位置索引并不是Adapter中的位置索引,而是当前在屏幕上的位置的索引。也就是说,要获取当前屏幕上在显示的第一个item的View,应该用getChidAt(0),同样,如果要得到当前屏幕上在显示的最后一个item的View,应该用getChildAt(getChildCount()-1) -
int getPosition(View view)
这个函数用于得到某个View在Adapter中的索引位置,我们经常将它与getChildAt(int position)联合使用,得到某个当前屏幕上在显示的View在Adapter中的位置。
3、自定义LayoutManager的回收和复用原理
从上面的原理中可以看到,回收复用主要有两部分:
第一:在onLayoutChildren初始布局时:
- 1、在布局前调用
detachAndScrapAttachedViews(recycler)
将所有可见的ViewHolder detach。 - 2、通过调用
recycler.getViewForPosition(position)
申请一个View,并添加到RecyclerView中,直到填充满整个屏幕。
第二:在scrollVerticallyBy滑动时
- 1、判断滚动dy后,那些ViewHolder需要回收,然后调用
removeAndRecycleView(child, recycler)
进行回收。 - 2、然后通过调用
recycler.getViewForPosition(position)
获取View,填充空白区域。
4、为自定义LayoutManager添加回收复用
4.1、修改onLayoutChildren
上面已经提到,在onLayoutChildren中,我们主要做两件事:
- 1、在布局前调用
detachAndScrapAttachedViews(recycler)
将所有可见的ViewHolder detach。 - 2、通过调用
recycler.getViewForPosition(position)
申请一个View,并添加到RecyclerView中,直到填充满整个屏幕。
关键就在于如何判断一屏能显示多少个item,在这里每个item高度相同,所以可以通过RecyclerView的高度处于item的高度即可
private int mItemWidth,mItemHeight;
@Override
public void onLayoutChildren(RecyclerView.Recycler recycler, RecyclerView.State state) {
if (getItemCount() == 0) {//没有Item,界面空着吧
detachAndScrapAttachedViews(recycler);
return;
}
detachAndScrapAttachedViews(recycler);
View childView = recycler.getViewForPosition(0);
measureChildWithMargins(childView, 0, 0);
mItemWidth = getDecoratedMeasuredWidth(childView);
mItemHeight = getDecoratedMeasuredHeight(childView);
int visiableCount = (int) Math.ceil(getVerticalSpace() * 1.0f / mItemHeight);
if (visiableCount > itemCount)
visiableCount = itemCount;
…………
}
//其中 getVerticalSpace()在上面已经提到,得到的是RecyclerView用于显示的高度,它的定义是:
private int getVerticalSpace() {
return getHeight() - getPaddingBottom() - getPaddingTop();
}
一屏可见的item个数=(int) Math.ceil(getVerticalSpace() * 1.0f / mItemHeight);
,这里使用Math.ceil进行向上取整的原因就是:如果一屏内可显示1.5个item,此时可见的item应该为2才对。
除此之外,由于item高度相同,为了布局方便,我们在初始化时,利用一个变量来保存在初始化时,在Adapter中每一个item的位置:
int offsetY = 0;
for (int i = 0; i < getItemCount(); i++) {
Rect rect = new Rect(0, offsetY, mItemWidth, offsetY + mItemHeight);
mItemRects.put(i, rect);
offsetY += mItemHeight;
}
接下来布局可见的item,不可见的item不再布局
for (int i = 0; i < visibleCount; i++) {
Rect rect = mItemRects.get(i);
View view = recycler.getViewForPosition(i);
addView(view);
//addView后一定要measure,先measure再layout
measureChildWithMargins(view, 0, 0);
layoutDecorated(view, rect.left, rect.top, rect.right, rect.bottom);
}
mTotalHeight = Math.max(offsetY, getVerticalVisibleHeight());
因为,在上面我们已经从保存了初始化状态下每个Item的位置,所以在初始化时,直接从mItemRects中取出当前要显示的Item的位置,直接将它摆放在这个位置就可以了。需要注意的是,因为我们在之前已经使用detachAndScrapAttachedViews(recycler);
将所有view从RecyclerView中剥离,所以,我们需要重新通过addView(view)
添加进来。在添加进来以后,需要走一个这个View的测量和layout逻辑,先经过测量,再将它layout到指定位置。如果我们没有测量直接layout,会什么都出不来,因为任何view的layout都是依赖measure出来的位置信息的。
到此,完整的onLayoutChildren的代码如下:
private int mItemWidth, mItemHeight;
private SparseArray<Rect> mItemRects = new SparseArray<>();;
@Override
public void onLayoutChildren(RecyclerView.Recycler recycler, RecyclerView.State state) {
if (getItemCount() == 0) {//没有Item,界面空着吧
detachAndScrapAttachedViews(recycler);
return;
}
detachAndScrapAttachedViews(recycler);
//将item的位置存储起来
View childView = recycler.getViewForPosition(0);
measureChildWithMargins(childView, 0, 0);
mItemWidth = getDecoratedMeasuredWidth(childView);
mItemHeight = getDecoratedMeasuredHeight(childView);
int visiableCount = (int) Math.ceil(getVerticalSpace() * 1.0f / mItemHeight);
if (visiableCount > itemCount)
visiableCount = itemCount;
//定义竖直方向的偏移量
int offsetY = 0;
for (int i = 0; i < getItemCount(); i++) {
Rect rect = new Rect(0, offsetY, mItemWidth, offsetY + mItemHeight);
mItemRects.put(i, rect);
offsetY += mItemHeight;
}
for (int i = 0; i < visibleCount; i++) {
Rect rect = mItemRects.get(i);
View view = recycler.getViewForPosition(i);
addView(view);
//addView后一定要measure,先measure再layout
measureChildWithMargins(view, 0, 0);
layoutDecorated(view, rect.left, rect.top, rect.right, rect.bottom);
}
//如果所有子View的高度和没有填满RecyclerView的高度,
// 则将高度设置为RecyclerView的高度
mTotalHeight = Math.max(offsetY, getVerticalSpace());
}
4.2、处理滚动
经过上面的分析可知,我们可知,我们首先回收滚出屏幕的ViewHolder,然后再填充滚动后的空白区域。向上滚动和向下滚动虽然都是回收滚出屏幕的ViewHolder,但是处理逻辑还是有区别的,下面就按照滚动方向分为两种情况进行分析。
4.2.1、处理向上滚动
向上滚动时dy>0,这里先假设向上滚动dy,然后判断哪些ViewHolder需要回收,需要新增哪些item,然后再执行offsetChildrenVertical(-travel)
进行滑动。
因为在开始移动之前,我们对dy做了到顶、到底的边界判断并进行了修正。
int travel = dy;
//如果滑动到最顶部
if (mSumDy + dy < 0) {
travel = -mSumDy;
} else if (mSumDy + dy > mTotalHeight - getVerticalSpace()) {
//如果滑动到最底部
travel = mTotalHeight - getVerticalSpace() - mSumDy;
}
所以真正移动的距离,是修正后的travel。所以在进行处理回收和填充item,应该以travel进行判断。
1、判断回收item
在判断向上滑动回收哪些item时,应该遍历当前屏幕所有可见的item,假设让它们向上滑动travel,然后判断是否已经超出了上边界(y=0),如果超出就进行回收。
for (int i = getChildCount() - 1; i >= 0; i--) {
View child = getChildAt(i);
if (travel > 0) {//需要回收当前屏幕,上越界的View
if (getDecoratedBottom(child) - travel< 0) {
removeAndRecycleView(child, recycler);
}
}
}
在上面代码中:
- 首先遍历屏幕上所有可见的item,这里getChildCount()-1表示屏幕上可见的最后一个item,注意和getItemCount()的区别。
- 由于是获取屏幕上可见的item,所以可以调用getChildAt(i)直接获取。开始我不注意使用了recycler.getViewForPosition(i)去获取了,这是从四个缓存池中获取,很明显不对。
-
getDecoratedBottom(child) - travel< 0
表示向上移动travel后超出了上边界,故对进入该判断的item进行回收。 - 注意这里使用
removeAndRecycleView(child, recycler)
方法进行回收,而不是detachAndScrapAttachedViews(recycler)
方法。在滚动时,滚出屏幕的ViewHolder应该remove掉,而不是detach掉。在onLayoutChildren中进行布局时,需要暂存屏幕上的ViewHolder,在再次布局时使用,此时就需要使用detachAndScrapAttachedViews(recycler)
方法。
2、填充空白区域
假设向上滚动了travel后,屏幕的位置如下图,左边是初始状态,右边是移动后的情况,其中绿色框表示屏幕。其实RecyclerView的滑动只是其中的内容在滑动,这里假设内容的位置不动,那么屏幕相对于内容就发生滑动。
在滚动travel后,屏幕此时所在的区域如下:
private Rect getVisibleArea(int travel) {
Rect result = new Rect(getPaddingLeft(), getPaddingTop() + mSumDy + travel, getWidth() - getPaddingRight(), getHeight()-getPaddingBottom() + mSumDy + travel);
return result;
}
mSumDy表示已经滑动的距离,travel表示即将滑动的距离。所以mSumDy + travel表示此时滑动后,屏幕的位置。
由于在onLayoutChildren中初始化布局时,已经记录每个item的初始位置,在拿到屏幕移动后的位置后,只需要和初始化item的位置进行比对,如果存在交集就表示在屏幕内,否则表示已滑出了屏幕。
分析到这里,我们还是不知道哪些item要滑入屏幕,再回看下上图不难看出,滑入屏幕的item无非就是在当前屏幕中可见的最后一个item的下一个item一直到第itemCount个item中一些item将要滑入屏幕。
Rect visibleRect = getVisibleArea(travel);
//布局子View阶段
if (travel >= 0) {
View lastView = getChildAt(getChildCount() - 1);
int minPos = getPosition(lastView) + 1;//从最后一个View+1开始吧\
//顺序addChildView
for (int i = minPos; i <= getItemCount() - 1; i++) {
Rect rect = mItemRects.get(i);
if (Rect.intersects(visibleRect, rect)) {
View child = recycler.getViewForPosition(i);
addView(child);
measureChildWithMargins(child, 0, 0);
layoutDecorated(child, rect.left, rect.top - mSumDy, rect.right, rect.bottom - mSumDy);
} else {
break;
}
}
}
mSumDy += travel;
// 平移容器内的item
offsetChildrenVertical(-travel);
我们来看看上面代码:
首先获取滑动后的屏幕的位置
Rect visibleRect = getVisibleArea(travel);
然后,找到移动前最后一个可见的View
View lastView = getChildAt(getChildCount() - 1);
然后,找到它之后的一个item:
int minPos = getPosition(lastView) + 1;
然后从这个item开始查询,看它和它之后的每个item是不是都在可见区域内,之后就是判断这个item是不是在显示区域,如果在就加进来并且布局,如果不在就退出循环:
for (int i = minPos; i <= getItemCount() - 1; i++) {
Rect rect = mItemRects.get(i);
if (Rect.intersects(visibleRect, rect)) {
View child = recycler.getViewForPosition(i);
addView(child);
measureChildWithMargins(child, 0, 0);
layoutDecorated(child, rect.left, rect.top - mSumDy, rect.right, rect.bottom - mSumDy);
} else {
break;
}
}
需要注意的是:mItemRects中记录的是item的位置是参考屏幕(0,0),在向上滚动时,我们需要把高度减去滑动的距离,这样才能实现滚入屏幕。注意这个滑动距离并不包括即将滑动的距离travel,虽然我们判断哪些item是新增显示时,假设移动了travel,其实到目前为止并没有发生滚动。所以我们在布局时,仍然需要按上次的移动距离来进行布局,所以这里在布局时使用是layoutDecorated(child, rect.left, rect.top - mSumDy, rect.right, rect.bottom - mSumDy),单纯只是减去了mSumDy,并没有同时减去mSumDy和travel,最后才调用offsetChildrenVertical(-travel)来整体移动布局好的item。这时才会把我们刚才新增布局上的item显示出来。
所以,此时完整的scrollVerticallyBy的代码如下:
public int scrollVerticallyBy(int dy, RecyclerView.Recycler recycler, RecyclerView.State state) {
if (getChildCount() <= 0) {
return dy;
}
int travel = dy;
//如果滑动到最顶部
if (mSumDy + dy < 0) {
travel = -mSumDy;
} else if (mSumDy + dy > mTotalHeight - getVerticalSpace()) {
//如果滑动到最底部
travel = mTotalHeight - getVerticalSpace() - mSumDy;
}
//回收越界子View
for (int i = getChildCount() - 1; i >= 0; i--) {
View child = getChildAt(i);
if (travel > 0) {//需要回收当前屏幕,上越界的View
if (getDecoratedBottom(child) - travel < 0) {
removeAndRecycleView(child, recycler);
}
}
}
Rect visibleRect = getVisibleArea(travel);
//布局子View阶段
if (travel >= 0) {
View lastView = getChildAt(getChildCount() - 1);
int minPos = getPosition(lastView) + 1;//从最后一个View+1开始吧
//顺序addChildView
for (int i = minPos; i <= getItemCount() - 1; i++) {
Rect rect = mItemRects.get(i);
if (Rect.intersects(visibleRect, rect)) {
View child = recycler.getViewForPosition(i);
addView(child);
measureChildWithMargins(child, 0, 0);
layoutDecorated(child, rect.left, rect.top - mSumDy, rect.right, rect.bottom - mSumDy);
} else {
break;
}
}
}
mSumDy += travel;
// 平移容器内的item
offsetChildrenVertical(-travel);
return travel;
}
此时已经实现了向上滚动的功能,并在向上滚动过程中,回收滑动屏幕的ViewHolder。
4.2.2、处理向下滚动
在分析完向上滚动的处理后,向下滚动的处理就很简单了,和向上滚动是完全相反的。
1、判断回收item
遍历当前屏幕可见的item,假设向下移动travel后,判断哪些item滑出了屏幕的底部,回收滑出的item即可。
for (int i = getChildCount() - 1; i >= 0; i--) {
View child = getChildAt(i);
if (travel > 0) {//需要回收当前屏幕,上越界的View
…………
}else if (travel < 0) {//回收当前屏幕,下越界的View
if (getDecoratedTop(child) - travel > getHeight() - getPaddingBottom()) {
removeAndRecycleView(child, recycler);
}
}
}
getDecoratedTop(child) - travel
得到移动travel距离后item的顶部位置,然后判断是否大于屏幕底部的位置getHeight() - getPaddingBottom()
,若大于则表示滑动了屏幕。
2、为滚动后的空白处填充Item
向下滚动,RecyclerView的头部位置滚动后会有空白,故可以从当前屏幕可见的第一个item的上一个开始遍历,到第0个item结束,判断哪些item在屏幕内,将在屏幕内的item添加进来。
Rect visibleRect = getVisibleArea(travel);
//布局子View阶段
if (travel >= 0) {
…………
} else {
View firstView = getChildAt(0);
int maxPos = getPosition(firstView) - 1;
for (int i = maxPos; i >= 0; i--) {
Rect rect = mItemRects.get(i);
if (Rect.intersects(visibleRect, rect)) {
View child = recycler.getViewForPosition(i);
addView(child, 0);
measureChildWithMargins(child, 0, 0);
layoutDecoratedWithMargins(child, rect.left, rect.top - mSumDy, rect.right, rect.bottom - mSumDy);
} else {
break;
}
}
}
下面来看看这段代码:
在这里,先得到在滚动前显示的第一个item的前一个item:
View firstView = getChildAt(0);
int maxPos = getPosition(firstView) - 1;
如果在显示区域,那么,就将它插在第一的位置:
addView(child, 0);
同样,在布局Item时,由于还没有移动,所以在布局时并不考虑travel的事:layoutDecoratedWithMargins(child, rect.left, rect.top - mSumDy, rect.right, rect.bottom - mSumDy)
。
其它的代码都很好理解了,这里就不再讲了。
这样就完整实现了滚动的回收和复用功能了,完整的scrollVerticallyBy代码如下:
public int scrollVerticallyBy(int dy, RecyclerView.Recycler recycler, RecyclerView.State state) {
if (getChildCount() <= 0) {
return dy;
}
int travel = dy;
//如果滑动到最顶部
if (mSumDy + dy < 0) {
travel = -mSumDy;
} else if (mSumDy + dy > mTotalHeight - getVerticalSpace()) {
//如果滑动到最底部
travel = mTotalHeight - getVerticalSpace() - mSumDy;
}
//回收越界子View
for (int i = getChildCount() - 1; i >= 0; i--) {
View child = getChildAt(i);
if (travel > 0) {//需要回收当前屏幕,上越界的View
if (getDecoratedBottom(child) - travel < 0) {
removeAndRecycleView(child, recycler);
continue;
}
} else if (travel < 0) {//回收当前屏幕,下越界的View
if (getDecoratedTop(child) - travel > getHeight() - getPaddingBottom()) {
removeAndRecycleView(child, recycler);
}
}
}
Rect visibleRect = getVisibleArea(travel);
//布局子View阶段
if (travel >= 0) {
View lastView = getChildAt(getChildCount() - 1);
int minPos = getPosition(lastView) + 1;//从最后一个View+1开始吧
//顺序addChildView
for (int i = minPos; i <= getItemCount() - 1; i++) {
Rect rect = mItemRects.get(i);
if (Rect.intersects(visibleRect, rect)) {
View child = recycler.getViewForPosition(i);
addView(child);
measureChildWithMargins(child, 0, 0);
layoutDecorated(child, rect.left, rect.top - mSumDy, rect.right, rect.bottom - mSumDy);
} else {
break;
}
}
} else {
View firstView = getChildAt(0);
int maxPos = getPosition(firstView) - 1;
for (int i = maxPos; i >= 0; i--) {
Rect rect = mItemRects.get(i);
if (Rect.intersects(visibleRect, rect)) {
View child = recycler.getViewForPosition(i);
addView(child, 0);//将View添加至RecyclerView中,childIndex为1,但是View的位置还是由layout的位置决定
measureChildWithMargins(child, 0, 0);
layoutDecoratedWithMargins(child, rect.left, rect.top - mSumDy, rect.right, rect.bottom - mSumDy);
} else {
break;
}
}
}
mSumDy += travel;
// 平移容器内的item
offsetChildrenVertical(-travel);
return travel;
}
到此为止,我们已经为LayoutManager增加了回收复用的功能,但是这里我们调用offsetChildrenVertical(-travel)
来实现平移,当需要实现在平移时,改变每个item的大小、角度等参数时,offsetChildrenVertical(-travel)
就无法完成了。
网友评论