RecyclerView 缓存复用导致动画失效问题

背景问题:

最近在项目中发现一个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中设置了四层缓存池,按照使用的优先级顺序依次是ScrapCacheViewViewCacheExtensionRecycledViewPool;其中Scrap包括mAttachedScrapmChangedScrapViewCacheExtension是默认没有实现的,它RecyclerView留给开发者拓展的回收池。

  • AttachedScrap : 不参与滑动时的回收复用,只保存重新布局时从RecyclerView分离的item的无效、未移除、未更新的holder。因为RecyclerView在onLayout的时候,会先把children全部移除掉,再重新添加进入,mAttachedScrap临时保存这些holder复用。
  • ChangedScrapmChangedScrapmAttachedScrap类似,不参与滑动时的回收复用,只是用作临时保存的变量,它只会负责保存重新布局时发生变化的item的无效、未移除的holder,那么会重走adapter绑定数据的方法。
  • CachedViews : 用于保存最新被移除(remove)的ViewHolder,已经和RecyclerView分离的视图;它的作用是滚动的回收复用时如果需要新的ViewHolder时,精准匹配(根据position/id判断)是不是原来被移除的那个item;如果是,则直接返回ViewHolder使用,不需要重新绑定数据;如果不是则不返回,再去mRecyclerPool中找holder实例返回,并重新绑定数据。这一级的缓存是有容量限制的,最大数量为2。
  • ViewCacheExtension: RecyclerView给开发者预留的缓存池,开发者可以自己拓展回收池,一般不会用到,用RecyclerView系统自带的已经足够了。
  • RecyclerPool : 是一个终极回收站,真正存放着被标识废弃(其他池都不愿意回收)的ViewHolder的缓存池,如果上述mAttachedScrapmChangedScrapmCachedViewsmViewCacheExtension都找不到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包括mAttachedScrapmChangedScrap,它们是一个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的复用逻辑的一次应用,查看下图,可以对本次着重分析的几个方法有清晰的认识。

参考文档:

深入理解 RecyclerView 的绘制流程和滑动原理(匠心巨作-上)

深入理解 RecyclerView 的回收复用缓存机制详解(匠心巨作-下)

相关推荐
安卓机器1 小时前
安卓10.0系统修改定制化____系列 ROM解打包 修改 讲解 导读篇
android·安卓10系统修改
叽哥2 小时前
flutter学习第 14 节:动画与过渡效果
android·flutter·ios
小仙女喂得猪2 小时前
2025再读Android RecyclerView源码
android·android studio
BoomHe2 小时前
车载 XCU 的简单介绍
android
程序员老刘3 小时前
操作系统“卡脖子”到底是个啥?
android·开源·操作系统
拭心3 小时前
一键生成 Android 适配不同分辨率尺寸的图片
android·开发语言·javascript
2501_915918414 小时前
iOS 文件管理全流程实战,从开发调试到数据迁移
android·ios·小程序·https·uni-app·iphone·webview
青莲8434 小时前
深拷贝 vs 浅拷贝
android
一枚小小程序员哈4 小时前
基于Android的音乐播放器/基于android studio的音乐系统/音乐管理系统
android·ide·android studio