Android DemoAndroid开发Android技术知识

RecyclerView理解-布局与回收复用

2018-04-02  本文已影响134人  澳门记者

RecyclerView作为新的官方钦定列表展示控件,其精彩实现值得我们一窥究竟。而其中最重要的部分就是布局与回收复用。我们将结合源码来分析RecyclerView具体是怎样实现了一个高效与高可扩展性的列表展示控件的。

从哪入手?

在RecyclerView的设计中,设计者将RecyclerView的测量与布局工作全部抽了出来交给它的组件LayoutManager来负责。

RecyclerView直接继承于ViewGroup,于是我们就可以从onMeasure()onLayout()两个部分来着手研究它布局的过程。

在一个ViewGroup的onMeasure()中,应该要完成两件事情,即:

  1. 测量出自身的大小,调用setMeasuredDimension()进行确认
  2. 调用子View的measure()方法测量每个子View的大小。

在一个ViewGroup的onLayout()中,则应该要完成:
调用每个子View的layout(),决定子View的布局位置。

也就是说,我们主要要研究LayoutManager是如何完成上面几项工作的,分别是怎么完成的。

RecyclerView如何决定自身大小?自动测量如何实现?

我们来看RecyclerView的onMeasure()方法中是怎样操作的:

@Override
    protected void onMeasure(int widthSpec, int heightSpec) {
        if (mLayout == null) {
            //还没有设置LayoutManager时,使用默认的测量方法
            defaultOnMeasure(widthSpec, heightSpec);
            return;
        }
        if (mLayout.mAutoMeasure) {
            ...
            //开启了自动测量,需要先确认子View的大小与布局
            dispatchLayoutStep2();
            ...
            //再根据子View的情况决定自身的大小
            mLayout.setMeasuredDimensionFromChildren(widthSpec, heightSpec);

            if (mLayout.shouldMeasureTwice()) {
                ...
                //如果有父子尺寸属性互相依赖的情况,要改变参数重新进行一次
                dispatchLayoutStep2();
                mLayout.setMeasuredDimensionFromChildren(widthSpec, heightSpec);
            }
        } else {
            if (mHasFixedSize) {
                //用户标记了尺寸是固定的不用测量了,可以偷个懒
                mLayout.onMeasure(mRecycler, mState, widthSpec, heightSpec);
                return;
            }
            ...
            mLayout.onMeasure(mRecycler, mState, widthSpec, heightSpec);
            ...
        }
    }

自动测量是RecyclerView在23版本增加的一个特性,它的作用就是能让RecyclerView在属性设置为wrap_content的时候,能真的根据自己的子View的大小改变自身的大小,实现包裹内容的效果(老版本中如果使用wrap_content会被忽略)。

为了实现按照子View布局来调整自身大小的效果,当然得首先知道子View各自的大小与排布了,于是RecyclerView将布局的过程提前到这里来进行。我们看到在mAutoMeasure为true的分支里,调用了dispatchLayoutStep2()这个方法,里面就是进行了一次实打实的布局。一般情况下自动测量都是默认为true的,除非你调用setAutoMeasureEnabled(false)方法关闭它。当然,如果在这里进行过布局以后,onLayout()过程中会根据State.STEP_START,STEP_LAYOUT,STEP_ANIMATIONS这几个状态来确定当前处在的步骤,判断是否可以不用再布局了,避免重复劳动。

defaultOnMeasure()mLayout.onMeasure()实际上都会调用setMeasuredDimension()设置RecyclerView的大小。mLayout.setMeasuredDimensionFromChildren()则是通过从前面先进行的子View的测量布局结果调用setMeasuredDimension()设置自身大小。

概括地来说,RecyclerView自身的大小在没有开启自动测量,或者不需要考虑子View情况的时候,都是与其它的View控件一样参照父级的要求与自身的属性进行默认测量。而在开启自动测量的时候,会提前进行一次子View的测量布局过程,然后根据子View的情况,设置自身大小。

LinearLayoutManager的布局实现是怎样的?

