问题描述
做需求开发时,遇到RecyclerView刷新时,通常会使用notifyItemXXX方法去做局部刷新。但是刷新后,有时会遇到RecyclerView定位到我们不希望的位置,这时候就会很头疼。这周有时间深入了解了下RecyclerView的源码,大致梳理清楚刷新后位置跳动的原因了。
做需求开发时,遇到RecyclerView刷新时,通常会使用notifyItemXXX方法去做局部刷新。但是刷新后,有时会遇到RecyclerView定位到我们不希望的位置,这时候就会很头疼。这周有时间深入了解了下RecyclerView的源码,大致梳理清楚刷新后位置跳动的原因了。
原因分析
先简单描述下RecyclerView在notify后的过程:
- 根据是否是全量刷新来选择触发RecyclerView.RecyclerViewDataObserver的onChanged方法或onItemRangeXXX方法
onChanged会直接调用requestlayout来重新layuout。 onItemRangeXXX会先把刷新数据保存到mAdapterHelper中,然后再调用requestlayout
- 进入dispatchLayout流程 这一步分为三个步骤:
- dispatchLayoutStep1:处理adapter的更新、决定哪些view执行动画、保存view的信息
- dispatchLayoutStep2:真正执行childView的layout操作
- dispatchLayoutStep3:触发动画、保存状态、清理信息
先简单描述下RecyclerView在notify后的过程:
- 根据是否是全量刷新来选择触发RecyclerView.RecyclerViewDataObserver的onChanged方法或onItemRangeXXX方法
onChanged会直接调用requestlayout来重新layuout。 onItemRangeXXX会先把刷新数据保存到mAdapterHelper中,然后再调用requestlayout
- 进入dispatchLayout流程 这一步分为三个步骤:
需要注意的是,在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的位置,再往后依次填满剩余空间,这也是导致画面上的跳动的根本原因。
来源:juejin.cn/post/7259358063517515834










































































若不是最近看到这样一篇新闻。
当时机哥身边的亲朋好友,只要是有智能手机的。
原因倒是不复杂,在那个流量资费偏高的年代。
WiFi万能钥匙,总是能如它的名字般神奇,帮用户成功连上WiFi。
关键是,这软件还免费使用。
WiFi万能钥匙发布不到三年,就拥有超过5亿的激活用户。
什么叫风头无两啊,


再加上App内部,出现了各种离谱的广告。
不知道的,还以为下了个病毒软件呢...
但它这些年,积攒起来的崩坏口碑。
当然啦,如果只是广告讨人嫌。
但它能帮咱们连上各种场合的WiFi,原理并不是暴力破解。
听起来,似乎是个不错的模式对吧。
你帮我,我帮你,天下就没有难办的事儿了。
但理想很丰满,现实很骨感。

我凭什么无缘无故,给一个陌生人一块钱?
他表示,App可以直接从用户手机拿到WiFi密码。
可谓是明文存放,点开就送。
可很凑巧的是,早期的安卓手机获取权限非常简单。
紧接着,最关键的问题来了。
作为一个,只会输入“Hello World”的代码废柴。

嗯?难道说...
如果实在遇到一些,数据库里配对不上的WiFi。
机友们都懂的,其实很多家庭路由器,密码都设置得很简单。


“我们可是有9亿用户总量的,欢迎来合作啊喂。
不能说克制,只能说处处皆是广告位。
而WiFi万能钥匙,对于广告的内容筛选,更是让人汗流浃背。

讲道理,以它如此庞大的用户总量。
更何况,现在早就不是,流氓App能随意践踏手机的时代了。


结果发现,它现在往App塞了个短剧板块。
emmm...机哥也没啥好说的,祝它一切顺利吧。




























































































































