RecyclerView notifyDataSetChanged 导致图片闪烁的真凶

       目前,在项目中一些主要页面(如图1 首页,采用ViewPager+TabLayout ,已实现数据懒加载)都会缓存第一页数据(网络)存DB,下次进来时会先请求DB数据,然后再请求网络数据,这样用户体验比较好。之前我们主要页面都是使用RadioPullToRefreshListView(封装的PullToRefreshListView,后面简称ListView)方式实现(刷新方式使用的是notifyDataSetChanged),这样不管DB中的数据和网路请求数据是否一样,都会刷新两次,体验上没有什么问题,自从4.0版本陆续改用RecyclerView 方式实现,发现有图片加载的地方会闪烁一下(录屏效果不怎么好,这里就不放图了,脑补一下)。

RecyclerView notifyDataSetChanged 导致图片闪烁的真凶_第1张图片
图1 企鹅FM 首页

       仅仅只是用RecyclerView 替换ListView 逻辑主体并未修改,因此目光很快锁定在RecyclerView上面,网上一搜索,类似问题有很多,大多说是没有设置setStableIds(true) 和复写getItemId()导致的

      照葫芦画瓢,我给对应的Adapter 设置了setStableIds(true) 和复写getItemId(),发现闪络问题依然存在,至于为什么当时没解决(其实可以解决,后面给原因),就放弃尝试这种方式。

      我们都知道RecylerView  在ListView 的基础上新增了 很多种刷新方式,既然notifyDataSetChanged()不行 ,于是乎改用了notifyItemRangeChanged(int positionStart, int itemCount),发现改方法竟然解决了上述闪烁问题。

一 、对比两种刷新方式异同点

(1)notifyDataSetChanged()最后作用于onChanged

RecyclerView notifyDataSetChanged 导致图片闪烁的真凶_第2张图片
图 2 onChanged()

       唯独可疑的是:RecyclerView.this.setDataSetChangedAfterLayout(),将ViewHolder标记为更新或无用,暂时记录一下,后续会讲。

RecyclerView notifyDataSetChanged 导致图片闪烁的真凶_第3张图片
图3 RecyclerView.this.setDataSetChangedAfterLayout()

(2)RecyclerView 中刷新(如notifyItemRangeChanged()

       notifyItemRangeChanged 等方法最后,都会调用triggerUpdateProcessor() ,由于主页面基本都是多ViewType 情况,一般不会设置HasFixedSize为true,因而最终只会调用else 分支去。

RecyclerView notifyDataSetChanged 导致图片闪烁的真凶_第4张图片
图4  triggerUpdateProcessor()

       从(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 。

RecyclerView notifyDataSetChanged 导致图片闪烁的真凶_第5张图片
图5 RecyclerView::onMeasure()

(2)RecyclerView::onLayout ()

         这里不打算细讲,跳过1000字 ,RecyclerView 之所以能够灵活布局,是因为它将onLayout()委托给了LayoutManager ,这里以LinearLayoutManager 为例说明:

LinearLayoutManager::onLayoutChildren()(调用链  RecyclerView::dispatchLayout()->dispatchLayoutStep2() ->onLayoutChildren()) ,在onLayoutChildren() 讲解两个比较重要的函数:detachAndScrapAttachedViews(recycler) 用于处理ViewHolder复用(View复用),fill()执行布局。

RecyclerView notifyDataSetChanged 导致图片闪烁的真凶_第6张图片
图 6:LinearLayoutManager::onLayoutChildren()                   

  1)detachAndScrapAttachedViews()

        detachAndScrapAttachedViews() 会循环遍历所有的View ,根据ViewHolder 的状态 及Adapter.hasStableIds() 来进行不同操作。

分支1:

ViewHolder 无用&&没有Remove&&hasStableIds为false ,会删除对应View,同时根据条件选择 保存在mCachedViews 和 addViewHolderToRecycledViewPool ;

分支2:

先detachView ,同时根据条件保存在 mChangedScrap 和 mAttachedScrap 中;

RecyclerView notifyDataSetChanged 导致图片闪烁的真凶_第7张图片
图7:detachAndScrapAttachedViews()

       看到上面的条件,是不是很熟,在notifyDataSetChanged 时,会先将ViewHolder 设置成无效。在此打断点,发现 单独 notifyDataSetChanged() 情况,确实会走进if 分支(notifyItemRangeChanged()会进入else 分支),那设置hasStableIds(true)应该不会吧,发现竟然还是走进去了 ,纳尼????

        仔细一看,对应的Adapter 不对,竟然是封装的RecyclerView组件,内部的WrapAdapter (方便实现加载更多等操作),对应修改组件内的WrapAdapter ,设置hasStableIds(true) 和 复写getItemId(),仍然使用notifyDataSetChanged ,闪烁问题果然没有了 。

       根据detachAndScrapAttachedViews()发现,两种刷新方式不同在于对ViewHoler的缓存方式不一样,那先看看布局时是如何复用的,fill() 函数是真正进行布局的:

RecyclerView notifyDataSetChanged 导致图片闪烁的真凶_第8张图片
图8 fill()调用链

从上可以看到 View 是通过next()方式获取 :

next()->recycler.getViewForPosition()->tryGetViewHolderForPositionByDeadline()

RecyclerView notifyDataSetChanged 导致图片闪烁的真凶_第9张图片
图9  tryGetViewHolderForPositionByDeadLine()

       从上可以看到ViewHolder创建或复用的过程,为了弄懂里面的流程,我们接着简要介绍一下RecyclerView的四级缓存是如何实现的。

(3)RecyclerView 的四级缓存

1)屏幕内缓存

        mChangedScrap: 表示数据已经改变的ViewHolder

 mAttachedScrap : 表示未与RecyclerView分离的ViewHolder