我们在RecyclerView的onMeasure()中看到的dispatchLayoutStep系列方法有三个,分别是dispatchLayoutStep1()dispatchLayoutStep2()dispatchLayoutStep3()。step1里是进行预布局,主要跟记录数据更新时需要进行的动画所需的信息有关,step2就是实际循环执行了子View的测量布局的一步,而step3主要是用来实际执行动画。

为了了解布局的过程,我们再看RecyclerView的onLayout()方法:

@Override
    protected void onLayout(boolean changed, int l, int t, int r, int b) {
        ...
        dispatchLayout();
        ...
    }

一共只有几行,其中需要关注的就是调用了dispatchLayout(),看来就是它来执行布局了。

  void dispatchLayout() {
        if (mAdapter == null) {//不需要考虑布局时可以直接偷懒跳过
            return;
        }
        if (mLayout == null) {//不需要考虑布局时可以直接偷懒跳过
            return;
        }
       ...
        if (mState.mLayoutStep == State.STEP_START) {
            dispatchLayoutStep1();
            ...
            dispatchLayoutStep2();
        } else if (mAdapterHelper.hasUpdates() || mLayout.getWidth() != getWidth() ||
                mLayout.getHeight() != getHeight()) {
            ...
            dispatchLayoutStep2();
        } else {
         ...
        }
        dispatchLayoutStep3();
    }

dispatchLayout()实际上内部就是调用上述的step1,step2,step3,而我们关注的实际布局过程就在step2。

  private void dispatchLayoutStep2() {
        ...
        // Step 2: Run layout
        mLayout.onLayoutChildren(mRecycler, mState);
        ...
    }

dispatchLayoutStep2()中我们见到了LayoutManager的一个核心方法,onLayoutChildren(),这个方法就是描述LayoutManager布局策略的地方,我们在自定义LayoutManager时也必须要重写这个方法来描述我们自己的布局策略,否则会在Log中输出一行错误信息。

我们以自带的LinearLayoutManager为例,研究一下它在排列方向是垂直(VERTICAL)时,是如何实现线性的布局策略的。

LinearLayoutManager的布局思路,我们这里以最常见的情况来描述,简单来说就是,先找到一个锚点作为起始位置,然后以锚点为基准先向一个方向一个一个按顺序尝试填充子View,直到填满可见区域,然后再朝另一个方向做同样的事情。布局的方向取决于排列方向是水平还是垂直,以及mReverseLayout和mStackFromEnd两个参数。这两个参数在没有主动设置的情况下,一般都是false,因此,一般情况下先从锚点向下布局,完成以后再向上。

锚点位置的选取

锚点的信息在LinearLayoutManager中封装成了一个内部类,叫AnchorInfo。它有3个属性:

那么如何选取布局的锚点呢?锚点选取相关的代码在onLayoutChildren()中的updateAnchorInfoForLayout()中。

  private void updateAnchorInfoForLayout(RecyclerView.Recycler recycler, RecyclerView.State state,
                                           AnchorInfo anchorInfo) {
        if (updateAnchorFromPendingData(state, anchorInfo)) {
            return;
        }
        //一般会从子View中获得,成功拿到锚点可以参考的View时返回true
        if (updateAnchorFromChildren(recycler, state, anchorInfo)) {
            return;
        }
        //没用能做参考的子View,直接选取可见区域最顶端(要考虑padding,反向则是底端)作为锚点
        anchorInfo.assignCoordinateFromPadding();
        anchorInfo.mPosition = mStackFromEnd ? state.getItemCount() - 1 : 0;
    }

第一个判断一般情况不会进入,而在当前没有可用的View作为锚点参考的时候,会直接执行两个if判断后面的两句来设置锚点。这种没有可用参考View的情况,举个栗子,就是RecyclerView第一次加载数据的时候,这时候RecyclerView里面啥都没有,一片空白,当然没有View可以作为锚点的参考了。此时将会选取可见区域最顶端(考虑RecyclerView的padding后的,如果要反向进行布局则是最底端)作为锚点坐标。

updateAnchorFromChildren()方法中:

