RecyclerView notifyDataSetChanged 导致图片闪烁的真凶

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

图1 企鹅FM 首页

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

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

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

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

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

图 2 onChanged()

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

图3 RecyclerView.this.setDataSetChangedAfterLayout()

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

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

图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 。

图5 RecyclerView::onMeasure()

(2)RecyclerView::onLayout ()

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

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

图 6:LinearLayoutManager::onLayoutChildren()                   

  1)detachAndScrapAttachedViews()

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

分支1:

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

分支2:

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

图7:detachAndScrapAttachedViews()

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

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

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

图8 fill()调用链

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

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

图9  tryGetViewHolderForPositionByDeadLine()

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

(3)RecyclerView 的四级缓存

1)屏幕内缓存

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

 mAttachedScrap : 表示未与RecyclerView分离的ViewHolder

2)屏幕外缓存

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

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

4)缓存池

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

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

图11  notifyDataSetChanged()

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

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

图 12 notifyDataSetChanged + setHasStableIds(true) + 复写getItemIds()

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

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

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

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

图14 setHasStableIds()

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

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

图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

图 16 getScrapOrCachedViewForId()

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

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

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

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 157,298评论 4 360
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 66,701评论 1 290
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 107,078评论 0 237
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,687评论 0 202
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,018评论 3 286
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,410评论 1 211
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,729评论 2 310
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,412评论 0 194
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,124评论 1 239
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,379评论 2 242
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 31,903评论 1 257
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,268评论 2 251
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 32,894评论 3 233
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,014评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,770评论 0 192
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,435评论 2 269
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,312评论 2 260

推荐阅读更多精彩内容