问题描述

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

原因分析

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

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

onChanged会直接调用requestlayout来重新layuout。 onItemRangeXXX会先把刷新数据保存到mAdapterHelper中,然后再调用requestlayout 2. 进入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的位置,再往后依次填满剩余空间,这也是导致画面上的跳动的根本原因。

最后

如果想要成为架构师或想突破20~30K薪资范畴,那就不要局限在编码,业务,要会选型、扩展,提升编程思维。此外,良好的职业规划也很重要,学习的习惯很重要,但是最重要的还是要能持之以恒,任何不能坚持落实的计划都是空谈。

如果你没有方向,这里给大家分享一套由阿里高级架构师编写的《Android八大模块进阶笔记》,帮大家将杂乱、零散、碎片化的知识进行体系化的整理,让大家系统而高效地掌握Android开发的各个知识点。
img
相对于我们平时看的碎片化内容,这份笔记的知识点更系统化,更容易理解和记忆,是严格按照知识体系编排的。

欢迎大家一键三连支持,若需要文中资料,直接扫描文末CSDN官方认证微信卡片免费领取↓↓↓

PS:群里还设有ChatGPT机器人,可以解答大家在工作上或者是技术上的问题
图片

Logo

欢迎加入我们的广州开发者社区,与优秀的开发者共同成长!

更多推荐