2)屏幕外缓存

      mCachedViews:屏幕划出屏幕时,ViewHolder会被缓存在mCachedViews  (默认 2)

3)自己实现ViewCacheExtension类实现自定义缓存

4)缓存池

tryGetViewHolderForPositionByDeadLine()中操作,可细化为如下流程图(网络盗图):

RecyclerView notifyDataSetChanged 导致图片闪烁的真凶_第10张图片
图 10 缓存策略图 (后续修改)

       结合 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 重新测量 和布局了。

RecyclerView notifyDataSetChanged 导致图片闪烁的真凶_第11张图片
图11  notifyDataSetChanged()

2)notifyDataSetChanged() + hasStableIds() true +复写getItemId()

发现请求网络数据后,并未发现itemView 重新测量和布局

RecyclerView notifyDataSetChanged 导致图片闪烁的真凶_第12张图片
图 12 notifyDataSetChanged + setHasStableIds(true) + 复写getItemIds()

       上述实验说明,图片闪烁的原因不在于此,不是因为createViewHolder 造成的 ,因为第二次网络请求回来时,pool中是有缓存的。

       那么闪烁的真正原因是什么?目前还有一个疑点  ,之前 我们只是分析了ViewHolder的复用,但是拿到ViewHolder(或View)后,addView 操作两种方式是不是也存在差别   。

       在layoutChunk()中,拿到view后,最终会调用addViewInt()方法,如下图所示:

RecyclerView notifyDataSetChanged 导致图片闪烁的真凶_第13张图片
图13 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可能会变的,并不唯一 ,大大的疑问?) 。但是,反复测试确实没有发现问题,可能是因为使用场景不同导致不易察觉。

RecyclerView notifyDataSetChanged 导致图片闪烁的真凶_第14张图片
图14 setHasStableIds()

      带着疑问,Google前人杰作及官方示例代码(太简单未涉及),发现了一篇文章 Android RecyclerView 使用 ItemTouchHelper 时异常 (感谢一下),文章提及在使用ItemTouchHelper时发生闪烁数据异常,同时指出原因 是 使用setHasStableIds(true) + 复写getItemId() 返回position导致的,具体原因,并未讲明。

       刚好项目中UGC 录音,音效歌曲选择那里使用到ItemTouchHelper,简单实验一下,发现果然如文章所示发生了异常,操作过程如下:滑动过程闪现闪烁,滑动停止后,发现数据异常(两个位置的数据时一样的)。

RecyclerView notifyDataSetChanged 导致图片闪烁的真凶_第15张图片
图15 操作截图 

       出现上述现象的原因 :

(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

RecyclerView notifyDataSetChanged 导致图片闪烁的真凶_第16张图片
图 16 getScrapOrCachedViewForId()

      这一步获取失败后,就会从缓存池pool 中获取,会重新addView ,导致 测量和布局,因此上述拖动过程会发生闪烁。

       既然是因为getItemId()返回position导致的,那就返回一个唯一值不就可以了 ,返回 第postion位置data的hashCode不就可以吗  (在DB+网络情况,data内容相同,但是它的hashCode 是不同的),如果这里有更好的表示返回值方式请告诉我 !

由此得出结论 :notifyDataSetChanged + setHasStableIds(true) + 复写getItemId() 方法 并非是万能的,其解决还是要区分场景。

你可能感兴趣的:(RecyclerView notifyDataSetChanged 导致图片闪烁的真凶)