美文网首页
Android-RecycleView机制

Android-RecycleView机制

作者: _喝喝酒吹吹风_ | 来源:发表于2020-10-11 21:59 被阅读0次

    RecyclerView的各个职责类

    • LayoutManager:RecyclerView的布局管理者,主要负责对于RecyclerView子View的测量和布局工作。
    • RecyclerView.Recycler:缓存的核心类。RecyclerView强大的缓存能力都是基于这个类来实现的。是缓存的核心工具类。
    • Adapter:Adapter的基类。负责将ViewHolder中的数据和RecyclerView中的控件进行绑定处理。
    • ViewHolder:视图和元数据类。它持有了要显示的数据信息,包括位置、View、ViewType等。

    LayoutManager

    测量
    //RecyclerView.java
        protected void onMeasure(int widthSpec, int heightSpec) {
            //dispatchLayoutStep1,dispatchLayoutStep2,dispatchLayoutStep3肯定会执行,但是会根据具体的情况来区分是在onMeasure还是onLayout中执行。
            if (mLayout == null) {//LayoutManager为空,那么就使用默认的测量策略
                defaultOnMeasure(widthSpec, heightSpec);
                return;
            }
            if (mLayout.mAutoMeasure) {
                //有LayoutManager,开启了自动测量
                final int widthMode = MeasureSpec.getMode(widthSpec);
                final int heightMode = MeasureSpec.getMode(heightSpec);
                final boolean skipMeasure = widthMode == MeasureSpec.EXACTLY && heightMode == MeasureSpec.EXACTLY;
                //步骤1  调用LayoutManager的onMeasure方法来进行测量工作
                mLayout.onMeasure(mRecycler, mState, widthSpec, heightSpec);
                //如果width和height都已经是精确值,那么就不用再根据内容进行测量,后面步骤不再处理
                if (skipMeasure || mAdapter == null) {
                    return;
                }
                //如果测量过程后的宽或者高都没有精确,那么就需要根据child来进行布局,从而来确定其宽和高。
                // 当前的布局状态是start
                if (mState.mLayoutStep == State.STEP_START) {
                    //布局的第一部 主要进行一些初始化的工作
                    dispatchLayoutStep1();
                }
                mLayout.setMeasureSpecs(widthSpec, heightSpec);
                mState.mIsMeasuring = true;
                //执行布局第二步。先确认子View的大小与布局
                dispatchLayoutStep2();
                // 布局过程结束,根据Children中的边界信息计算并设置RecyclerView长宽的测量值
                mLayout.setMeasuredDimensionFromChildren(widthSpec, heightSpec);
                //检查是否需要再此测量。如果RecyclerView仍然有非精确的宽和高,或者这里还有至少一个Child还有非精确的宽和高,我们就需要再次测量。
                // 比如父子尺寸属性互相依赖的情况,要改变参数重新进行一次
                if (mLayout.shouldMeasureTwice()) {
                    mLayout.setMeasureSpecs(MeasureSpec.makeMeasureSpec(getMeasuredWidth(), MeasureSpec.EXACTLY),MeasureSpec.makeMeasureSpec(getMeasuredHeight(), MeasureSpec.EXACTLY));
                    mState.mIsMeasuring = true;
                    dispatchLayoutStep2();
                    // now we can get the width and height from the children.
                    mLayout.setMeasuredDimensionFromChildren(widthSpec, heightSpec);
                }
            } else {
                //有LayoutManager,没有开启自动测量。一般系统的三个LayoutManager都是自动测量,
                // 如果是我们自定义的LayoutManager的话,可以通过setAutoMeasureEnabled关闭自动测量功能
                //RecyclerView已经设置了固定的Size,直接使用固定值即可
                if (mHasFixedSize) {
                    mLayout.onMeasure(mRecycler, mState, widthSpec, heightSpec);
                    return;
                }
                //如果在测量过程中数据发生变化,需要先对数据进行处理
                ...
                // 处理完新更新的数据,然后执行自定义测量操作。
                if (mAdapter != null) {
                    mState.mItemCount = mAdapter.getItemCount();
                } else {
                    mState.mItemCount = 0;
                }
                eatRequestLayout();
                //没有设置固定的宽高,则需要进行测量
                mLayout.onMeasure(mRecycler, mState, widthSpec, heightSpec);
                resumeRequestLayout(false);
                mState.mInPreLayout = false;
            }
        }
    

    测量分为3种情况

    1. 没有设置LayoutManager:使用默认的测量方案。
    2. 设置了LayoutManager 但是没有开启自动测量:关闭的情况下不需要考虑子View的大小和布局。直接按照正常的流程来进行测量即可。如果直接已经设置了固定的宽高,那么直接使用固定值即可。如果没有设置固定宽高,那么就按照正常的控件一样,根据父级的要求与自身的属性进行测量。
      3.设置了LayoutManager 开启了自动测量:
      这种情况是最复杂的,需要根据子View的布局来调整自身的大小。需要知道子View的大小和布局。所以RecyclerView将布局的过程提前到这里来进行了。
    //RecyclerView.java
    if (mLayout.mAutoMeasure) {
                //调用LayoutManager的onMeasure方法来进行测量工作
                mLayout.onMeasure(mRecycler, mState, widthSpec, heightSpec);
                //如果width和height都已经是精确值,那么就不用再根据内容进行测量,后面步骤不再处理
                if (skipMeasure || mAdapter == null) {
                    return;
                }
                if (mState.mLayoutStep == State.STEP_START) {
                    //布局的第一部 主要进行一些初始化的工作
                    dispatchLayoutStep1();
                }
                ...
                //开启了自动测量,需要先确认子View的大小与布局
                dispatchLayoutStep2();
                ...
                //再根据子View的情况决定自身的大小
                mLayout.setMeasuredDimensionFromChildren(widthSpec, heightSpec);
    
                if (mLayout.shouldMeasureTwice()) {
                    ...
                    //如果有父子尺寸属性互相依赖的情况,要改变参数重新进行一次
                    dispatchLayoutStep2();
                    mLayout.setMeasuredDimensionFromChildren(widthSpec, heightSpec);
                }
            }
    

    对于RecyclerView的测量和绘制工作,是需要 dispatchLayoutStep1 ,dispatchLayoutStep2 , dispatchLayoutStep3 这三步来执行的,step1里是进行预布局,主要跟记录数据更新时需要进行的动画所需的信息有关,step2就是实际循环执行了子View的测量布局的一步,而step3主要是用来实际执行动画。而且通过 mLayoutStep记录了当前执行到了哪一步。在开启自动测量的情况下如果没有设置固定宽高,那么会执行setp1和step2。在step2执行完后就可以调用setMeasuredDimensionFromChildren 方法,根据子类的测量布局结果来设置自身的大小。

    我们先不进行分析step1,step2和step3的具体功能。直接把 onLayout 的代码也贴出来,看一下这3步是如何保证都能够执行的。

    //RecyclerView.java
        @Override
        protected void onLayout(boolean changed, int l, int t, int r, int b) {
            dispatchLayout();
        }
    
        void dispatchLayout() {
            if (mAdapter == null) {//没有设置adapter,返回
                Log.e(TAG, "No adapter attached; skipping layout");
                // leave the state in START
                return;
            }
            if (mLayout == null) {//没有设置LayoutManager,返回
                Log.e(TAG, "No layout manager attached; skipping layout");
                // leave the state in START
                return;
            }
            mState.mIsMeasuring = false;
            //在onMeasure阶段,如果宽高是固定的,那么mLayoutStep == State.STEP_START 而且dispatchLayoutStep1和dispatchLayoutStep2不会调用
            //所以这里就会调用一下
            if (mState.mLayoutStep == State.STEP_START) {
                dispatchLayoutStep1();
                mLayout.setExactMeasureSpecsFrom(this);
                dispatchLayoutStep2();
            } else if (mAdapterHelper.hasUpdates() || mLayout.getWidth() != getWidth()|| mLayout.getHeight() != getHeight()) {
                //在onMeasure阶段,如果执行了dispatchLayoutStep1,但是没有执行dispatchLayoutStep2,就会执行dispatchLayoutStep2
                mLayout.setExactMeasureSpecsFrom(this);
                dispatchLayoutStep2();
            } else {
                mLayout.setExactMeasureSpecsFrom(this);
            }
            //最终调用dispatchLayoutStep3
            dispatchLayoutStep3();
        }
    

    可以看到,其实在 onLayout 阶段会根据 onMeasure 阶段3个步骤执行到了哪个,然后会在 onLayout 中把剩下的步骤执行。

    OK,到现在整个流程通了,在这3个步骤中,step2就是执行了子View的测量布局的一步,也是最重要的一环,所以我们将关注的重点放在这个函数。

    //RecyclerView.java
        private void dispatchLayoutStep2() {
            //禁止布局请求
            eatRequestLayout();
            ...
            mState.mInPreLayout = false;
            //调用LayoutManager的layoutChildren方法来布局
            mLayout.onLayoutChildren(mRecycler, mState);
            ...
            resumeRequestLayout(false);
        }
    

    这里调用LayoutManager的 onLayoutChildren 方法,将对于子View的测量和布局工作交给了LayoutManager。而且我们在自定义LayoutManager的时候也必须要重写这个方法来描述我们的布局策略。这里我们分析最经常使用的 LinearLayoutManager(后面简称LLM) 。我们这里只研究垂直方向布局的情况。

    //LinearLayoutManager.java
        public void onLayoutChildren(RecyclerView.Recycler recycler, RecyclerView.State state) {
            // layout algorithm:
            // 1) by checking children and other variables, find an anchor coordinate and an anchor
            //  item position.
            // 2) fill towards start, stacking from bottom
            // 3) fill towards end, stacking from top
            // 4) scroll to fulfill requirements like stack from bottom.
            // create layout state
    
    锚点的选择

    AnchorInfo 类需要能够有效的描述一个具体的位置信息,我们首先类内部的几个重要的成员变量。

    //LinearLayoutManager.java
        //简单的数据类来保存锚点信息
        class AnchorInfo {
            //锚点参考View在整个数据中的position信息,即它是第几个View
            int mPosition;
            //锚点的具体坐标信息,填充子View的起始坐标。当positon=0的时候,如果只有一半View可见,那么这个数据可能为负数
            int mCoordinate;
            //是否从底部开始布局
            boolean mLayoutFromEnd;
            //是否有效
            boolean mValid;
    

    可以看到,通过 AnchorInfo 就可以准确的定位当前的位置信息了。那么在LLM中,这个锚点的位置是如何确定的呢?

    我们从源码中去寻找答案。

    //LinearLayoutManager.java
        public void onLayoutChildren(RecyclerView.Recycler recycler, RecyclerView.State state) {
            ...
            //确认LayoutState存在
            ensureLayoutState();
            //禁止回收
            mLayoutState.mRecycle = false;
            //计算是否需要颠倒绘制。是从底部到顶部绘制,还是从顶部到底部绘制(在LLM的构造函数中,其实可以设置反向绘制)
            resolveShouldLayoutReverse();
            //如果当前锚点信息非法,滑动到的位置不可用或者有需要恢复的存储的SaveState
            if (!mAnchorInfo.mValid || mPendingScrollPosition != NO_POSITION || mPendingSavedState != null) {
                //重置锚点信息
                mAnchorInfo.reset();
                //是否从end开始进行布局。因为mShouldReverseLayout和mStackFromEnd默认都是false,那么我们这里可以考虑按照默认的情况来进行分析,也就是mLayoutFromEnd也是false
                mAnchorInfo.mLayoutFromEnd = mShouldReverseLayout ^ mStackFromEnd;
                //计算锚点的位置和坐标
                updateAnchorInfoForLayout(recycler, state, mAnchorInfo);
                //设置锚点有效
                mAnchorInfo.mValid = true;
            }
    

    在需要确定锚点的时候,会先将锚点进行初始化,然后通过updateAnchorInfoForLayout 方法来确定锚点的信息。

    //LinearLayoutManager.java
        private void updateAnchorInfoForLayout(RecyclerView.Recycler recycler, RecyclerView.State state, AnchorInfo anchorInfo) {
            //从挂起的数据更新锚点信息  这个方法一般不会调用到
            if (updateAnchorFromPendingData(state, anchorInfo)) {
                return;
            }
            //**重点方法 从子View来确定锚点信息(这里会尝试从有焦点的子View或者列表第一个位置的View或者最后一个位置的View来确定)
            if (updateAnchorFromChildren(recycler, state, anchorInfo)) {
                return;
            }
            //进入这里说明现在都没有确定锚点(比如设置Data后还没有绘制View的情况下),就直接设置RecyclerView的顶部或者底部位置为锚点(按照默认情况,这里的mPosition=0)。
            anchorInfo.assignCoordinateFromPadding();
            anchorInfo.mPosition = mStackFromEnd ? state.getItemCount() - 1 : 0;
        }
    

    在需要确定锚点的时候,会先将锚点进行初始化,然后通过updateAnchorInfoForLayout 方法来确定锚点的信息。

    //LinearLayoutManager.java
        private void updateAnchorInfoForLayout(RecyclerView.Recycler recycler, RecyclerView.State state, AnchorInfo anchorInfo) {
            //从挂起的数据更新锚点信息  这个方法一般不会调用到
            if (updateAnchorFromPendingData(state, anchorInfo)) {
                return;
            }
            //**重点方法 从子View来确定锚点信息(这里会尝试从有焦点的子View或者列表第一个位置的View或者最后一个位置的View来确定)
            if (updateAnchorFromChildren(recycler, state, anchorInfo)) {
                return;
            }
            //进入这里说明现在都没有确定锚点(比如设置Data后还没有绘制View的情况下),就直接设置RecyclerView的顶部或者底部位置为锚点(按照默认情况,这里的mPosition=0)。
            anchorInfo.assignCoordinateFromPadding();
            anchorInfo.mPosition = mStackFromEnd ? state.getItemCount() - 1 : 0;
        }
    

    锚点的确定方案主要有3个:

    • 从挂起的数据获取锚点信息。一般不会执行。
    • 从子View来确定锚点信息。比如说notifyDataSetChanged方法的时候,屏幕上原来是有View的,那么就会通过这种方式获取
    • 如果上面两种方法都无法确定,则直接使用0位置的View作为锚点参考position。
      最后一种什么时候会发生呢?其实就是没有子View让我们作为参考。比如说第一次加载数据的时候,RecyclerView一片空白。这时候肯定没有任何子View能够让我们作为参考。

    那么当有子View的时候,我们通过 updateAnchorFromChildren 方法来确定锚点位置。

    //LinearLayoutManager.java
        //从现有子View中确定锚定。大多数情况下,是起始或者末尾的有效子View(一般是未移除,展示在我们面前的View)。
        private boolean updateAnchorFromChildren(RecyclerView.Recycler recycler, RecyclerView.State state, AnchorInfo anchorInfo) {
            //没有数据,直接返回false
            if (getChildCount() == 0) {
                return false;
            }
            final View focused = getFocusedChild();
            //优先选取获得焦点的子View作为锚点
            if (focused != null && anchorInfo.isViewValidAsAnchor(focused, state)) {
                //保持获取焦点的子view的位置信息
                anchorInfo.assignFromViewAndKeepVisibleRect(focused);
                return true;
            }
            if (mLastStackFromEnd != mStackFromEnd) {
                return false;
            }
            //根据锚点的设置信息,从底部或者顶部获取子View信息
            View referenceChild = anchorInfo.mLayoutFromEnd ? findReferenceChildClosestToEnd(recycler, state) : findReferenceChildClosestToStart(recycler, state);
            if (referenceChild != null) {
                anchorInfo.assignFromView(referenceChild);
                ...
                return true;
            }
            return false;
        }
    

    通过子View确定锚点坐标也是进行了3种情况的处理

    • 没有数据,直接返回获取失败
    • 如果某个子View持有焦点,那么直接把持有焦点的子View作为锚点参考点
    • 没有子View持有焦点,一般会选择最上(或者最下面)的子View作为锚点参考点

    一般情况下,都会使用第三种方案来确定锚点,所以我们这里也主要关注一下这里的方法。按照我们默认的变量信息,这里会通过 findReferenceChildClosestToStart 方法获取可见区域中的第一个子View作为锚点的参考View。然后调用 assignFromView 方法来确定锚点的几个属性值。

    //LinearLayoutManager.java
            public void assignFromView(View child) {
                if (mLayoutFromEnd) {
                    //如果是从底部布局,那么获取child的底部的位置设置为锚点
                    mCoordinate = mOrientationHelper.getDecoratedEnd(child) + mOrientationHelper.getTotalSpaceChange();
                } else {
                    //如果是从顶部开始布局,那么获取child的顶部的位置设置为锚点坐标(这里要考虑ItemDecorator的情况)
                    mCoordinate = mOrientationHelper.getDecoratedStart(child);
                }
                //mPosition赋值为参考View的position
                mPosition = getPosition(child);
            }
    

    mPostion这个变量很好理解,就是子View的位置值,那么 mCoordinate 是个什么鬼?我们 getDecoratedStart 是怎么处理的就知道了。

    //LinearLayoutManager.java
            //创建mOrientationHelper。我们按照垂直布局来进行分析
            if (mOrientationHelper == null) {
                mOrientationHelper = OrientationHelper.createOrientationHelper(this, mOrientation);
            }
            //OrientationHelper.java
        public static OrientationHelper createVerticalHelper(RecyclerView.LayoutManager layoutManager) {
            return new OrientationHelper(layoutManager) {
                @Override
                @Override
                public int getDecoratedStart(View view) {
                    final RecyclerView.LayoutParams params =  (RecyclerView.LayoutParams)view.getLayoutParams();
                    //
                    return mLayoutManager.getDecoratedTop(view) - params.topMargin;
                }
    

    比较难理么,我们上个简陋的图解释一下


    recycleView锚点.png

    可以看到在使用子控件进行锚点信息确认时,一般会选择屏幕中可见的子View的position为锚点。这里会选取屏幕上第一个可见View,也就是positon=1的子View作为参考点,mCoordinate 被赋值为1号子View上面的Decor的顶部位置。

    布局填充

    回到主线 onLayoutChildren 函数。当我们的锚点信息确认以后,剩下的就是从这个位置开始进行布局的填充。

    if (mAnchorInfo.mLayoutFromEnd) {//从end开始布局
                //倒着绘制的话,先从锚点往上,绘制完再从锚点往下
                //设置绘制方向信息为从锚点往上
                updateLayoutStateToFillStart(mAnchorInfo);
                mLayoutState.mExtra = extraForStart;
                fill(recycler, mLayoutState, state, false);
                startOffset = mLayoutState.mOffset;
                final int firstElement = mLayoutState.mCurrentPosition;
                if (mLayoutState.mAvailable > 0) {
                    extraForEnd += mLayoutState.mAvailable;
                }
                //设置绘制方向信息为从锚点往下
                updateLayoutStateToFillEnd(mAnchorInfo);
                mLayoutState.mExtra = extraForEnd;
                mLayoutState.mCurrentPosition += mLayoutState.mItemDirection;
                fill(recycler, mLayoutState, state, false);
                endOffset = mLayoutState.mOffset;
    
                if (mLayoutState.mAvailable > 0) {
                    extraForStart = mLayoutState.mAvailable;
                    updateLayoutStateToFillStart(firstElement, startOffset);
                    mLayoutState.mExtra = extraForStart;
                    fill(recycler, mLayoutState, state, false);
                    startOffset = mLayoutState.mOffset;
                }
            } else {//从起始位置开始布局
                // 更新layoutState,设置布局方向朝下
                updateLayoutStateToFillEnd(mAnchorInfo);
                mLayoutState.mExtra = extraForEnd;
                //开始填充布局
                fill(recycler, mLayoutState, state, false);
                //结束偏移
                endOffset = mLayoutState.mOffset;
                //绘制后的最后一个view的position
                final int lastElement = mLayoutState.mCurrentPosition;
                if (mLayoutState.mAvailable > 0) {
                    extraForStart += mLayoutState.mAvailable;
                }
                //更新layoutState,设置布局方向朝上
                updateLayoutStateToFillStart(mAnchorInfo);
                mLayoutState.mExtra = extraForStart;
                mLayoutState.mCurrentPosition += mLayoutState.mItemDirection;
                //再次填充布局
                fill(recycler, mLayoutState, state, false);
                //起始位置的偏移
                startOffset = mLayoutState.mOffset;
    
                if (mLayoutState.mAvailable > 0) {
                    extraForEnd = mLayoutState.mAvailable;
                    updateLayoutStateToFillEnd(lastElement, endOffset);
                    mLayoutState.mExtra = extraForEnd;
                    fill(recycler, mLayoutState, state, false);
                    endOffset = mLayoutState.mOffset;
                }
            }
    

    可以看到,根据不同的绘制方向,这里面做了不同的处理,只是填充的方向相反而已,具体的步骤是相似的。都是从锚点开始往一个方向进行View的填充,填充满以后再朝另一个方向填充。填充子View使用的是 fill() 方法。

    因为对于绘制方向都按照默认的来处理,所以这里我们看看分析else的代码,而且第一次填充是朝下填充。

    //在LinearLayoutManager中,进行界面重绘和进行滑动两种情况下,往屏幕上填充子View的工作都是调用fill()进行
        int fill(RecyclerView.Recycler recycler, LayoutState layoutState, RecyclerView.State state, boolean stopOnFocusable) {
            //可用区域的像素数
            final int start = layoutState.mAvailable;
            if (layoutState.mScrollingOffset != LayoutState.SCROLLING_OFFSET_NaN) {
                if (layoutState.mAvailable < 0) {
                    layoutState.mScrollingOffset += layoutState.mAvailable;
                }
                //将滑出屏幕的View回收掉
                recycleByLayoutState(recycler, layoutState);
            }
            //剩余绘制空间=可用区域+扩展空间。
            int remainingSpace = layoutState.mAvailable + layoutState.mExtra;
    

    在 fill 方法中,会判断当前的是否还有剩余区域可以进行子View的填充。如果没有剩余区域或者没有子View,那么就返回。否则就通过 layoutChunk 来进行填充工作,填充完毕以后更新当前的可用区域,然后依次遍历循环,直到不满足条件为止。

    循环中的填充是通过 layoutChunk 来实现的。

    void layoutChunk(RecyclerView.Recycler recycler, RecyclerView.State state,LayoutState layoutState, LayoutChunkResult result) {
            //通过缓存获取当前position所需要展示的ViewHolder的View
            View view = layoutState.next(recycler);
            if (view == null) {
                //如果我们将视图放置在废弃视图中,这可能会返回null,这意味着没有更多的项需要布局。
                result.mFinished = true;
                return;
            }
            LayoutParams params = (LayoutParams) view.getLayoutParams();
            if (layoutState.mScrapList == null) {
                //根据方向调用addView方法添加子View
                if (mShouldReverseLayout == (layoutState.mLayoutDirection == LayoutState.LAYOUT_START)) {
                    addView(view);
                } else {
                    addView(view, 0);
                }
            } else {
                if (mShouldReverseLayout == (layoutState.mLayoutDirection == LayoutState.LAYOUT_START)) {
                    //这里是即将消失的View,但是需要设置对应的移除动画
                    addDisappearingView(view);
                } else {
                    addDisappearingView(view, 0);
                }
            }
            //调用measure测量view。这里会考虑到父类的padding
            measureChildWithMargins(view, 0, 0);
            //将本次子View消费的区域设置为子view的高(或者宽)
            result.mConsumed = mOrientationHelper.getDecoratedMeasurement(view);
            //找到view的四个边角位置
            int left, top, right, bottom;
            ...
            //调用child.layout方法进行布局(这里会考虑到view的ItemDecorator等信息)
            layoutDecoratedWithMargins(view, left, top, right, bottom);
            //如果视图未被删除或更改,则使用可用空间
            if (params.isItemRemoved() || params.isItemChanged()) {
                result.mIgnoreConsumed = true;
            }
            result.mFocusable = view.isFocusable();
        }
    

    这里主要做了5个处理

    1. 通过 layoutState 获取要展示的View
    2. 通过 addView 方法将子View添加到布局中
    3. 调用 measureChildWithMargins 方法测量子View
    4. 调用 layoutDecoratedWithMargins 方法布局子View
    5. 根据处理的结果,填充LayoutChunkResult的相关信息,以便返回之后,能够进行数据的计算。

    如果只是考虑第一次数据加载,那么到目前为止,我们的整个页面通过两次 fill 就能够将整个屏幕填充完毕了。

    RecyclerView 的缓存机制

    缓存分级
    • AttachedScrap:缓存当前在屏幕上的ViewHolder,因为每个ViewGroup都至少会onLayout两次,所以在第一次onLayout的时候记录在mAttachedScrap上,第二次onLayout的时候就可以从mAttachedScrap上直接获取,避免重复的创建,而在第二次layout过程完之后它也就被清空了,它不用重新onBindViewHolder()的,因为本来就是屏幕上对应好的位置,在 整个布局过程中都能使用。
    • ChangedScrap:ChangedScrap保存的是那些将要做改变的View。
      我们知道RecyclerView提供了局部刷新的功能,比如我们通过调用getAdapter().notifyItemChanged(int postion);就能修改某个位置
      所以那个位置上在经过Scrap缓存时会到mChangedScrap,其余的就到mAttachedScrap。只能在预布局阶段使用
    • CachedViews:在我们滑动过程中,优先复用的就是来自这个mCachedViews中,里面会暂存在RecycleView滑出的ViewHoder,而暂存的数量在RecycleView里面默认是2

    由这个mCachedViews缓存的ViewHoder的位置信息都在而且也是对应的,所以它也是不需要重新onBindViewHolder()的

    • RecyclerPool: 看名字带有Pool,显然它是一个缓存池。通过这个缓存池可以把移出屏幕的,并且没有在CacheViews中的(达到最大缓存数)缓存下来,不同的是,它会清空ViewHoder的信息,而且内部也会记录ViewHoder对应的viewType

    所以当要复用的时候,需要通过指定的viewType来缓存池中获取一个ViewHolder,而此时这个ViewHoder的数据是被清空的,所以需要调用onBindViewHolder()来重新绑定数据

    • ViewCacheExtension: 看名字是一个拓展用的缓存机制,在系统的缓存机制里,并没有去实现它。(也就是默认mViewCacheExtension=null),要使用它的话我们必须继承ViewCacheExtension这个抽象类重写它的抽象方法:getViewForPositionAndType来完成自定义的缓存机制
    读取缓存顺序
    1. 尝试从 ChangedScrap 中获取,只有在 mState.isPreLayout() 为 true 时,也就是预布局阶段,才会做这次尝试
    2. getScrapOrHiddenOrCachedHolderForPosition() 获得 ViewHolder。尝试从 1. Recycler ::AttachedScrap 2.mHiddenViews 3. Recycler ::mCachedViews 中查找 ViewHolder。如果找到ViewHolder则验证其有效性,如果验证失败则回收到RecycleViewPool中,验证成功则直接使用。(有效性的定义是啥
    3. 如果给 Adapter 设置了 stableId,调用 getScrapOrCachedViewForId 尝试获取 ViewHolder。跟第二步的区别在于,之前是根据 position 查找,现在是根据 id 查找。
    4. mViewCacheExtension 不为空的话,则调用 ViewCacheExtension#getViewForPositionAndType 方法尝试获取 View。ViewCacheExtension 是由开发者设置的,默认情况下为空,一般我们也不会设置。这层缓存大部分情况下可以忽略。
    5. 尝试从 RecyclerViewPool 中获取,相比较于 mCachedViews,从 mRecyclerPool 中成功获取 ViewHolder 对象后并没有做合法性和 item 位置校验,只检验 viewType 是否一致。
    6. 如果都没有找到,则创建一个新的ViewHolder
    预布局,预测动画的作用

    理解「预布局」需要先了解「预测动画」。考虑这样一个场景:

    用户有 A、B、C 三个 item,A,B 刚好显示在屏幕中,这个时候,用户把 B 删除了,那么最终 C 会显示在 B 原来的位置


    recycleViewAnim.PNG

    如果 C 从底部平滑地滑动到之前 B 的位置将会更符合直觉。但是要做到这点实际上没那么简单。因为我们只知道 C 最终的位置,但是不知道 C 的起始位置在哪里,无法确定 C 应该从哪里滑动过来。如果根据最终的状态,就断定 C 应该要从底部滑动过来的话,很可能是有问题的。因为在其他 LayoutManager 中,它可能是从侧面或者是其他地方滑动过来的。
    那根据原状态与最终状态之间的差异,能不能得出我们应该执行什么样的切换动画呢?答案依然是 no。因为在原状态中,C 根本就不存在。(这个时候,我们并不知道,B 要被删除了,如果把 C 给加载出来,很可能是一种资源浪费。)
    设计 RecyclerView 的工程师是这么解决的。当 Adapter 发生变化的时候,RecyclerView 会让 LayoutManager 进行两次布局。

    • 第一次是预布局。将之前原状态 下的 item 都布局出来。并且根据 Adapter 的 notify 信息,我们知道哪些 item 即将变化了,所以可以加载出另外的 View。在上述例子中,因为知道 B 已经被删除了,所以可以把屏幕之外的 C 也加载出来。

    • 第二个,最终的布局,也就是变化完成之后的布局。


      recycleViewPrelayout.png

      这样只要比较前后布局的变化,就能得出应该执行什么动画了。

    这种负责执行动画的 view 在原布局或新布局中不存在的动画,就称为预测动画。

    预布局是实现预测动画的一个步骤。

    关于 Scrap

    Scrap 缓存列表(mChangedScrap、mAttachedScrap)是 RecyclerView 最先查找 ViewHolder 地方,它跟 RecyclerViewPool 或者 ViewCache 有很大的区别。
    mChangedScrap 和 mAttachedScrap 只在布局阶段使用。其他时候它们是空的。布局完成之后,这两个缓存中的 viewHolder,会移到 mCacheView 或者 RecyclerViewPool 中。
    当 LayoutManager 开始布局的时候(预布局或者是最终布局),当前布局中的所有 view,都会被 dump 到 scrap 中(具体实现可见 LinearLayoutManager#onLayoutChildren() 方法中调用了 detachAndScrapAttachedViews() ),然后 LayoutManager 挨个地取回 view,除非 view 发生了什么变化,否则它会马上从 scrap 中回到原来的位置。


    recycleView-scrap.png

    以上图为例,我们删除掉 b,调用 notifyItemRemove 方法,触发重新布局,这时 a,b,c 都会被 dump 到 scrap 中,然后 LayoutManager 会从 scrap 中取回 a 和 c。
    偏个题,这个时候,b 去哪了? 比对scrap和最终的布局中,发现b不在最终布局中,会 unscrap 它,让它执行一个消失的动画然后隐藏。动画执行完之后,b 被放到 RecyclerViewPool 中。
    为什么 LayoutManager 需要先执行 detach,然后再重新 attach 这些 view,而不是只移除哪些变化的子 view 呢?Scrap 缓存列表的存在,是为了隔离 LayoutManager 和 RecyclerView.Recycler 之间的关注点/职责。LayoutManager 不需要知道哪一个子 view 应该保留 或者是 应该被回收到 pool 亦或者其他什么地方。这是 Recycler 的职责。
    除了在布局时不为空外,还有另一个与 scrap 有关的规律:所有 scrap 的 view 都会跟 RecyclerView 分离。ViewGroup 中的 attachView 和 detachView 方法跟 addView 和 removeView 方法很像,但是不会触发请求布局会重绘的事件。它们只是从 ViewGroup 的子 view 列表中删除对应的子 view,并将该子 view 的 parent 设置为 null。detached 状态必须是临时,后面紧随着 attach 或者 remove 事件
    如果在计算一个新布局的时候,已经添加了一堆子 view,可以放心的将它们全部 detach ,Recyclerview 就是这么做的。

    Hidden Views 是什么?

    前面提到在第二次尝试获取 ViewHolder 的时候,有一个子步骤会从 hidden view 中搜索,这里的 hidden view 指的是什么?「hidden view」指的是那些正在从 RecyclerView 边界中脱离的 view。为了让这些 view 正确地执行对应的分离动画,它们仍然作为 RecyclerView 的子 view 被保留下来。
    站在 LayoutManager 的角度,这些 view 已经不存在了,因此不应该被包含在计算里面。比如 在部分 view 正在执行消失动画的过程中,调用 LayoutManager#getChildAt 方法,这些 view 不算在下标里面。来自 LayoutManager 的所有对 getChildAt()、getChildCount()、addView() 等的方法调用 在应用到实际的可回收view 之前,都要通过 ChildHelper 处理,ChildHelper 的职责是重新计算非隐藏的子 view 列表和完整的子 view 列表之间的索引。
    请记住,我们正在搜索要提供给 LayoutManager 的视图,但是 LayoutManager 不应了解隐藏 View!
    举一个实际的🌰:这种让人费解的“从隐藏的 view 弹跳”(bouncing from hidden views)机制对于处理下面这种情况而言是很有必要的。 考虑这种场景,我们插入一个 item ,然后在插入动画完成之前,马上删除该 item:


    recycleView-anim-jump.png

    我们想要看到的是 b 从 c 移除时的位置开始向上平移。 但是在那个时候,b 是一个隐藏的 view! 如果我们忽略了它(“隐藏”的 b),那会导致在现有 b 下面创建一个新的 b。更糟糕的是,这两个 view 会重叠,因为 新的 b 会往上,旧的 b 会往下。 为了避免这种错误,在搜索 ViewHolder 的较早步骤之一中,RecyclerView 会询问 ChildHelper 是否具有合适的 hidden view。 所谓「合适」,表示这个 view 跟我们需要的位置相关联,并具有正确的 view type,并且这个 view 的被隐藏的原因不是为了移除掉它(我们不应该让被移除的 view 复活)
    如果有这样的 view ,RecyclerView 会将其返回到 LayoutManager 并将其添加到 preLayout 中以标记应从其进行动画处理的位置(详见 recordAnimationInfoIfBouncedHiddenView 方法)。
    什么?在 布局前后 添加内容不应该是 LayoutManager 的职责吗?怎么现在 RecyclerView 也在往 preLayout 中添加view? 是的,这种机制看起来有点职责部分,但这是也说明我们有必要了解它。

    Stable Id 的作用是什么?

    理解 stable Id 特性的最重要的一个点是,它只会在调用 notifyDataSetChanged 方法之后,影响 RecyclerView 的行为。
    如果调用 notifyDataSetChanged 的时候,Adapter 并没有设置 hasStableId,RecyclerView 不知道 发生了什么,哪一些东西变化了,所以,它假设所有的东西都变了,每一个 ViewHolder 都是无效的,因此应该把它们放到 RecyclerViewPool 而不是 scrap 中。
    下图的例子是将4号item移动到6号item下面。
    没有stable id的示例:


    stable-id-1.png

    8和9由于超过了Pool的最大数量,则直接删除回收,不进入Pool,导致后面再次创建。

    有 Stable Id的示例:


    stable-id-2.png

    ViewHolder 会进入 scrap 而不是 pool 中。然后会通过特定的 Id(Adapter 中的 getItemId 获取到的 id)而不是 postion 到 scrap 中查找 ViewHolder。

    有stable id的优势:

    1. 不会导致 RecyclerViewPool 溢出,因此非必须情况下,不需要创建新的 ViewHolder。之前的 ViewHolder 会重新绑定,因为 Id 没有变化不代表内容没有变化
    2. 最大好处的好处是 支持动画。上面移动 item4 到 item6 的位置。正常情况下,我们需要调用 notifyItemMoved(4,6) 才能得到一个移动动画。但是通过 stable id,调用 notifyDataSetChanged 也能支持这一点。因为 RecyclerView 可以看到特定 id 的 view 在新旧布局的上的位置。但不支持预测动画。
    优化实践
    • 尽量使用 notifyItemXxx 方法进行细粒度的通知更新,而不是 notifyDatasetChanged,如果变更前后是两个数据集,无法确定具体哪一些数据项变化了,可以考虑使用 DiffUtil 。如果数据集较大,建议结合使用 AsyncListDiffer 在子线程做 diff 运算。

    • 如果特定 viewType 的 item 只有一个,可以通过 RecyclerView#getRecycledViewPool()#setMaxRecycledViews(viewType,1); 来调整缓存区的大小,减少内存占用

    • 如果特定 viewType 的 item 特别多,但是不得不通过 notifyDataSetChange 方法更新数据,可以通过下面这种方式,在变更前调大缓存,变更完成后,调小缓存。这样布局变化也可以最大程度地复用已有的 ViewHolder。

    mRecyclerView.getRecycledViewPool().setMaxRecycledViews(0, 屏幕显示的item总数+7 );
    mAdapter.notifyDataSetChanged();
    new Handler().post(new Runnable() {
        @Override
        public void run() {
            mRecyclerView.getRecycledViewPool()
                    .setMaxRecycledViews(0, 5);
        }
    });
    
    • 如果 RecyclerView 中的每个 item 都是一个 RecyclerView, 并且子 RecyclerView 的 item type 相同可以通过 RecyclerView#setRecycledViewPool(); 方法,实现缓存池的复用。

    refrence

    RecyclerView源码解析
    阿里3轮面试都问了RecyclerView
    深入理解 RecyclerView 的缓存机制
    结合源码理解RecyclerView的四级缓存机制

    相关文章

      网友评论

          本文标题:Android-RecycleView机制

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