private boolean updateAnchorFromChildren(RecyclerView.Recycler recycler,
                                             RecyclerView.State state, AnchorInfo anchorInfo) {
        ...
        final View focused = getFocusedChild();
        //优先选取获得焦点的子View作为锚点
        if (focused != null && anchorInfo.isViewValidAsAnchor(focused, state)) {
            anchorInfo.assignFromViewAndKeepVisibleRect(focused);
            return true;
        }
        ...
        //一般情况下会选取最上(反向布局则是最下)的子View作为锚点参考
        View referenceChild = anchorInfo.mLayoutFromEnd
                ? findReferenceChildClosestToEnd(recycler, state)
                : findReferenceChildClosestToStart(recycler, state);
        if (referenceChild != null) {
            //赋予锚点信息
            anchorInfo.assignFromView(referenceChild);
            ...
            return true;
        }
        return false;
    }

这种情况很好理解,比如我们在调用了notifyDataSetChanged()更新了数据的时候,此时原本屏幕上就是有View可以作为参考的。在一般情况下,都会选取可见区域中的第一个(设置了反向布局则是最后一个)子View作为锚点参考View。

public void assignFromView(View child) {
            if (mLayoutFromEnd) {
                mCoordinate = mOrientationHelper.getDecoratedEnd(child) +
                        mOrientationHelper.getTotalSpaceChange();
            } else {
                //顺向布局进这里,选取子View的最上点作为锚点坐标
                mCoordinate = mOrientationHelper.getDecoratedStart(child);
            }
            //mPosition赋值为参考View的position
            mPosition = getPosition(child);
        }

选取了参考的View后,会调用assignFromView()来设置AnchorInfo里面的几个属性,可以看到,在一般正向布局的情况下,会选取第一个可见子View的最上点为锚点的坐标(使用ItemDecorator装饰的位置也要考虑进去)。

RecyclerView布局.png

画了一个在默认情况下LinearLayoutManager垂直布局的示意图,可以看到在这里屏幕上可见的子View为position为1到5的子View,锚点将会选取屏幕上第一个可见View,也就是这里position为1的子View,锚点坐标信息AnchorInfo.mCoordinate将会被赋值为1号子View的上边界y坐标,注意这里在图中position为1的子View没有完全显示出来,因此这是的mCoordinate将会是负值。图中的这个情况,向上布局的过程其实根本不会进行就直接跳出了,因为上面已经没有位置了嘛。

要注意这只是在一般情况下,事实上布局工作不仅仅会局限于在可见区域内进行。在有预判动画的情况下,预布局的过程中会将即将从可见区域外动画入场的View也进行布局,这个时候它就是布局在外面的。

调用fill()从锚点起填充子View

fill()在源码的注释里被称为是这里的「魔术方法」(The magic functions),它里面就进行了RecyclerView中最关键的步骤:回收移除不再显示的View,并在屏幕上堆叠出要展示的View来。堆叠的实现的逻辑很简单,就是判断当前可见区域还有没有剩余空间,如果有的话就再填充一个View上去,使用一个while循环来实现。如果需要填充,就会调用layoutChunk()方法来完成填充工作。

