• RecyclerView刷新后定位问题


    问题描述

    做需求开发时,遇到RecyclerView刷新时,通常会使用notifyItemXXX方法去做局部刷新。但是刷新后,有时会遇到RecyclerView定位到我们不希望的位置,这时候就会很头疼。这周有时间深入了解了下RecyclerView的源码,大致梳理清楚刷新后位置跳动的原因了。

    原因分析

    先简单描述下RecyclerView在notify后的过程:

    1. 根据是否是全量刷新来选择触发RecyclerView.RecyclerViewDataObserver的onChanged方法或onItemRangeXXX方法

    onChanged会直接调用requestlayout来重新layuout。 onItemRangeXXX会先把刷新数据保存到mAdapterHelper中,然后再调用requestlayout

    1. 进入dispatchLayout流程 这一步分为三个步骤:
    • dispatchLayoutStep1:处理adapter的更新、决定哪些view执行动画、保存view的信息
    • dispatchLayoutStep2:真正执行childView的layout操作
    • dispatchLayoutStep3:触发动画、保存状态、清理信息

    需要注意的是,在onMeasure的过程中,如果传入的measureMode不是exactly,会去调用dispatchLayoutStep1和dispatchLayoutStep2从而取得真正需要的宽高。 所以在dispatchLayout会先判断是否需要重新执行dispatchLayoutStep1和dispatchLayoutStep2

    重点分析dispatchLayoutStep2这一步: 核心操作在 mLayout.onLayoutChildren(mRecycler, mState)这一行。以LinearLayoutManager为例继续往下挖:

    public void onLayoutChildren(RecyclerView.Recycler recycler, RecyclerView.State state) {
        ...
        final View focused = getFocusedChild();
        if (!mAnchorInfo.mValid || mPendingScrollPosition != RecyclerView.NO_POSITION
                    || mPendingSavedState != null) {
                mAnchorInfo.reset();
                mAnchorInfo.mLayoutFromEnd = mShouldReverseLayout ^ mStackFromEnd;
                // 关键步骤1,寻找锚点View位置
                updateAnchorInfoForLayout(recycler, state, mAnchorInfo);
                mAnchorInfo.mValid = true;
            } else if (focused != null && (mOrientationHelper.getDecoratedStart(focused)
                    >= mOrientationHelper.getEndAfterPadding()
                    || mOrientationHelper.getDecoratedEnd(focused)
                    <= mOrientationHelper.getStartAfterPadding())) {
                mAnchorInfo.assignFromViewAndKeepVisibleRect(focused, getPosition(focused));
            }
            ...
                // fill towards end
                updateLayoutStateToFillEnd(mAnchorInfo);
                mLayoutState.mExtraFillSpace = extraForEnd;
                //关键步骤2,从锚点View位置往后填充
                fill(recycler, mLayoutState, state, false);
                endOffset = mLayoutState.mOffset;
                final int lastElement = mLayoutState.mCurrentPosition;
                if (mLayoutState.mAvailable > 0) {
                //如果锚点位置后面数据不足,无法填满剩余的空间,那把剩余空间加到顶部
                    extraForStart += mLayoutState.mAvailable;
                }
                // fill towards start
                updateLayoutStateToFillStart(mAnchorInfo);
                mLayoutState.mExtraFillSpace = extraForStart;
                mLayoutState.mCurrentPosition += mLayoutState.mItemDirection;
                //关键步骤3,从锚点View位置向前填充
                fill(recycler, mLayoutState, state, false);
                startOffset = mLayoutState.mOffset;
    
                if (mLayoutState.mAvailable > 0) {
                //如果锚点View位置前面数据不足,那把剩余空间加到尾部再做一次尝试
                    extraForEnd = mLayoutState.mAvailable;
                    // start could not consume all it should. add more items towards end
                    updateLayoutStateToFillEnd(lastElement, endOffset);
                    mLayoutState.mExtraFillSpace = extraForEnd;
                    fill(recycler, mLayoutState, state, false);
                    endOffset = mLayoutState.mOffset;
                }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46

    先解释一下锚点View,锚点View在一次layout过程中的位置不会发生变化,即之前在哪里显示,这次layout完还在哪,从视觉上看没有位移。

    总结一下,mLayout.onLayoutChildren主要做了以下几件事:

    1. 调用updateAnchorInfoForLayout方法确定锚点view位置
    2. 从锚点view后面的位置开始填充,直到后面空间被填满或者已经遍历到最后一个itemView
    3. 从锚点view前面的位置开始填充,直到空间被填满或者遍历到indexe为0的itemView
    4. 经过第三步后仍有剩余空间,则把剩余空间加到尾部再做一次尝试

    所以回到一开始的问题,RecyclerView在notify之后位置跳跃的关键在于锚点View的确定,也就是updateAnchorInfoForLayout方法,所以下面重点看下这个方法:

    private void updateAnchorInfoForLayout(RecyclerView.Recycler recycler, RecyclerView.State state,
            AnchorInfo anchorInfo) {
        if (updateAnchorFromPendingData(state, anchorInfo)) {
            if (DEBUG) {
                Log.d(TAG, "updated anchor info from pending information");
            }
            return;
        }
    
        if (updateAnchorFromChildren(recycler, state, anchorInfo)) {
            if (DEBUG) {
                Log.d(TAG, "updated anchor info from existing children");
            }
            return;
        }
        if (DEBUG) {
            Log.d(TAG, "deciding anchor info for fresh state");
        }
        anchorInfo.assignCoordinateFromPadding();
        anchorInfo.mPosition = mStackFromEnd ? state.getItemCount() - 1 : 0;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21

    这个方法比较短,所以代码全贴出来了。如果是调用了scrollToPosition后的刷新,会通过updateAnchorFromPendingData方法确定锚点View位置,否则调用updateAnchorFromChildren来计算:

    private boolean updateAnchorFromChildren(RecyclerView.Recycler recycler,
            RecyclerView.State state, AnchorInfo anchorInfo) {
        if (getChildCount() == 0) {
            return false;
        }
        final View focused = getFocusedChild();
        if (focused != null && anchorInfo.isViewValidAsAnchor(focused, state)) {
            anchorInfo.assignFromViewAndKeepVisibleRect(focused, getPosition(focused));
            return true;
        }
        if (mLastStackFromEnd != mStackFromEnd) {
            return false;
        }
        View referenceChild =
                findReferenceChild(
                        recycler,
                        state,
                        anchorInfo.mLayoutFromEnd,
                        mStackFromEnd);
        if (referenceChild != null) {
            anchorInfo.assignFromView(referenceChild, getPosition(referenceChild));
            ...
            return true;
        }
        return false;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26

    代码比较简单,如果有焦点View,并且焦点View没被remove,则使用焦点View作为锚点。否则调用findReferenceChild来查找:

    View findReferenceChild(RecyclerView.Recycler recycler, RecyclerView.State state,
            boolean layoutFromEnd, boolean traverseChildrenInReverseOrder) {
        ensureLayoutState();
    
        // Determine which direction through the view children we are going iterate.
        int start = 0;
        int end = getChildCount();
        int diff = 1;
        if (traverseChildrenInReverseOrder) {
            start = getChildCount() - 1;
            end = -1;
            diff = -1;
        }
    
        int itemCount = state.getItemCount();
    
        final int boundsStart = mOrientationHelper.getStartAfterPadding();
        final int boundsEnd = mOrientationHelper.getEndAfterPadding();
    
        View invalidMatch = null;
        View bestFirstFind = null;
        View bestSecondFind = null;
    
        for (int i = start; i != end; i += diff) {
            final View view = getChildAt(i);
            final int position = getPosition(view);
            final int childStart = mOrientationHelper.getDecoratedStart(view);
            final int childEnd = mOrientationHelper.getDecoratedEnd(view);
            if (position >= 0 && position < itemCount) {
                if (((RecyclerView.LayoutParams) view.getLayoutParams()).isItemRemoved()) {
                    if (invalidMatch == null) {
                        invalidMatch = view; // removed item, least preferred
                    }
                } else {
                    // b/148869110: usually if childStart >= boundsEnd the child is out of
                    // bounds, except if the child is 0 pixels!
                    boolean outOfBoundsBefore = childEnd <= boundsStart && childStart < boundsStart;
                    boolean outOfBoundsAfter = childStart >= boundsEnd && childEnd > boundsEnd;
                    if (outOfBoundsBefore || outOfBoundsAfter) {
                        // The item is out of bounds.
                        // We want to find the items closest to the in bounds items and because we
                        // are always going through the items linearly, the 2 items we want are the
                        // last out of bounds item on the side we start searching on, and the first
                        // out of bounds item on the side we are ending on.  The side that we are
                        // ending on ultimately takes priority because we want items later in the
                        // layout to move forward if no in bounds anchors are found.
                        if (layoutFromEnd) {
                            if (outOfBoundsAfter) {
                                bestFirstFind = view;
                            } else if (bestSecondFind == null) {
                                bestSecondFind = view;
                            }
                        } else {
                            if (outOfBoundsBefore) {
                                bestFirstFind = view;
                            } else if (bestSecondFind == null) {
                                bestSecondFind = view;
                            }
                        }
                    } else {
                        // We found an in bounds item, greedily return it.
                        return view;
                    }
                }
            }
        }
        // We didn't find an in bounds item so we will settle for an item in this order:
        // 1. bestSecondFind
        // 2. bestFirstFind
        // 3. invalidMatch
        return bestSecondFind != null ? bestSecondFind :
                (bestFirstFind != null ? bestFirstFind : invalidMatch);
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
    • 58
    • 59
    • 60
    • 61
    • 62
    • 63
    • 64
    • 65
    • 66
    • 67
    • 68
    • 69
    • 70
    • 71
    • 72
    • 73

    解释一下,查找过程会遍历RecyclerView当前可见的所有childView,找到第一个没被notifyRemove的childView就停止查找,否则会把遍历过程中找到的第一个被notifyRemove的childView作为锚点View返回。

    这里需要注意final int position = getPosition(view);这一行代码,getPosition返回的是经过校正的最终position,如果ViewHolder被notifyRemove了,这里的position会是0,所以如果可见的childView都被remove了,那最终定位的锚点View是第一个childView,锚点的position是0,偏移量offset是这个被删除的childView的top值,这就会导致后面fill操作时从位置0开始填充,先把position=0的view填充到偏移量offset的位置,再往后依次填满剩余空间,这也是导致画面上的跳动的根本原因。

    Android 学习笔录

    Android 性能优化篇:https://qr18.cn/FVlo89
    Android Framework底层原理篇:https://qr18.cn/AQpN4J
    Android 车载篇:https://qr18.cn/F05ZCM
    Android 逆向安全学习笔记:https://qr18.cn/CQ5TcL
    Android 音视频篇:https://qr18.cn/Ei3VPD
    Jetpack全家桶篇(内含Compose):https://qr18.cn/A0gajp
    OkHttp 源码解析笔记:https://qr18.cn/Cw0pBD
    Kotlin 篇:https://qr18.cn/CdjtAF
    Gradle 篇:https://qr18.cn/DzrmMB
    Flutter 篇:https://qr18.cn/DIvKma
    Android 八大知识体:https://qr18.cn/CyxarU
    Android 核心笔记:https://qr21.cn/CaZQLo
    Android 往年面试题锦:https://qr18.cn/CKV8OZ
    2023年最新Android 面试题集:https://qr18.cn/CgxrRy
    Android 车载开发岗位面试习题:https://qr18.cn/FTlyCJ
    音视频面试题锦:https://qr18.cn/AcV6Ap

  • 相关阅读:
    html5期末大作业 基于HTML+CSS制作dr钻戒官网5个页面 企业网站制作
    02-Node.js模块化、模块作用域、module.exports
    CSS 盒子模型
    SpringBoot启动流程梳理-自定义实现@SpringBootApplication注解
    【电源专题】LDO基础——压降
    Linux - Python安装
    【SA8295P 源码分析】125 - MAX96712 解串器 start_stream、stop_stream 寄存器配置 过程详细解析
    layui之会议OA系统4.0
    webpack源码分析——loader-runner库之runLoaders函数
    Java子类继承父类私有方法属性问题讲解
  • 原文地址:https://blog.csdn.net/maniuT/article/details/132987299