问题描述
做需求开发时,遇到RecyclerView改写时,通常会运用notifyItemXXX办法去做部分改写。可是改写后,有时会遇到RecyclerView定位到咱们不希望的方位,这时候就会很头疼。这周有时间深入了解了下RecyclerView的源码,大致整理清楚改写后方位跳动的原因了。
原因剖析
先简略描述下RecyclerView在notify后的进程:
- 依据是否是全量改写来选择触发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首要做了以下几件事:
- 调用updateAnchorInfoForLayout办法确认锚点view方位
- 从锚点view后边的方位开端填充,直到后边空间被填满或者现已遍历到最后一个itemView
- 从锚点view前面的方位开端填充,直到空间被填满或者遍历到indexe为0的itemView
- 通过第三步后仍有剩下空间,则把剩下空间加到尾部再做一次尝试
所以回到一开端的问题,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的方位,再往后依次填满剩下空间,这也是导致画面上的跳动的根本原因。