事实上在LinearLayoutManager中,进行界面重绘和进行滑动两种情况下,往屏幕上填充子View的工作都是调用fill()进行的。两者的区别在于,界面重绘的时候,前一步会将已布局的View进行清空并重新填充界面,而滑动的时候,只需要根据滑动的距离计算出经过滑动以后有没有空出新的位置需要填充就可以了。

  int fill(RecyclerView.Recycler recycler, LayoutState layoutState,
             RecyclerView.State state, boolean stopOnFocusable) {

        final int start = layoutState.mAvailable;
        if (layoutState.mScrollingOffset != LayoutState.SCOLLING_OFFSET_NaN) {
            // TODO ugly bug fix. should not happen
            if (layoutState.mAvailable < 0) {
                layoutState.mScrollingOffset += layoutState.mAvailable;
            }
            //回收掉已经滑出屏幕的View
            recycleByLayoutState(recycler, layoutState);
        }
        int remainingSpace = layoutState.mAvailable + layoutState.mExtra;
        LayoutChunkResult layoutChunkResult = new LayoutChunkResult();
        //进入循环,无论是滑动还是界面重绘时,只要还有空出的位置就要进行填充
        while ((layoutState.mInfinite || remainingSpace > 0) && layoutState.hasMore(state)) {
            layoutChunkResult.resetInternal();
            //填充一个View到屏幕上
            layoutChunk(recycler, state, layoutState, layoutChunkResult);
            if (layoutChunkResult.mFinished) {
                break;
            }
            layoutState.mOffset += layoutChunkResult.mConsumed * layoutState.mLayoutDirection;
            /**
             * Consume the available space if:
             * * layoutChunk did not request to be ignored
             * * OR we are laying out scrap children
             * * OR we are not doing pre-layout
             */
            if (!layoutChunkResult.mIgnoreConsumed || mLayoutState.mScrapList != null
                    || !state.isPreLayout()) {
                layoutState.mAvailable -= layoutChunkResult.mConsumed;
                // we keep a separate remaining space because mAvailable is important for recycling
                //如果进行了填充,就要在计算时消耗掉填充使用了的空间
                remainingSpace -= layoutChunkResult.mConsumed;
            }

            if (layoutState.mScrollingOffset != LayoutState.SCOLLING_OFFSET_NaN) {
                layoutState.mScrollingOffset += layoutChunkResult.mConsumed;
                if (layoutState.mAvailable < 0) {
                    layoutState.mScrollingOffset += layoutState.mAvailable;
                }
                recycleByLayoutState(recycler, layoutState);
            }
            if (stopOnFocusable && layoutChunkResult.mFocusable) {
                break;
            }
        }
        return start - layoutState.mAvailable;
    }

源码的注释中提及这个方法是基本独立于LinearLayoutManager其余代码的,稍加更改就能用于我们自己的自定义LayoutManager。

