在实际的流式业务场景中,经常会因为数据源的更新而刷新整个列表容器:例如加载了下一页的数据、删除或者插入某一个 cell,甚至某个 cell 的一个按钮状态的变化;
刷新范围过大往往是造成列表容器卡顿、流畅度降低的主要原因,严重影响了用户的操作体验。所以我们需要尽量减少 Widget tree 打脏刷新的范围,减少 Element rebuild 的调用,实现局部刷新的能力。
Viewport 刷新的过程
为什么说整个列表容器打脏刷新会带来这面严重的耗时呢?我们来简单看一下 Viewport 的刷新过程。
列表容器被打脏之后,会做两个关键的操作:
Viewport 所有 sliver 的 Element 都会 rebuild;
Viewport 也会重新 layout,进而所有的 sliver 也会重新 layout;
我们来先看 Viewport layout 的过程:这个方法的核心,首先找到当前的 center sliver(默认是第一个child)的位置,然后向上、向下遍历Viewport每一个sliver;每个 child sliver 根据当前 Viewport 在 Scrollview 中的 scrollOffset,Viewport的大小以及cacheExtent大小等信息 (SliverConstraints),计算当前需要展示的child的index范围,layout 每一个在可显示范围的child;
以下图例,SliverList可视范围内需要layout的child index为2~3;SliverGrid需要layout的child index为0~3;
再来看 Viewport 所有 sliver 的 Element rebuild 的过程,这个过程才是列表容器刷新耗时的关键;
我们先来看一下常见的几种布局 SliverList、SliverGrid 以及我们自定义的瀑布流布局 SliverWaterfall 的实现,它们都继承自SliverMultiBoxAdaptorWidget,一个管理多 child(Box模型)的 sliver 的基类;它对应的 Element 是 SliverMultiBoxAdaptorElement,主要负责 child 的创建、更新、移除等生命周期相关的工作,这正是局部刷新需要精细处理的地方。
SliverMultiBoxAdaptorElement 内部维护两个 Map,缓存 child element 以及 child widget,在 ViewPort 需要的时候(上面提到的layout过程)lazily build 自己的 child;
rebuild 过程之所以耗时是因为要清空所有 child widget 缓存,重新 build child widget,update child Element;如果遇到数据的变化,例如 insert、delete,很有可能导致 element 无法复用,这样 rebuild 的成本会更高。
局部刷新的实现原理
摸清了基本原理之后,我们就在思考,当列表容器内容发生变化的时候(比如 insert、delete、LoadMore),是否可以做出一些优化,只让发生变化的部分去 build、layout 呢?
首先我们认为 sliver 的 Element 全部 rebuild 的做法过于简单粗暴,我们可以通过更精准的控制 sliver element 中,childWidgets 与 childElements,来实现局部刷新的目的;
下面我们来看看针对与具体的场景,如何实现精准的 childWidgets 与 childElements 控制,实现局部刷新的能力的。
可变的 child count
在常见的需要局部刷新的场景,容器元素的数量往往会发生变化。在常见的 CustomScrollview 使用中,childCount 都是创建时指定的,当 childCount 方式变化,就需要重新 build 列表容器;
第一步就是避免因为 sliver 内部元素数量变化,必须重新build整个容器的问题;
虽然也可以使用childCount为空,根据builder返回null来决定是否为最后一个child的方式实现可变childCount的目的,但这种方式并不太符合常用的习惯,对使用方也会增加额外成本,所以并未采用这种方式。
做法比较简单,通过继承自SliverChildBuilderDelegate,修改childCount获取方法。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-D4Lrk4S7-1612336882848)(https://upload-images.jianshu.io/upload_images/24957688-78c067342b61a505.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)]
局部刷新之 LoadMore
LoadMore的实现相对会比较简单,需要做的主要有两点:
- 清理widgets缓存,防止不算加载的过程中内存占用过大;保存与 _childElements 中 index 相同的 widget;这里有一个需要特别注意的点:要过滤为 null 的 widget,否则这个位置的 widget 无法正常展示;(_childWidgets 最后一个 index 会是一个为 null 的值,具体为什么插入一个为 null 的 widget 大家可以阅读源码寻找答案)
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-ryEHpvfJ-1612336882849)(https://upload-images.jianshu.io/upload_images/24957688-0efe4d1ea3d7e692.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)]
- 最后打脏sliver,重新layout children:
使用 Dart DevTools 的 TimeLine 数据对比两种 LoadMore 方式的耗时情况如下图:
SetState 的 timeline:
LoadMore 的 timeline:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-PCCr2kh3-1612336882851)(https://upload-images.jianshu.io/upload_images/24957688-aca4817353ae164a.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)]
局部刷新之 Delete
首先整理 childWidgets 的内容,根据 delete 的 index,重新调整 childWidgets 中 widget 与 index 的对应关系;
接下来是 _childElements 的处理,如果需要删除的 index 还未创建,只需要把当前 sliver 的 RenderObject 的 layout 信息标脏,重新 layout 自己即可。注意这个过程是不会重新 layout 当前 viewport 已经展示的 child 的
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-uUJu4Otl-1612336882852)(https://upload-images.jianshu.io/upload_images/24957688-86350bab365b7ef3.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)]
否则要找到要删除的 child element,deactivate 对应的 element,其对应的 RenderObject 从 Render tree 上移除:
这个过程同时会维护好 child 的 RenderObject 中 ParentData 的 previousSibling 和 nextSibling 的关系;
接下来调整 _childElements 中 Element 与 index 的对应关系;
最后更新每一个 child 的 slot:
最后将sliver的RenderObject标脏,下一帧重新layout刷新。
局部刷新之 Insert
Insert的实现过程与上面的类似,可以根据上面的过程自行实现,这里就不做赘述;
Element 复用能力
不管是 iOS 的 UITableView、UICollectionView 还是 Android 的 RecyclerView,都支持 cell 的复用能力;在 Flutter 的列表容器中,在不修改 framework 层的情况下,是否能够实现 element 的复用呢?
首先我们来分析 element 被回收的过程,SliverMultiBoxAdaptorElement 通过 _childElements 来缓存 elements,当滚动超出 viewport 的显示以及预加载范围或者数据源发生变化,会通过调用 collectGarbage 方法回收不需要的 elements;
我们可以通过重写 collectGarbage 的方式,在不使用 keepAlive 的情况下,截获本该 deactive 的 child element,放入缓冲池中;在需要创建 element 的时候,优先从缓冲池获取;
虽然原理比较简单,也会遇到一些需要注意的点:需要缓存的 element 需要通过 remove 方法,将它从 childList 中移除,而不是真正的销毁 element, 如果将它被置为 defunct 状态,这样就无法复用了。
因为业务中卡片布局基本相同,这里面复用的逻辑做的相对简单,事实上针对卡片类型复用才能发挥出最好的效果。
分帧渲染
在实际的滑动过程中,如果一帧的时间内需要 build 过多的 cell ,很容易引起掉帧的情况,用户会感觉到卡顿。为了减少这种情况,我们在 cell 层面引入了 placeholder 的机制:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-3pg4qdx8-1612336882855)(https://upload-images.jianshu.io/upload_images/24957688-bfbc2c011999ced6.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)]
使用方可以为每个 item 定制较为简单的 Widget,这样在一帧任务较多时,通过一定的策略,先 build placeholder 进行渲染,延迟到之后几帧再进行实际 cell 的 build。由于 viewport 上下都有缓冲区,在延后的帧设置较少时,用户并没有机会看到 placeholder,所以业务上并不会有影响。placeholder 最明显的作用是削峰,较长的一帧耗时会被下几帧瓜分。
下面数据是使用复杂商品 card 在瀑布流中的场景,使用机型为 Pixel XL。从数据上看,分帧使平均耗时有所增加,但是90、99、最长帧耗时,都有明显的降低,丢帧数也有所减少。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-3ugeuBaT-1612336882855)(https://upload-images.jianshu.io/upload_images/24957688-1a00854a96bdffb4.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)]
值得注意的是,对于 cell 过于复杂的场景,即使一帧 build 一个都会超时,那么以 cell 为最小粒度的分帧就没有优化效果了,类比到在性能非常差的手机上,普通复杂的 cell 的分帧可能会使流畅度降低。这个时候需要降低 cell 复杂度或者缩小分帧的粒度。
PowerScrollView 已经在闲鱼多个核心页面线上全量使用,如下图:
完善的能力、优良的性能、较低的接入成本,都使得使用方受益颇多。
自我介绍一下,小编13年上海交大毕业,曾经在小公司待过,也去过华为、OPPO等大厂,18年进入阿里一直到现在。
深知大多数初中级Android工程师,想要提升技能,往往是自己摸索成长或者是报班学习,但对于培训机构动则近万的学费,着实压力不小。自己不成体系的自学效果低效又漫长,而且极易碰到天花板技术停滞不前!
因此收集整理了一份《2024年Android移动开发全套学习资料》,初衷也很简单,就是希望能够帮助到想自学提升又不知道该从何学起的朋友,同时减轻大家的负担。
既有适合小白学习的零基础资料,也有适合3年以上经验的小伙伴深入学习提升的进阶课程,基本涵盖了95%以上Android开发知识点,真正体系化!
由于文件比较大,这里只是将部分目录截图出来,每个节点里面都包含大厂面经、学习笔记、源码讲义、实战项目、讲解视频,并且会持续更新!
如果你觉得这些内容对你有帮助,可以扫码获取!!(备注:Android)
学习分享,共勉
Android高级架构师进阶之路
题外话,我在阿里工作多年,深知技术改革和创新的方向,Android开发以其美观、快速、高效、开放等优势迅速俘获人心,但很多Android兴趣爱好者所需的进阶学习资料确实不太系统,完整。今天我把我搜集和整理的这份学习资料分享给有需要的人
- Android进阶知识体系学习脑图
- Android进阶高级工程师学习全套手册
- 对标Android阿里P7,年薪50w+学习视频
- 大厂内部Android高频面试题,以及面试经历
《互联网大厂面试真题解析、进阶开发核心学习笔记、全套讲解视频、实战项目源码讲义》点击传送门即可获取!
册**
[外链图片转存中…(img-KtJzwcoP-1712618740510)]
- 对标Android阿里P7,年薪50w+学习视频
[外链图片转存中…(img-VhIQbmcr-1712618740510)]
- 大厂内部Android高频面试题,以及面试经历
[外链图片转存中…(img-Xea72zKJ-1712618740510)]
《互联网大厂面试真题解析、进阶开发核心学习笔记、全套讲解视频、实战项目源码讲义》点击传送门即可获取!