关键词:IndexOutOfBoundsException: Inconsistency detected. Invalid view holder adapter positionViewHolder,
Added View has RecyclerView as parent but view is not a real child
研究起因
关于recyclerview就不用多做介绍了,做android开发的相信大家都知道,大多数人会使用它,利用一些三方adapter库几行代码实现复杂item的加载效果,但是真正说到rv中涉及到的一些源码可能很多人也都是一知半解,这段时间真的是花了时间去研究rv的源码,研究rv源码最主要的起因是由于公司内部使用adapter所引起的一些疑难杂症,项目集成bugly上线后会时不时的报一些rv崩溃的异常,问题存在有一些时间,但是苦于rv巨多的代码,崩溃一直没人处理,虽说发生的频率不是很高,但是看着那些无从下手,莫名其妙的崩溃,内心其实是比较煎熬的。这里要说下公司内部使用的adapter库并没有直接使用现有的一些开源库,而是自己进行的代码封装,自己封装adapter的好处显而易见代码可控性高,出现问题可以快速定位,随便还能学一波和rv相关的东西。谁知道不小心翻了车,这也是为什么bugly在线上会报一些崩溃的原因,还是对rv的源码没有理解到位造成的。
Inconsistency detected. Invalid view holder adapter positionViewHolder
bug如风,常伴吾身,bugly上报的崩溃有两个,都是磨人的小妖精,曾抱着侥幸的心理,想是不是rv自身的bug导致的崩溃,直到我亲手把这两个bug给复现出来才知道原来是封装adapter有问题导致的崩溃。先来说一下第一个bug
java.lang.IndexOutOfBoundsException: Inconsistency detected. Invalid view holder adapter positionViewHolder
不知道看到这个崩溃的各位有没有一种似曾相似,其实这个崩溃算是比较常见的崩溃,网上也有相关的解决方法,解释的比较清楚了,就是data数据更新后没有及时调用notify方法引起的,可以在rv的源码中找到这个崩溃的根源
if (holder.mPosition < 0 || holder.mPosition >= mAdapter.getItemCount()) {
throw new IndexOutOfBoundsException("Inconsistency detected. Invalid view holder "
+ "adapter position" + holder + exceptionLabel());
}
引起bug的根源就是满足了holder.mPosition >= mAdapter.getItemCount()这个条件,现在的问题是如何复现这个bug,其实方法很简单,当你清除数据后没有及时调用notify方法直接去滑动rv的时候,这个bug就会出现,现在解释下引起bug的原因
当清除完数据后此时mAdapter.getItemCount()的值将为0,而holder.mPosition的值实际上就是各个item的对应位置,很显然任意·一个item都会满足holder.mPosition >= mAdapter.getItemCount()这个条件。而如果在清除完数据后调用notifiyDatachange方法,代码最终会通知rv进行重新onlayout,该方法中会调用到layoutmanager一个非常重要的函数fill,fill函数的主要作用就是将各个item排布到rv上,内部通过while循环依次排布各个item,关键判断如下
while ((layoutState.mInfinite || remainingSpace > 0) && layoutState.hasMore(state)) {
......
}
此时layoutState.hasMore(state)为false,意思就是rv中没有更多的item可以排布,所以抛异常的代码根本不会执行到。
在清除完数据源除了调用notifiyDatachange同步下数据源之外,还可以调用adapter提供的若干局部刷新方法如notifyItemRemove等,这些方法的原理和notifiyDatachange有些不同,是通过更改holder.mposition值来避免
holder.mPosition >= mAdapter.getItemCount()成立,从而避开进入抛异常的代码。
经过上述分析可以知道一个事实就是当数据源改变后一定要及时调用一系列的notify方法来避免这个崩溃的产生。实际上除了数据源清空没有调用notify方法会引发崩溃外,当对数据源进行删除若干项的操作而没有调用notify也会引起崩溃,只不过这种情况下崩溃没有这么容易触发,需要滑动rv到一定程度才会引发崩溃。将数据直接清空不调用notify的操作是让崩溃重现的最佳方法。
Inconsistency detected bug产生实际场景
既然找到了崩溃引发的根源,那么接下来要去解决的问题在项目代码中去查找究竟是哪里的逻辑引发的崩溃。最终根据bugly崩溃日志信息定位到了大致位置,剩下的就是需要自己根据代码逻辑去仔细排查,只要抓住数据源改变后没及时调用notify这个原因,那么发现问题代码就是时间问题。
这里说下项目问题代码的逻辑,项目中有这么个场景,当用户没有登录时rv会展示数据,当用户登录后rv展示的数据会根据网络请求进行改变,很常见的一个逻辑,问题就出在写代码的同事不知道数据源改变同步调用notify,当进行用户登录操作时会进行一个数据源清空的操作,然后在登陆完毕后请求到数据执行add操作和notifydatachange来展示新的数据。就是这么个操作会有概率引起崩溃问题,当清空数据源之后notifydatachange不是紧接着调用而是在数据返回后才调用,如果这时网络有延迟notifydatachange就会一直得不到调用,如果此时有用户在屏幕上执行滑动操作那么恭喜你app crash!!。这个问题没有被测试发现的一个原因就在于内网网络速度很快,可以说notifydatachange几乎都是跟在数据清空后调用的,还有一个比较坑的地方在于明明是清空数据不同步调用notify引起的崩溃,但bugly会把崩溃发生的代码定位到登陆逻辑里面,导致问题查了半天找不到原因,这个崩溃在项目中存在有一段时间都没人解决,主要原因还是在于没有对rv有一定了解。
Added View has RecyclerView as parent but view is not a real child
很难搞定的一个bug,但最终还是在我的不懈努力下找到了解决方法。此时有掌声,啪啪啪...在bugly是一个比较稀有的bug,常出没于一些比较低端点的手机上,时有时无若隐若现,这个崩溃不是rv自身代码bug,而是我们自身代码存在一定缺陷引起的,好在网上可以搜到一些关于这个bug的传闻,当快速滑动rv到加载更多的时候有时候会触发这个bug,但是网上没有一篇文章有对这个bug的原因有过分析,只是知道触发条件是快递滑动rv到加载更多,不停重复这个操作会复现这个bug。
但是我按照这个操作来来回回操作了几十次,换了n台测试机都没触发过这个崩溃,程序没崩溃这就让我很崩溃了,让我们看下这个崩溃的源码所在,addviewint内部有如下代码
if (child.getParent() == mRecyclerView) { // it was not a scrap but a valid child
// ensure in correct position
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) + mRecyclerView.exceptionLabel());
}
if (currentIndex != index) {
mRecyclerView.mLayout.moveView(currentIndex, index);
}
}
在这里我们发现了引起崩溃的具体条件child.getParent() == mRecyclerView并且currentIndex==-1,第一个条件getParent为rv,当addviewint的时候,被添加的child的parent居然不为null,一种似曾相似的感觉,相信很多人之前都遇到过viewpager中左右滑动view时可能会报出一个类似 child already has parent的崩溃,没想到rv中居然也报了这么个崩溃。
问题分析
这个child.getParent()==mRecyclerView的条件着实让人费解,结合网上流传滑动到底部加载更多引起崩溃的原因大致可以猜测引起崩溃的view是一个"加载更多"view,那么问题来了为什么其他view都没有问题唯独"加载更多"这个view会有问题。在我们封装adapter的时候一般都会提供加载更多这么个功能,加载更多严格来说并不是真正的数据源,而是adapter在getitemcount的时候我们通过对数据源.size+1来忽悠adapter,让adapter误以为我们有这么多数据要加载,当加载到最后一个item时,通过inflate加载更多的layoutid来展示“加载更多”布局,项目原来封装的adapter里面有这样一段逻辑足以引起怀疑,这段代码在oncreateviewholder时会被调用到,生成的view作为一个rootview传递给holder
public final View onCreateView(ViewGroup parent) {
if (itemView == null) {
itemView = inflate(layoutRes, parent, false);
onViewCreate(itemView);
}
return itemView;
}
注意这个条件当itemView==null才inflate,如果不为null则直接复用itemView,理论上每一个rv只会存在一个"加载更多"布局,以下简称loadmore,我们每次滑到底部看到的loadmore实际上都是通过rv的缓存机制得到的,但是在个别极端情况下是会存在两个loadmore布局的!!
双生loadmore情况
这是个很坑爹的情况,但是却是真实有可能发生的,只不过概率没这么高而已,我们都知道rv默认情况下都是有动画效果的,而loadmore的动画效果就是loadmore布局下移,然后新获取的数据展示出来,相信这种动画效果大家都见过,经过对源码分析发现一个情况就是当loadmore还在执行下移动画此时快速滑动rv到底部的时候就会出现这种双生loadmore情况,何为双生laodmore,说白了就是同时会存在两个loadmore,而这两个loadmore用的却是同一个itemview,这种情况最大的一个特点就是此时第一个loadmore还在执行动画中,还并没有从rv上移除所以此时getParent==mRecyclerView,至此第一个崩溃条件就成立了,这二个条件 int currentIndex = mChildHelper.indexOfChild(child);值为-1,具体就不想分析了,原因就是执行动画的时候得到的值就是-1就对了,到此两条件满足崩溃重现江湖!!
解决崩溃问题
bug知道根源都好解决,难得是发现崩溃的整个过程,想解决很简单只要破坏第一个条件成立的情况就可以了,修改onCreateView中的代码如下
public final View onCreateView(ViewGroup parent) {
itemView = inflate(layoutRes, parent, false);
onViewCreate(itemView);
return itemView;
}
到此世界就清净了。。。让每一个loadmore创造出来的时候独立拥有一个itemview即可。写到这里就不免想到那个笑话,考核程序员绩效通过代码量来体现,修一个bug只删了两行代码,但是整个过程却让我把rv的源码来回翻了N次。这个bug之所有这么难复现主要原因还是这个bug太细节了,低端手机性能比较差在loadmore执行动画还没结束的时候的快速下拉有更大概率触发崩溃。还有一个大坑就是这个bug在compat 27的包中会被提前报出,导致我在demo使用compat 27的代码复现这个bug的时候死活不能复现。
复现路上的大坑
通过代码对比就能发现问题所在,compat26中rv的oncreateviewholder代码
public final VH createViewHolder(ViewGroup parent, int viewType) {
TraceCompat.beginSection(TRACE_CREATE_VIEW_TAG);
final VH holder = onCreateViewHolder(parent, viewType);
holder.mItemViewType = viewType;
TraceCompat.endSection();
return holder;
}
接下来是compat 27代码中的oncreateviewholder
public final VH createViewHolder(@NonNull ViewGroup parent, int viewType) {
try {
TraceCompat.beginSection(TRACE_CREATE_VIEW_TAG);
final VH holder = onCreateViewHolder(parent, viewType);
if (holder.itemView.getParent() != null) {
throw new IllegalStateException("ViewHolder views must not be attached when"
+ " created. Ensure that you are not passing 'true' to the attachToRoot"
+ " parameter of LayoutInflater.inflate(..., boolean attachToRoot)");
}
holder.mItemViewType = viewType;
return holder;
} finally {
TraceCompat.endSection();
}
}
这么一对比是不是就发现问题所在了,27版本中直接将这个问题在createViewHolder的时候就暴露出来了,也就是这个原因导致我在27版本的代码中脑袋想冒烟也没复现出那个bug,所以在调试一些bug的时候尽量让demo的sdk版本和线上sdk版本一致!!这是一个血一样的教训。
总结
到此自己解决的两个bug问题就已经全部阐述完毕了,第一个bug比较好解决因为复现的条件比较简单,只要bug能复现剩下的一切都好商量。第二个bug就有点恶心了,各种无法复现,最后还是通过源码去定位问题,饶了一大圈首先发现是sdk版本问题,然后在线上相同的sdk版本上继续深究问题原因,也正是在解决这两个bug的过程,结合网上一些rv优化技术,让自己对之前封装的adapter又进行了一次重构,可以说在理解rv源码基础上封装起adapter才能更有把握,否则出现一个问题就两眼一抹黑,扔在那边没人处理,在使用一些三方库的时候虽然很爽,但是很多时候还是自己亲手做些东西才能更有收获。