Android RecyclerView 复用错乱通用解法详解
写在前面:
在上篇文章中说过对于像RecyclerView或者ListView等等此类在有限屏幕中展示大量内容的控件,复用的逻辑就是其核心的逻辑,而关于复用导致最常见的bug就是复用错乱。在大上周我就遇到了一个很奇怪的问题,这也是我下决心研究RecyclerView的原因。
RecyclerView源码分析
而这篇文章的目的首先是讨论在RecyclerView复用错乱时,一些通用的解决思路,其次就是探究我遇到的那个奇怪的问题,帮助未来同样遇到的朋友们。
复用错乱的解决办法
本文的前半部分很简单的,以为关于复用错乱,RecyclerView已经有他的前辈ListView替它踩了很多坑了。虽然他们的复用逻辑是有差异的,例如ListView只有两层缓存,但是RecyclerView可以理解为有四层;ListView缓存的单位是view,而RecyclerView缓存的单位是ViewHolder。但是不管他们复用逻辑的差异如何,终归都是把那个缓存起来的view拿过来接着用,所以解决复用错乱的方法是一样的。
RecyclerView复用导致错乱的原因其实就是拿出来之前的View来添加到新item上,之前View的状态一直保留着,所以也就错乱了。不过解决起来很简单:
首先我们以adapter数据的来源分为两大类:
1.当数据来源是同步的
这种情况是最简单的,你就保证当onBindViewHolder方法调用的时候,你的itemview中每个view的状态都有一个默认值。这是什么意思呢?
if("".equals(artists)){ holder.cbMusicState.setChecked(true); }else{ holder.cbMusicState.setChecked(false); }
假设我们的holder里面有个Checkbox控件,当歌手名为unknown时,Checkbox勾选。注意个时候你一定要加上这个else条件,才能保证复用这个ViewHolder的时候,Checkbox的状态不出错。任何控件都一样,总结起来就是你要给每个控件的状态赋一个新的值,替换掉之前的,这样自然不会出现什么复用错乱的问题。
2.当数据的来源是异步的
这种情况也很常见,我们举个栗子,比如你的ItemView里面有个ImageView,每次onBindViewHolder的时候,你传入一个url,等待服务器返回的结果,然后展示在ImageView上。这种情况会怎样导致错乱呢?
是这样的,假设我进入了页面,开始为第一个ImageView请求图片,但是此刻我下划屏幕,划到了第四个item,此时第一个item已经不可见了,第四个item复用了第一个item的imageview,恰好此刻第一个imageview的图片结果返回了,就正好展示在了第四个itemview上。这样就发生了图片的错乱。
出现这个问题的原因就是这个ImageView和请求的url没一一绑定,所以按照这个思路来解决吧:
holder.ivCameraImages.setBackground(R.drawable.place_holder); holder.ivCameraImages.setTag(imageURL); @Override publicvoidhandleMessage(Messagemsg){ super.handleMessage(msg); if(msg.what==MSG_IMAGE){ Bitmapbm=(Bitmap)msg.obj; if(bm!=null){ if(TextUtils.equals((String)imageView.getTag(),imageURL)){ imageView.setBackground(newBitmapDrawable(bm)); } } } }
首先在没加载图片之前,给ImageView设置一个默认图片,然后通过setTag方法,将ImageView和图片的url一一对应起来,设置的时候再判断一下,这个imageview的tag和当时请求的url,是不是一致的,如果是一致的,再保存。
以上就是复用错乱时两种比较通用的解法,基本上可以覆盖大部分情况。
一个奇怪的问题
这个问题的现象是这样子的:
当RecyclerView的条目很少的时候,比如只有六个,将RecyclerView从上滑动到下,这个时候是正常的,onBindViewHolder会调用,不过此时从底部上划的时候,上方的item从不可见到可见的这个过程中,onBindViewHolder并没有调用,这个时候我也就没办法进行一些刷新item的操作了。
这个问题的原因是onBindViewHolder方法不调用导致的,我在StackOverflow上搜索了很多答案,终于找到了一个可以解决我的问题的:
recyclerview-not-recycling-views-if-the-view-count-is-small
(中文资料压根就没有,所以掌握英文搜索是多么的重要)
你可以调用
recyclerView.setItemViewCacheSize(int);
这个api,去调整RecyclerView的复用逻辑和方式来解决onBindViewHolder没有调用的这个问题。
但是原理是怎样的呢?作为一名好奇心颇重的程序员,一步步debugRecyclerView的源代码,发现了导致这个问题的原因,一起来看看吧。
在上一篇文章中,我们分析了RecyclerView的源码,其中复用逻辑的模块,有一个非常重要的核心方法tryBindViewHolderByDeadline,这个方法目的就是在RecyclerView的层层缓存结构中,取出ViewHolder。
这里就不再次研究它了,想了解的去看之前的文章,我来描述一下对于这个场景,简化之后的逻辑:
当RecyclerView从底部向上滑动的时候,会先后从mCachedViews和mRecyclerPool中寻找缓存的ViewHolder。
mCachedViews和mRecyclerPool之间又有什么关系呢?
publicvoidsetViewCacheSize(intviewCount){ mRequestedCacheMax=viewCount; updateViewCacheSize(); } voidupdateViewCacheSize(){ intextraCache=mLayout!=null?mLayout.mPrefetchMaxCountObserved:0; mViewCacheMax=mRequestedCacheMax+extraCache; //first,trytheviewsthatcanberecycled for(inti=mCachedViews.size()-1; i>=0&&mCachedViews.size()>mViewCacheMax;i--){ recycleCachedViewAt(i); } }
当调用setViewCacheSize这个方法时,相当于是给mViewCacheMax这个变量赋值了,for循环调用recycleCachedViewAt的作用是将mCachedViews中缓存的ViewHolder放进RecyclerPool中。可以看到for循环的周期是从mCachedViews的最后一个对象直到mCachedViews.size==mViewCacheMax这个值时。
也就是可以这么理解,setViewCacheSize这个方法其实就是为mCachedViews集合设置所能持有ViewHolder的最大数量。
当 setViewCacheSize(0)时,RecyclerView想去复用ViewHolder时,只能去RecyclerPool中去取了,这里就有问题来了,从RecyclerPool中取和从mCachedViews中取ViewHolder中又有什么区别呢?
if(holder==null){//fallbacktopool if(DEBUG){ Log.d(TAG,"tryGetViewHolderForPositionByDeadline(" +position+")fetchingfromsharedpool"); } holder=getRecycledViewPool().getRecycledView(type); if(holder!=null){ holder.resetInternal(); if(FORCE_INVALIDATE_DISPLAY_LIST){ invalidateDisplayListInt(holder); } } }
当从RecyclerPool取出ViewHolder时,调用了resetInternal这个函数的作用是清空一些记录的参数,包括之前记录ViewHolder状态的mFlags。
elseif(!holder.isBound()||holder.needsUpdate()||holder.isInvalid()){ if(DEBUG&&holder.isRemoved()){ thrownewIllegalStateException("Removedholdershouldbeboundanditshould" +"comehereonlyinpre-layout.Holder:"+holder); } finalintoffsetPosition=mAdapterHelper.findPositionOffset(position); bound=tryBindViewHolderByDeadline(holder,offsetPosition,position,deadlineNs); }
代码再往下走的时候,刚刚清空的flag参数这个时候就用到了,holder.isBound()返回flase,进入if判断,调用tryBindViewHolderByDeadline进而调用了onBindViewHolder。
到这里这个逻辑就描述清楚了,所以设置setViewCacheSize来调整mCachedViews保存ViewHolder的大小,就能解决问题。
当然有些特殊的情况,某些位置就不能调用onBindViewHolder,没关系,可以监听RecyclerView的滑动,当滑动停止的时候,再调用notify刷新下列表也是可以的。
好了本文到这里就结束了~希望对大家的学习有所帮助,也希望大家多多支持毛票票。