最近在用ReyclerView写模块化页面,每个模块视图部分作为一个小的Aapter,会发现一些RecyclerView的坑,在博客中进行一些总结,保持更新。
打开RecyclerView页面,快速滚动crash Added View has RecyclerView as parent **
“Added View has RecyclerView as parent but view is not a real child. Unfiltered index: xx ” 使用的是LinearLayoutManger。
直接打开RecyclerView源码(24.2.1),在它的内部类LayoutManger中,可以搜到这个Crash,在addViewInt方法中报的crash,addViewInt方法是将childview添加到RecyclerView中,在添加之前要检查获取的childview是否合法,来看下面源码。
private void addViewInt(View child, int index, boolean disappearing) {
final ViewHolder holder = getChildViewHolderInt(child);
************
final LayoutParams lp = (LayoutParams) child.getLayoutParams();
// child 如果是从复用池里捞出来重用走这个逻辑
if (holder.wasReturnedFromScrap() || holder.isScrap()) {
if (holder.isScrap()) {
holder.unScrap();
} else {
holder.clearReturnedFromScrapFlag();
}
mChildHelper.attachViewToParent(child, index, child.getLayoutParams(), false);
if (DISPATCH_TEMP_DETACH) {
ViewCompat.dispatchFinishTemporaryDetach(child);
}
} else if (child.getParent() == mRecyclerView) {
int currentIndex = mChildHelper.indexOfChild(child);
if (index == -1) {
index = mChildHelper.getChildCount();
}
if (currentIndex == -1) {
throw new IllegalStateException("Added View has RecyclerView as parent but"
+ " view is not a real child. Unfiltered index:"
+ mRecyclerView.indexOfChild(child));
}
if (currentIndex != index) {
mRecyclerView.mLayout.moveView(currentIndex, index);
}
} else {
mChildHelper.addView(child, index, false);
lp.mInsetsDirty = true;
if (mSmoothScroller != null && mSmoothScroller.isRunning()) {
mSmoothScroller.onChildAttachedToWindow(child);
}
}
if (lp.mPendingInvalidate) {
if (DEBUG) {
Log.d(TAG, "consuming pending invalidate on child " + lp.mViewHolder);
}
holder.itemView.invalidate();
lp.mPendingInvalidate = false;
}
}
这个crash产生的直接原因很简单,就是持有的childview的父view已经是RecyclerView了,换句话说这个View已经在RecyclerView上,并且这个View的mChildHelper.indexOfChild(child)==-1。
mChildHelper.indexOfChild这个方法干了什么呢?看下面代码,首先会找到这个View在RecyclerView中的index,如果没找到直接返回-1;如果有这个View,再去mBuket中寻找,如果发现这个View是个隐藏View,则返回-1. Buket中记录了隐藏的View的index,什么View需要被隐藏呢?看下一个代码块,在开始做动画之前,这个“隐藏”并不是把View移除掉,而是把这些需要做动画的View做记录,并存在一个数组中,在做动画时特殊处理(脑补)。
int indexOfChild(View child) {
final int index = mCallback.indexOfChild(child);
if (index == -1) {
return -1;
}
if (mBucket.get(index)) {
if (DEBUG) {
throw new IllegalArgumentException("cannot get index of a hidden child");
} else {
return -1;
}
}
// reverse the index
return index - mBucket.countOnesBefore(index);
}
ChildHelper 利用Buket隐藏View
private void addAnimatingView(ViewHolder viewHolder) {
final View view = viewHolder.itemView;
final boolean alreadyParented = view.getParent() == this;
mRecycler.unscrapView(getChildViewHolder(view));
if (viewHolder.isTmpDetached()) {
// re-attach
mChildHelper.attachViewToParent(view, -1, view.getLayoutParams(), true);
} else if(!alreadyParented) {
mChildHelper.addView(view, true);
} else {
mChildHelper.hide(view);
}
}
所以我们看到这个Crash如果满足上述几个条件就会发生:1、ChildView的Holder是新创建的,不是从复用池里捞出来;2、ChildView的父View是RecyclerView;3、ChildView 还在做动画。
为什么一个新创建的ViewHolder的View的Parent是RecyclerView,做着动画,还在被复用?按道理做着动画不能被复用啊!!
原因肯定是我们用错了,写法有问题啊,分析一下RecyclerView的复用机制:
(1) ArrayList mChangedScrap:在视图范围内,且正在做动画的holder
(2)ArrayList mAttachedScrap:在视图范围内,除去做动画的Holder
(3)ArrayList mCachedViews:默认最大值为2,存储的是最近移出屏幕的ViewHolder,这里面的ViewHolder的属性全部保留,当读取缓存ViewHolder时优先从这里取,如果取到,可以避免再计算位置,LayoutParams。
(4)RecycledViewPool mRecyclerPool:主要用来缓存重置ViewHolder的对象
如果addViewInt这个函数接收的child是从mCachedViews或mRecyclerPool中取的,不会有问题,但偏偏是mAttachedScrap中的View,正常情况下addViewInt不会添加视图范围内部的View,所以addViewInt传入的View应该是调用onCreateViewHolder获取的。通过debug这个crash,查找调用堆栈,发现确实在复用池中没有找到对应类型的View,重新调用了onCreateViewHolder。
Crash产生原因是,当RecyclerView调用OnCreateViewHolder时,我们并没有重新生成一个View和ViewHolder,我们返回的View可能是一个之前已经存在的View,把这个View存储成了一个全局变量,在OnCreateViewHolder没有重新生成,而是把上次的View又返回了,导致出现这种问题。类似与下面写法,所以在RecyclerView的Adapter中,如果调用OnCreateViewHolder,一定不要偷懒,返回一个全新的对象吧
public View onCreateView(ViewGroup parent, int viewType) {
if (mRootView == null) {
mRootView = LayoutInflater.from(getContext()).inflate(R.layout.xxxxxx, null, false);
mContainer = (LinearLayout) mRootView.findViewById( R.id.container )
}
return mRootView ;
}
当我们写法有问题的时候:
为什么需要刚打开页面快速滚动才能复现?为什么网上一些其他的解决方法貌似也很管用:把Adapter的hasStableID设置false或者setItemAnimation为null。我们既然找到了真实原因,就再聊聊这些治标不治本的方法为什么管用。
解决以上问题的根源在于:当我们的ItemCount没发生改变时,为什么会重复调用OnCreateViewHolder,复用池为什么找不到这个View??如果复用池中可以找到,就不会有这个问题了。
原因是:当RecyclerView每次onLayout时,会将他的View都回收一遍,然后再重新计算添加,关键的步骤在LinearLayoutManger中scrapOrRecycleView方法里,这个方法负责回收子View。
里面有个条件判断非常关键——mAdapter.hasStableIds(),removeViewAt(index)和detachViewAt(index)两个处理方法不同。RemoveViewAt(index)会将View在RecyclerView中移除,并会回收到复用池中;detachViewAt(index)会把View放到 mAttachedScrap或mAttachedScrap中。
什么时候会放到复用池呢?当动画mItemAnimator null会立即放入,如果itemAnimation不为null,会在动画执行完毕后放入复用池。
执行动画的条件是什么?两个必要条件是mItemAnimator不为null,hasStabelID = true。mItemAnimator默认是DefaultItemAnimator,不为null;Adapter hasStableID默认应该是false。
所以为啥设置hasStabelID为false或者设置mItemAnimator为null会有奇效,但是这绝对不是永久解决方案,最好的方案是改变写法,把bug解除。
为啥当打开页面快速滑动才容易复现这个问题呢,因为动画执行时间很短,只有在做动画时,不断Scroll,让RecyclerView重新去计算视图,加载需要的View,才会走到addViewInt逻辑,才能触发问题。
private void scrapOrRecycleView(Recycler recycler, int index, View view) {
final ViewHolder viewHolder = getChildViewHolderInt(view);
if (viewHolder.shouldIgnore()) {
return;
}
if (viewHolder.isInvalid() && !viewHolder.isRemoved() &&
!mRecyclerView.mAdapter.hasStableIds()) {
// hasStableIds很关键
removeViewAt(index);
recycler.recycleViewHolderInternal(viewHolder);
} else {
detachViewAt(index);
recycler.scrapView(view);
mRecyclerView.mViewInfoStore.onViewDetached(viewHolder);
}
}
ReyclerView当添加动画Finish时,会调用removeAnimation方法,这个时候View会被加入到复用池汇中。
private boolean removeAnimatingView(View view) {
eatRequestLayout();
final boolean removed = mChildHelper.removeViewIfHidden(view);
if (removed) {
final ViewHolder viewHolder = getChildViewHolderInt(view);
mRecycler.unscrapView(viewHolder);
mRecycler.recycleViewHolderInternal(viewHolder);
}
resumeRequestLayout(false);
return removed;
}