背景问题:
最近在项目中发现一个RecyclerView的奇怪现象,跟RV的复用有关系。
在RV的item中,左侧有一个Switch,在点击该item的时候
1.将点击事件回调给Adapter--->Activity--->ViewModel
2.修改数据源后,通过setDiff刷新该item
3.通过onBindViewHolder刷新选中态,此时Switch会有一个选中动画
简化页面结构如下:

简化流程图如下:

现象:
实际运行中,发现该Switch动画没有播放,但是Switch的选中态又是正确的。 下图一为预期表现,Switch的左右切换动画是流畅的。 下图二所示为实际表现,Switch的状态是瞬间切换的。
简略原因:
最终定位发现,是因为item复用导致了更新时Switch已经是目标状态了。甚至会出现,首次点击时,RV会创建出一个新的item出来,流程图变成了这样:

一、RecyclerView源码引入
为了分析RV为什么会出现上面的复用问题,从github-androidx上面下载源码,并将RecyclerView部分放入到Demo工程中,方便调试。
RecyclerViewActivity代码:github.com/FrankdeBoer...
Adapter代码:github.com/FrankdeBoer...

二、应用层日志分析
添加日志:
在onCreateViewHolder、onBindViewHolder加入日志,查看生命周期调用:

日志输出如下:

通过上述日志分析,会发现:
1.在首次点击item1的时候,又走了一次onCreateViewHolder。
2.再次点击item1,只会走onBindViewHolder,并且holder的hash值一直在两个ViewHolder来回交替变化。

3.最重要的,我们onBindViewHolder的前打印的日志,会发现Switch已经是目标状态了。
因为自定义的Switch有防重入设计,设置状态时,如果已经是目标状态,则直接返回。这里导致了动画没有执行,但状态是正确的。

总结:
页面初始化完成后,点击item1,会重新create一个新的ViewHolder,并且两个ViewHolder会交替使用。
那么,系统为什么这么设计,以及如何实现的呢?
尤其是需要思考一下,系统是如何做到两个ViewHolder交替使用的?即另一个ViewHolder是存放在哪里了?mAttachedScrap or mChangedScrap 还是另有答案?
三、问题分析
1.RV四级缓存:
这篇文章的RV缓存写的非常细致:深入理解 RecyclerView 的回收复用缓存机制详解(匠心巨作-下)
但是文章中没有提到item原地刷新时的表现,接下来站在巨人的肩膀上,分析上述场景。
Recycler
中设置了四层缓存池,按照使用的优先级顺序依次是Scrap
、CacheView
、ViewCacheExtension
、RecycledViewPool
;其中Scrap
包括mAttachedScrap
和mChangedScrap
,ViewCacheExtension
是默认没有实现的,它RecyclerView留给开发者拓展的回收池。
- AttachedScrap : 不参与滑动时的回收复用,只保存重新布局时从RecyclerView分离的item的无效、未移除、未更新的holder。因为RecyclerView在
onLayout
的时候,会先把children
全部移除掉,再重新添加进入,mAttachedScrap
临时保存这些holder复用。 - ChangedScrap :
mChangedScrap
和mAttachedScrap
类似,不参与滑动时的回收复用,只是用作临时保存的变量,它只会负责保存重新布局时发生变化的item的无效、未移除的holder,那么会重走adapter绑定数据的方法。 - CachedViews : 用于保存最新被移除(
remove
)的ViewHolder,已经和RecyclerView分离的视图;它的作用是滚动的回收复用时如果需要新的ViewHolder时,精准匹配(根据position/id
判断)是不是原来被移除的那个item;如果是,则直接返回ViewHolder使用,不需要重新绑定数据;如果不是则不返回,再去mRecyclerPool
中找holder实例返回,并重新绑定数据。这一级的缓存是有容量限制的,最大数量为2。 - ViewCacheExtension: RecyclerView给开发者预留的缓存池,开发者可以自己拓展回收池,一般不会用到,用RecyclerView系统自带的已经足够了。
- RecyclerPool : 是一个终极回收站,真正存放着被标识废弃(其他池都不愿意回收)的ViewHolder的缓存池,如果上述
mAttachedScrap
、mChangedScrap
、mCachedViews
、mViewCacheExtension
都找不到ViewHolder的情况下,就会从mRecyclerPool
返回一个废弃的ViewHolder实例,但是这里的ViewHolder是已经被抹除数据的,没有任何绑定的痕迹,需要重新绑定数据。它是根据itemType
来存储的,是以SparseArray
嵌套一个ArraryList的形式保存ViewHolder的。
2.RV源码分析:
在Adapter的onCreateViewHolder上面打个断点,会看到调用栈是dispatchLayoutStep2触发的。

3.RV绘制流程:
在这篇文章中,对于RV的刷新流程做了详述:深入理解 RecyclerView 的绘制流程和滑动原理(匠心巨作-上)

