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 的回收复用缓存机制详解(匠心巨作-下)

相关推荐
雨白12 小时前
Android 快捷方式实战指南:静态、动态与固定快捷方式详解
android
hqk12 小时前
鸿蒙项目实战:手把手带你实现 WanAndroid 布局与交互
android·前端·harmonyos
LING13 小时前
RN容器启动优化实践
android·react native
恋猫de小郭15 小时前
Flutter 发布官方 Skills ,Flutter 在 AI 领域再添一助力
android·前端·flutter
Kapaseker20 小时前
一杯美式搞懂 Any、Unit、Nothing
android·kotlin
黄林晴20 小时前
你的 Android App 还没接 AI?Gemini API 接入全攻略
android
恋猫de小郭1 天前
2026 Flutter VS React Native ,同时在 AI 时代 VS Native 开发,你没见过的版本
android·前端·flutter
冬奇Lab1 天前
PowerManagerService(上):电源状态与WakeLock管理
android·源码阅读
BoomHe2 天前
Now in Android 架构模式全面分析
android·android jetpack
二流小码农2 天前
鸿蒙开发:上传一张参考图片便可实现页面功能
android·ios·harmonyos