自定义LayoutManager之复用与回收一

转自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 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的滑动只是其中的内容在滑动,这里假设内容的位置不动,那么屏幕相对于内容就发生滑动。

image.png

在滚动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)就无法完成了。

你可能感兴趣的:(自定义LayoutManager之复用与回收一)