在Layout的时候,会先将屏幕上所有的item与屏幕分离,将他们从RV的布局中拿下来,放到list中,在重新布局时,再将ViewHolder一个个放到屏幕上面去。将屏幕上的ViewHolder从RecyclerView的布局中拿下来后,存放在Scrap
中,Scrap
包括mAttachedScrap
和mChangedScrap
,它们是一个list,用来保存从RecyclerView布局中拿下来ViewHolder列表。
4.tryGetViewHolderForPositionByDeadline:
重新查看这个堆栈,createViewHolder是被tryGetViewHolderForPositionByDeadline方法调用的,从方法名也可以看出,这里是查看屏幕指定位置item的,没有找到就会创建一个新的ViewHolder。

tryGetViewHolderForPositionByDeadline流程如下,是按照屏幕内缓存-->屏幕外缓存-->自定义缓存-->全局缓存-->新创建ViewHolder 的流程。

当四级缓存中都无法查找到对应的ViewHolder,就会重新创建。
5.重新认识屏幕内缓存scrap
到这里,已经找到了系统新创建ViewHolder的流程,至于为什么重建,后面再分析。
那么下一个问题,系统是如何实现两个ViewHolder交替使用的?
需要知道,屏幕内缓存attachedScrap/changedScrap是临时缓存,仅在RV需要重新绘制的时候,暂存屏幕内缓存数据,用于后面item重绘后快速上屏,查看源码也知道,在一次绘制完成后,会清空上述缓存。如下图,dispatchLayoutStep3是RV绘制过程中的onLayout()触发的。

中间的debug过程忽略,最终发现,同一个position,两个ViewHolder交替使用,是因为RV将另一个ViewHolder放到了全局缓存池中,所以每次都能够拿到另一个ViewHolder。
如下图断点所示,前面的步骤中holder都是空,走了getRecycledViewPool().getRecycledView(type) 后holer有值。

本part只是为了说明,屏内缓存Scrap仅在RV重绘过程中有效,无法在两次重绘间传递缓存数据。
6.为什么会走createViewHolder流程
part5也间接解释了,为什么首次点击item1时,会走createViewHolder流程,因为首次点击的时候,全局缓存池没有该类型的ViewHolder,只能创建。创建一次后,后续就可以通过全局缓存池拿到该类型ViewHolder。
基于此,再深入思考一下,点击item1后,再点击item2,还会走createViewHolder吗?
答案是不会,因为全局缓存池中,有同类型的ViewHolder,直接走了复用流程。通过下面日志可以确认:

四、ViewHolder重建的本质原因
原因:
前面的分析都是现象,即代码运行逻辑是这样的,接下来需要分析RV为什么会这样设计,本质原因是什么。
也就是,为什么在首次点击item1的时候,不能直接用屏幕缓存的ViewHolder,而是新创建了一个。
因为已经可以debug源码,这个论证的过程非常复杂,这里直接从正向去分析问题。
根本原因是RV为了保证能够做item动画(缩放、淡出等),所以只能创建一个新的item。
RV是支持item动画的,比如删除时,item做左移、alpha等动画,如果不创建出一个新的ViewHolder,在原有的item上面直接做动画,则动画持续时间过长,就不能在绘制的时候,用这个唯一的ViewHolder做测量等工作了。
验证:
简单做个验证,我们将RV的itemAnimator置为空,再重复上述流程:


源码:
大概说一下整体逻辑,每次触发onLoyout()-->dispatchLayoutStep1/2/3,都会调用LayoutManager.onLayoutChildren(),这里每次都会去调用detachAndScrapAttachedViews(),将屏幕内的item回收到attachedScrap,然后获取到后就删除该item。
在detachAndScrapAttachedViews添加缓存的时候,canReuseUpdatedViewHolder会判断是否有动画,如果有动画则不能复用,也就不能添加。


所以首次点击item1,在第一次dispatchLayoutStep2时,没有添加屏幕内的item到attachedScrap,当后面获取的时候也就无法获取,只能新创建item1_NEW。
同时,item1_OLD执行完动画,会被加入到全局缓存池。
当第二次点击item1时,全局缓存池可以获取到同类型的item1,即item1_OLD。所以无需再次创建。

五、优化建议
对于静态列表RV,或者没有特殊动画要求的RV,将itemAnimator置空是很好的优化手段,不只可以节省内存,从RV绘制流程上,也可以少走很多逻辑,对提升流畅度有一定优化。
同时,也可以解决ViewHolder刷新时,动画播放的问题。
六、总结
问题:
当首次点击RV中的某个item时,会再次创建一个新的ViewHolder,新的ViewHolder与老的ViewHolder交替使用,导致动画没有播放。
原因:
RV默认有itemAnimator,有动画的情况下,item无法复用,所以会创建新的ViewHolder。放到全局缓存池进行复用。
上述内容涉及到RV的绘制、缓存逻辑,主要是对RV的复用逻辑的一次应用,查看下图,可以对本次着重分析的几个方法有清晰的认识。

参考文档: