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;
            }
}

先解释一下锚点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;
}

这个方法比较短,所以代码全贴出来了。如果是调用了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;
}

代码比较简单,如果有焦点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);
}

解释一下,查找过程会遍历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

相关推荐
青春给了狗3 分钟前
Android 14 修改侧滑手势动画效果
android
CYRUS STUDIO9 分钟前
Android APP 热修复原理
android·app·frida·hotfix·热修复
火柴就是我1 小时前
首次使用Android Studio时,http proxy,gradle问题解决
android
limingade1 小时前
手机打电话时电脑坐席同时收听对方说话并插入IVR预录声音片段
android·智能手机·电脑·蓝牙电话·电脑打电话
浩浩测试一下1 小时前
计算机网络中的DHCP是什么呀? 详情解答
android·网络·计算机网络·安全·web安全·网络安全·安全架构
青春给了狗3 小时前
Android 14 系统统一修改app启动时图标大小和圆角
android
pengyu4 小时前
【Flutter 状态管理 - 柒】 | InheritedWidget:藏在组件树里的"魔法"✨
android·flutter·dart
居然是阿宋5 小时前
Kotlin高阶函数 vs Lambda表达式:关键区别与协作关系
android·开发语言·kotlin
凉、介5 小时前
PCI 总线学习笔记(五)
android·linux·笔记·学习·pcie·pci
小贾要学习6 小时前
【C++】继承----下篇
android·java·c++