Flutter 高性能、多功能的全场景滚动容器,一定要看!
发布时间:2025-05-22 01:38:43
作者:益华网络
来源:undefined
浏览量(2)
点赞(2)
摘要:目前闲鱼的主要业务场景都已经使用 Flutter 来实现,其中流式布局是最常见的页面布局场景(如搜索、商品详情等)。随着业务的快速迭代和业务复杂度的不断提升,对流式场景的能力和性能要求也越来越高; 在能力方面,最常见的如卡片曝光、滚动锚点、瀑布流布
目前闲鱼的主要业务场景都已经使用 Flutter 来实现,其中流式布局是最常见的页面布局场景(如搜索、商品详情等)。随着业务的快速迭代和业务复杂度的不断提升,对流式场景的能力和性能要求也越来越高;
在能力方面,最常见的如卡片曝光、滚动锚点、瀑布流布局等能力,随着业务和需求的不断变化,Flutter原生和一些开源解决方案,渐渐无法满足我们需求。
性能方面,流式场景下的列表滚动流畅度问题随着业务复杂度的增加而逐渐恶化,亟需解决以提升用户的使用体验。
针对以上在业务中面临的问题,我们设计了一套流式场景下通用的页面布局解决方案,我们将其命名为 PowerScrollView。整体架构设计
在架构设计之前,我们充分调研了原生 Native 的滚动容器:UICollectionView(iOS) 和 RecyclerView(Android)。其中UICollectionView 的 Section(段落)理念令我们印象深刻,RecyclerView 的架构设计也启发了我们。由于 Flutter 的独特性,我们不能将其照搬过来,所以我们的目标是结合 Native 成熟的滚动容器,加以 Flutter 的特点,设计出更加优秀的滚动容器。Flutter 原生有常用的 ListView、GridView,他们布局较为单一,功能较为简单。官方也提供了CustomScrollView的进阶Widget,CustomScrollView由多个 Sliver 进行拼接,以适应更复杂的使用场景,我们将基于 CustomScrollView 进行设计。从使用角度出发,整个列表由若干个 Section 组成,又将 Section 分为 header、content、footer 三部分,header 为段落的头部,一般可作为 Section 的头部装饰,支持是否吸顶;footer 为段落的尾部,作为 Section 的尾部装饰。列表拥有下拉刷新与加载更多能力;content 为 Section 的正文,支持常见的布局方式:列表、网格、瀑布流以及自定义。Section 的 content 由任意个 cell 组成,cell 即为列表最小粒度的 item。从 Flutter 原生容器出发,CustomScrollView 支持任意多个 Sliver 的组合,Sliver 提供了 SliverList、SliverGrid、SliverBox 等,已基本符合了我们要求。我们将 Section 的 header 和 footer 各对应一个 SliverBox,content 对应 SliverList 或SliverGrid,再单独为瀑布流布局开发一个 SliverWaterfall;再在整个列表的头部和尾部插入用于刷新加载更多的 Sliver。我们将 PowerScrollView 分成数据源管理器、控制器、事件回调和刷新配置四大部分。如下图所示。功能完善
我们为 PowerScrollView 完善了业务使用的核心诉求,包括自动曝光、滚动到某个 index 、瀑布流、刷新加载更多等能力。下面将重点介绍前两部分。自动曝光能力
在 Flutter 中,通常不得不将曝光放在 build 函数中,这使得曝光会错乱,不在屏幕上但是在屏幕缓冲区的部分将会被错误曝光,且有多次曝光问题,代码臃肿混乱,这都使得业务层非常头疼。曝光能力是各种业务都必须的核心诉求,我们在 PowerScrollView 中统一进行了封装,通过事件回调给使用者。前面我们知道,在 PowerScrollView 中,我们用 cell 封装了最小粒度的 item,因为对 item 的封装,使得我们的掌控力大大增强。正因为此,我们自定义了 cell 的 StatefulElement,在 element 的生命周期中 mount、unmount 记录当前 element,利用 InheritedWidget ,将树上的 element 维护在外面的列表中。在 PowerScrollView 的滚动过程中,我们会遍历检查 element 数组,筛选屏幕中的元素进行曝光回调。其中被筛选掉的即为缓冲区的元素,同时维护个数组避免单元素当次屏幕中多次曝光。为了减少滚动中的多次遍历检查 element 数组,我们加入了控制滚动采样率的可配参数,通过此参数,我们可以控制滚动一定距离后才进行检查。在复杂场景中,会存在 cell 高度先为 0,下载模板渲染后再撑开的情况,这种情况下整个 element list 数据会非常大,且数据并不正确,我们需要过滤掉这种。但是当 cell 刷新之后,有了真实的高度,我们需要进行正确的曝光。所以我们在 cell 中监听了 size 的变化,当高度由 0 到非 0 的时候,通知上层进行一次曝光。滚动到某个index
Flutter 本身提供了滚动到 position 距离的能力,但一般业务场景下,我们不知道要滚动的距离,最多知道要滚动到第几个,这使得在 Flutter 侧很多交互无法实现。这个问题我们会分几种场景进行分析。场景一:当要滚动的目标 index 的 cell 在视图树中(当前屏幕及缓冲区),由于我们已经维护了一个屏幕及缓冲区的element数组,我们可以遍历找到,然后将其滚动到可见区域即可。场景二:当要滚动的目标 index 的 cell 不在视图树中时,首先我们根据当前屏幕的 index 与目标 index 进行比较,判断是需要往上滚动还是往下滚动。然后,以较快的速度进行特定距离的滚动,滚动之后再递归,直到找到目标 index。由于滚动距离与时间的不确定性,极端情况下会没有动画效果,普通的动画效果可能也会有些生硬。性能优化
为什么要做局部刷新
在实际的流式业务场景中,经常会因为数据源的更新而刷新整个列表容器:例如加载了下一页的数据、删除或者插入某一个 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;局部刷新的实现原理
摸清了基本原理之后,我们就在思考,当列表容器内容发生变化的时候(比如 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获取方法。局部刷新之 LoadMore
LoadMore的实现相对会比较简单,需要做的主要有两点:1.清理widgets缓存,防止不算加载的过程中内存占用过大;保存与 _childElements 中 index 相同的 widget;这里有一个需要特别注意的点:要过滤为 null 的 widget,否则这个位置的 widget 无法正常展示;(_childWidgets 最后一个 index 会是一个为 null 的值,具体为什么插入一个为 null 的 widget 大家可以阅读源码寻找答案)局部刷新之 Delete
首先整理 childWidgets 的内容,根据 delete 的 index,重新调整 childWidgets 中 widget 与 index 的对应关系;局部刷新之 Insert
Insert的实现过程与上面的类似,可以根据上面的过程自行实现,这里就不做赘述;Element 复用能力
不管是 iOS 的 UITableView、UICollectionView 还是 Android 的 RecyclerView,都支持 cell 的复用能力;在 Flutter 的列表容器中,在不修改 framework 层的情况下,是否能够实现 element 的复用呢?首先我们来分析 element 被回收的过程,SliverMultiBoxAdaptorElement 通过 _childElements 来缓存 elements,当滚动超出 viewport 的显示以及预加载范围或者数据源发生变化,会通过调用 collectGarbage 方法回收不需要的 elements;分帧渲染
在实际的滑动过程中,如果一帧的时间内需要 build 过多的 cell ,很容易引起掉帧的情况,用户会感觉到卡顿。为了减少这种情况,我们在 cell 层面引入了 placeholder 的机制:实际应用场景
PowerScrollView 已经在闲鱼多个核心页面线上全量使用,如下图:总结和展望
经过对列表容器能力的不断完善、流畅度方面不断优化,目前 PowerScrollView 已经能够更好的支撑闲鱼流式布局下的业务,给用户提供更好的使用体验。但在一些低端机型上,长列表的表现仍然不能让人满意;瀑布流等一些需要复杂布局计算的场景,如何更好的优化布局计算过程,这些都是需要我们继续探索的方向。目前复用实现还比较粗糙,未来也会深入到Flutter引擎,寻找提升复用能力的方法,让 PowerScrollView 真正成为一个高效流式布局的解决方案。另外在端到端研发方面,我们在探索将列表容器与动态模板相结合,实现端云一体的页面搭建解决方案。闲不住?来闲鱼!扫一扫,关注我们
声明:本文由【益华网络】编辑上传发布,转载此文章须经作者同意,并请附上出处【益华网络】及本页链接。如内容、图片有任何版权问题,请联系我们进行处理。
2