循环中的layoutChunk()

 void layoutChunk(RecyclerView.Recycler recycler, RecyclerView.State state,
                     LayoutState layoutState, LayoutChunkResult result) {
        //准备一个将要用来布局的View,缓存里没有的话就要创建
        View view = layoutState.next(recycler);
       ...
        if (layoutState.mScrapList == null) {
            if (mShouldReverseLayout == (layoutState.mLayoutDirection
                    == LayoutState.LAYOUT_START)) {
                //看到熟悉的addView()了
                addView(view);
            } else {
                //区别只是布局的顺序
                addView(view, 0);
            }
        } else {
            if (mShouldReverseLayout == (layoutState.mLayoutDirection
                    == LayoutState.LAYOUT_START)) {
                //这是即将消失的View,但位置需要作为移出动画的参考
                addDisappearingView(view);
            } else {
                addDisappearingView(view, 0);
            }
        }
        //布局前进行测量
        measureChildWithMargins(view, 0, 0);
        ...
        //布局这个View,ItemDecorator所占的位置也要考虑进去
        layoutDecorated(view, left + params.leftMargin, top + params.topMargin,
        ...
    }

layoutChunk()方法每调用一次,就布局一个View。在调用next()获取将要用来布局的View的时候,将会按照缓存层级的顺序依次优先从Scrap、ViewCacheExtension、CacheViews、RecycledViewPool中尝试获取,如果都没有获取到,就调用一次createViewHolder()重新创建一个。然后就是添加这个View进来并进行测量和布局了。

RecyclerView是如何实现回收与复用的?

RecyclerView的回收复用由模块Recycler来负责,其设计了4层缓存,按照使用的优先级顺序依次是Scrap、CacheView、ViewCacheExtension还有RecycledViewPool,其中ViewCacheExtension默认是没有实现的,预留给开发者针对自己的项目实际使用。

Scrap是什么?

Scrap是RecyclerView中最轻量的缓存,它不参与滑动时的回收复用,只是作为重新布局时的一种临时缓存。它的目的是,缓存当界面重新布局的前后都出现在屏幕上的ViewHolder,以此省去不必要的重新加载与绑定工作。

在RecyclerView重新布局的时候(不包括RecyclerView初始的那次布局,因为初始化的时候屏幕上本来并没有任何View),先调用detachAndScrapAttachedViews()将所有当前屏幕上正在显示的View以ViewHolder为单位标记并记录在列表中,在之后的fill()填充屏幕过程中,会优先从Scrap列表里面寻找对应的ViewHolder填充。从Scrap中直接返回的ViewHolder内容没有任何的变化,不会进行重新创建和绑定的过程。

Scrap列表存在于Recycler模块中,

 public final class Recycler {
        final ArrayList<ViewHolder> mAttachedScrap = new ArrayList<>();
        ArrayList<ViewHolder> mChangedScrap = null;
        ...
}

可以看到,Scrap实际上包括了两个ViewHolder类型的ArrayList。mAttachedScrap负责保存将会原封不动的ViewHolder,而mChangedScrap负责保存位置会发生移动的ViewHolder,注意只是位置发生移动,内容仍旧是原封不动的。

Scrap.png

上图描述的是我们在一个RecyclerView中删除B项,并且调用了notifyItemRemoved()时,mAttachedScrap与mChangedScrap分别会临时存储的View情况。此时,A是在删除前后完全没有变化的,它会被临时放入mAttachedScrap。B是我们要删除的,它也会被放进mAttachedScrap,但是会被额外标记REMOVED,并且在之后会被移除。C和D在删除B后会向上移动位置,因此他们会被临时放入mChangedScrap中。E在此次操作前并没有出现在屏幕中,它不属于Scrap需要管辖的,Scrap只会缓存屏幕上已经加载出来的ViewHolder。在删除时,A,B,C,D都会进入Scrap,而在删除后,A,C,D都会回来,其中C,D只进行了位置上的移动,其内容没有发生变化。

RecyclerView的局部刷新,依赖的就是Scrap的临时缓存,我们需要通过notifyItemRemoved()notifyItemChanged()等系列方法通知RecyclerView哪些位置发生了变化,这样RecyclerView就能在处理这些变化的时候,使用Scrap来缓存其它内容没有发生变化的ViewHolder,于是就完成了局部刷新。需要注意的是,如果我们使用notifyDataSetChanged()方法来通知RecyclerView进行更新,其会标记所有屏幕上的View为FLAG_INVALID,从而不会尝试使用Scrap来缓存一会儿还会回来的ViewHolder,而是统统直接扔进RecycledViewPool池子里,回来的时候就要重新走一遍绑定的过程。

Scrap只是作为布局时的临时缓存,它和滑动时的缓存没有任何关系,它的detach和重新attach只临时存在于布局的过程中。布局结束时Scrap列表应该是空的,其成员要么被重新布局出来,要么将被移除,总之在布局过程结束的时候,两个Scrap列表中都不应该再存在任何东西。

CacheView是什么?

CacheView是一个以ViewHolder为单位,负责在RecyclerView列表位置产生变动的时候,对刚刚移出屏幕的View进行回收复用的缓存列表。

 public final class Recycler {
        ...
        final ArrayList<ViewHolder> mCachedViews = new ArrayList<ViewHolder>();
        int mViewCacheMax = DEFAULT_CACHE_SIZE;
        ...
}

我们可以看到,在Recycler中,mCachedView与前面的Scrap一样,也是一个以ViewHolder为单位存储的ArrayList。这意味着,它也是对于ViewHolder整个进行缓存,在复用时不需要经过创建和绑定过程,内容不发生改变。而且它有个最大缓存个数限制,默认情况下是2个。

CacheView.png

从上图中可以看出,CacheView将会缓存刚变为不可见的View。这个缓存工作的进行,是发生在fill()调用时的,由于布局更新和滑动时都会调用fill()来进行填充,因此这个场景在滑动过程中会反复出现,在布局更新时也可能因为位置变动而出现。fill()几经周转最终会调用recycleViewHolderInternal(),里面将会出现mCachedViews.add()。上面提到,CacheView有最大缓存个数限制,那么如果超过了缓存会怎样呢?

void recycleViewHolderInternal(ViewHolder holder) {
            ...
            if (forceRecycle || holder.isRecyclable()) {
                if (mViewCacheMax > 0
                        && !holder.hasAnyOfTheFlags(ViewHolder.FLAG_INVALID
                                | ViewHolder.FLAG_REMOVED | ViewHolder.FLAG_UPDATE)) {
                    // Retire oldest cached view 回收并替换最先缓存的那个
                    int cachedViewSize = mCachedViews.size();
                    if (cachedViewSize >= mViewCacheMax && cachedViewSize > 0) {
                        recycleCachedViewAt(0);
                        cachedViewSize--;
                    }
                    mCachedViews.add(targetCacheIndex, holder);
                }
               ...
        }

recycleViewHolderInternal()中有这么一段,如果Recycler发现缓存进来一个新ViewHolder时,会超过最大限制,那么它将先调用recycleCachedViewAt(0)将最先缓存进来的那个ViewHolder回收进RecycledViewPool池子里,然后再调用mCachedViews.add()添加新的缓存。也就是说,我们在滑动RecyclerView的时候,Recycler会不断地缓存刚刚滑过变成不可见的View进入CacheView,在达到CacheView的上限时,又会不断地替换CacheView里的ViewHolder,将它们扔进RecycledViewPool里。如果我们一直朝同一个方向滑动,CacheView其实并没有在效率上产生帮助,它只是不断地在把后面滑过的ViewHolder进行了缓存;而如果我们经常上下来回滑动,那么CacheView中的缓存就会得到很好的利用,毕竟复用CacheView中的缓存的时候,不需要经过创建和绑定的消耗。

RecycledViewPool是什么?

前面提到,在Srap和CacheView不愿意缓存的时候,都会丢进RecycledViewPool进行回收,因此RecycledViewPool可以说是Recycler中的一个终极回收站。

 public static class RecycledViewPool {
        private SparseArray<ArrayList<ViewHolder>> mScrap =
                new SparseArray<ArrayList<ViewHolder>>();
        private SparseIntArray mMaxScrap = new SparseIntArray();
        private int mAttachCount = 0;

        private static final int DEFAULT_MAX_SCRAP = 5;

我们可以在RecyclerView中找到RecycledViewPool,可以看见它的保存形式是和上述的Srap、CacheView都不同的,它是以一个SparseArray嵌套一个ArrayList对ViewHolder进行保存的。原因是RecycledViewPool保存的是以ViewHolder的viewType为区分(我们在重写RecyclerView的onCreateViewHolder()时可以发现这里有个viewType参数,可以借助它来实现展示不同类型的列表项)的多个列表。

与前两者不同,RecycledViewPool在进行回收的时候,目标只是回收一个该viewType的ViewHolder对象,并没有保存下原来ViewHolder的内容,在复用时,将会调用bindViewHolder()按照我们在onBindViewHolder()描述的绑定步骤进行重新绑定,从而摇身一变变成了一个新的列表项展示出来。

同样,RecycledViewPool也有一个最大数量限制,默认情况下是5。在没有超过最大数量限制的情况下,Recycler会尽量把将被废弃的ViewHolder回收到RecycledViewPool中,以期能被复用。值得一提的是,RecycledViewPool只会按照ViewType进行区分,只要ViewType是相同的,甚至可以在多个RecyclerView中进行通用的复用,只要为它们设置同一个RecycledViewPool就可以了。

总的来看,RecyclerView着重在两个场景使用缓存与回收复用进行了性能上的优化。一是,在数据更新时,利用Scrap实现局部更新,尽可能地减少没有被更改的View进行无用地重新创建与绑定工作。二是,在快速滑动的时候,重复利用已经滑过的ViewHolder对象,以尽可能减少重新创建ViewHolder对象时带来的压力。总体的思路就是:只要没有改变,就直接重用;只要能不创建或重新绑定,就尽可能地偷懒。

上一篇下一篇

猜你喜欢

热点阅读