美文网首页
自定义LayoutManager之复用与回收一

自定义LayoutManager之复用与回收一

作者: code希必地 | 来源:发表于2021-09-30 11:50 被阅读0次

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

    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之复用与回收一

          本文链接:https://www.haomeiwen.com/subject/lqmjnltx.html