目前,在项目中一些主要页面(如图1 首页,采用ViewPager+TabLayout ,已实现数据懒加载)都会缓存第一页数据(网络)存DB,下次进来时会先请求DB数据,然后再请求网络数据,这样用户体验比较好。之前我们主要页面都是使用RadioPullToRefreshListView(封装的PullToRefreshListView,后面简称ListView)方式实现(刷新方式使用的是notifyDataSetChanged),这样不管DB中的数据和网路请求数据是否一样,都会刷新两次,体验上没有什么问题,自从4.0版本陆续改用RecyclerView 方式实现,发现有图片加载的地方会闪烁一下(录屏效果不怎么好,这里就不放图了,脑补一下)。
仅仅只是用RecyclerView 替换ListView 逻辑主体并未修改,因此目光很快锁定在RecyclerView上面,网上一搜索,类似问题有很多,大多说是没有设置setStableIds(true) 和复写getItemId()导致的。
照葫芦画瓢,我给对应的Adapter 设置了setStableIds(true) 和复写getItemId(),发现闪络问题依然存在,至于为什么当时没解决(其实可以解决,后面给原因),就放弃尝试这种方式。
我们都知道RecylerView 在ListView 的基础上新增了 很多种刷新方式,既然notifyDataSetChanged()不行 ,于是乎改用了notifyItemRangeChanged(int positionStart, int itemCount),发现改方法竟然解决了上述闪烁问题。
一 、对比两种刷新方式异同点
(1)notifyDataSetChanged()最后作用于onChanged
唯独可疑的是:RecyclerView.this.setDataSetChangedAfterLayout(),将ViewHolder标记为更新或无用,暂时记录一下,后续会讲。
(2)RecyclerView 中刷新(如notifyItemRangeChanged())
notifyItemRangeChanged 等方法最后,都会调用triggerUpdateProcessor() ,由于主页面基本都是多ViewType 情况,一般不会设置HasFixedSize为true,因而最终只会调用else 分支去。
从(1)和(2)中,两种刷新差别,仅仅在于方法(1)会更改ViewHolder的标志,共同点都会调用requestLayout(),导致RecyclerView 重新测量和布局,到此还是看不出闪烁问题的原因所在。另外还有一个重要实验未说:如果单纯的只进行网络刷新的话,图像闪烁情况基本没有(页面跳转导致闪烁不明显)。种种迹象表明,可能是数据刷新过程中View未复用,从新inflate耗时亦或是其他原因,导致闪烁。
接下来,我们只能从RecyclerView 的测量和布局来分析,找出蛛丝马迹。
二、简要分析RecyclerView 的测量、布局及缓存策略
(1) RecyclerView::onMeasure()
RecyclerView 的onMeasure 相对比较简单 ,具体如下:目前官方提供的LayoutManager的mAutoMeasure为true ,项目中RecyclerView 的宽高是精确的,因而skipMeasure 为true ,直接return 了。从 RecyclerView::onMeasure() 看不出两种刷新的不同点,接下来看看 onLayout 。
(2)RecyclerView::onLayout ()
这里不打算细讲,跳过1000字 ,RecyclerView 之所以能够灵活布局,是因为它将onLayout()委托给了LayoutManager ,这里以LinearLayoutManager 为例说明:
LinearLayoutManager::onLayoutChildren()(调用链 RecyclerView::dispatchLayout()->dispatchLayoutStep2() ->onLayoutChildren()) ,在onLayoutChildren() 讲解两个比较重要的函数:detachAndScrapAttachedViews(recycler) 用于处理ViewHolder复用(View复用),fill()执行布局。
1)detachAndScrapAttachedViews()
detachAndScrapAttachedViews() 会循环遍历所有的View ,根据ViewHolder 的状态 及Adapter.hasStableIds() 来进行不同操作。
分支1:
ViewHolder 无用&&没有Remove&&hasStableIds为false ,会删除对应View,同时根据条件选择 保存在mCachedViews 和 addViewHolderToRecycledViewPool ;
分支2:
先detachView ,同时根据条件保存在 mChangedScrap 和 mAttachedScrap 中;
看到上面的条件,是不是很熟,在notifyDataSetChanged 时,会先将ViewHolder 设置成无效。在此打断点,发现 单独 notifyDataSetChanged() 情况,确实会走进if 分支(notifyItemRangeChanged()会进入else 分支),那设置hasStableIds(true)应该不会吧,发现竟然还是走进去了 ,纳尼????
仔细一看,对应的Adapter 不对,竟然是封装的RecyclerView组件,内部的WrapAdapter (方便实现加载更多等操作),对应修改组件内的WrapAdapter ,设置hasStableIds(true) 和 复写getItemId(),仍然使用notifyDataSetChanged ,闪烁问题果然没有了 。
根据detachAndScrapAttachedViews()发现,两种刷新方式不同在于对ViewHoler的缓存方式不一样,那先看看布局时是如何复用的,fill() 函数是真正进行布局的:
从上可以看到 View 是通过next()方式获取 :
next()->recycler.getViewForPosition()->tryGetViewHolderForPositionByDeadline()
从上可以看到ViewHolder创建或复用的过程,为了弄懂里面的流程,我们接着简要介绍一下RecyclerView的四级缓存是如何实现的。
(3)RecyclerView 的四级缓存
1)屏幕内缓存
mChangedScrap: 表示数据已经改变的ViewHolder
mAttachedScrap : 表示未与RecyclerView分离的ViewHolder
2)屏幕外缓存
mCachedViews:屏幕划出屏幕时,ViewHolder会被缓存在mCachedViews (默认 2)
3)自己实现ViewCacheExtension类实现自定义缓存
4)缓存池
tryGetViewHolderForPositionByDeadLine()中操作,可细化为如下流程图(网络盗图):
结合 tryGetViewHolderForPositionByDeadLine()调用流程 和RecyclerView 四级缓存策略,我们很容易分析出两种刷新方式,导致的现象不一样。
三、RecyclerView notifyDataSetChanged 导致图片闪烁的真凶
结合上文所讲内容,可以很容易分析出 notifyDataSetChanged 导致图片闪烁的真凶 :
(1)仅使用notifyDataSetChanged ,在重新布局时,会先remove View ,然后根据相关场景,缓存到mCachedViews 、ViewCacheExtension(项目未实现)、缓存池pool 中;在 tryGetViewHolderForPositionByDeadLine()时,会从上述缓存中复用,根据图10,如果在pool中取不到的话,会直接CreateViewHolder,重新inflate,导致闪烁。
(2)使用notifyDataSetChanged() + hasStableIds() true +复写getItemId() 或者notifyItemRangeChanged(),会缓存在 mAttachedScrap,因此不会重新createViewHolder。
原因真的如(1)所说是重新创建CreateViewHolder,导致闪烁的吗,简单实验如下:图中Customxxxx是itemView 的顶级View。
1)notifyDataSetChanged() 刷新
发现请求网络数据后,基本上没有发现CreateViewHolder (有时候会有,日志中没有打印,这里主要展示ItemView的测量和布局),却发现了itemView 重新测量 和布局了。
2)notifyDataSetChanged() + hasStableIds() true +复写getItemId()
发现请求网络数据后,并未发现itemView 重新测量和布局
上述实验说明,图片闪烁的原因不在于此,不是因为createViewHolder 造成的 ,因为第二次网络请求回来时,pool中是有缓存的。
那么闪烁的真正原因是什么?目前还有一个疑点 ,之前 我们只是分析了ViewHolder的复用,但是拿到ViewHolder(或View)后,addView 操作两种方式是不是也存在差别 。
在layoutChunk()中,拿到view后,最终会调用addViewInt()方法,如下图所示:
1) 如果holder 来自scrap,则最终调用 RecyclerView.this.attachViewToParent(child, index, layoutParams) ,用来显示View ,与之前detachView 相对应;
2)child.getParent是当前RecyclerView ,则 仅仅移动View;
3)1)和2)都不是,则直接 RecyclerView.this.addView(child, index),导致 itemView 重新测量和布局
至此,notifyDataSetChanged 导致图片闪烁的真凶 是itemView 重新测量布局导致的,并非createViewHolder 。
四、解决图片闪烁的几种方式
几种解决的办法:
1)notifyDataSetChanged()+SetHasStableIds(true) + getItemId 复写
2)使用 notifyItemRangeChanged()
notifyItemRangeChanged() 一般使用在加载更多场景,如果直接使用notifyItemRangeChanged() 代替notifyDataSetChanged 会给人一种错觉,为什么不能使用notifyDataSetChanged(),不是很好理解。
3)使用diffUtil 工具类实现刷新
具体可参看我的另一篇文章 :RecyclerView + DiffUtil 使用预研
五 、关于getItemId()的疑问
为什么这里单独介绍notifyDataSetChanged + setHasStableIds(true) + 复写getItemId() 方法,这种方式解决闪烁是没有问题的,但是存在一个大大的坑。
那就是设置 setHasStableIds(true) 时,一定要保证 有一个unique identifiters (如下图所示) ,同网上通常做法是直接复用Adapter 中的getItemId()方法,直接返回position (但是数据集的position可能会变的,并不唯一 ,大大的疑问?) 。但是,反复测试确实没有发现问题,可能是因为使用场景不同导致不易察觉。
带着疑问,Google前人杰作及官方示例代码(太简单未涉及),发现了一篇文章 Android RecyclerView 使用 ItemTouchHelper 时异常 (感谢一下),文章提及在使用ItemTouchHelper时发生闪烁数据异常,同时指出原因 是 使用setHasStableIds(true) + 复写getItemId() 返回position导致的,具体原因,并未讲明。
刚好项目中UGC 录音,音效歌曲选择那里使用到ItemTouchHelper,简单实验一下,发现果然如文章所示发生了异常,操作过程如下:滑动过程闪现闪烁,滑动停止后,发现数据异常(两个位置的数据时一样的)。
出现上述现象的原因 :
(1)在移动过程中,会调用 notifyItemMoved(fromPosition, toPosition),导致RecyclerView重新布局 ;
(2)在图9 tryGetViewHolderForPositionByDeadLine()方法中,当hasStableIds 为true 时,会通过getScrapOrCachedViewForId (如下图)获取复用viewHolder 的id 。滑动前:getItemId 为fromPosition,因此 holder.getItemId() 等于fromPosition,由于滑动RecyclerView.this.mAdapter.getItemId(offsetPosition)中的offsetPosition发生了改变,导致 id不等于fromPosition,最终导致getScrapOrCachedViewForId()为null。
这一步获取失败后,就会从缓存池pool 中获取,会重新addView ,导致 测量和布局,因此上述拖动过程会发生闪烁。
既然是因为getItemId()返回position导致的,那就返回一个唯一值不就可以了 ,返回 第postion位置data的hashCode不就可以吗 (在DB+网络情况,data内容相同,但是它的hashCode 是不同的),如果这里有更好的表示返回值方式请告诉我 !
由此得出结论 :notifyDataSetChanged + setHasStableIds(true) + 复写getItemId() 方法 并非是万能的,其解决还是要区分场景。