作者:新宿、光酒
目前闲鱼的次要业务场景都曾经应用 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 分成数据源管理器、控制器、事件回调和刷新配置四大局部。如下图所示。
- 数据源管理器:用于数据的治理,外面就波及 Sections 初始化与通常的增删改查;
- 控制器:次要用于管制 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。
再来看 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获取办法。
部分刷新之 LoadMore
LoadMore的实现绝对会比较简单,须要做的次要有两点:
1、清理widgets缓存,避免不算加载的过程中内存占用过大;保留与 _childElements 中 index 雷同的 widget;这里有一个须要特地留神的点:要过滤为 null 的 widget,否则这个地位的 widget 无奈失常展现;(_childWidgets 最初一个 index 会是一个为 null 的值,具体为什么插入一个为 null 的 widget 大家能够浏览源码寻找答案)
2、最初打脏sliver,从新layout children:
应用 Dart DevTools 的 TimeLine 数据比照两种 LoadMore 形式的耗时状况如下图:
SetState 的 timeline:
LoadMore 的 timeline:
部分刷新之 Delete
首先整顿 childWidgets 的内容,依据 delete 的 index,从新调整 childWidgets 中 widget 与 index 的对应关系。
接下来是 _childElements 的解决,如果须要删除的 index 还未创立,只须要把以后 sliver 的 RenderObject 的 layout 信息标脏,从新 layout 本人即可。留神这个过程是不会从新 layout 以后 viewport 曾经展现的 child 的。
否则要找到要删除的 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 的机制:
应用方能够为每个 item 定制较为简单的 Widget,这样在一帧工作较多时,通过肯定的策略,先 build placeholder 进行渲染,提早到之后几帧再进行理论 cell 的 build。因为 viewport 高低都有缓冲区,在延后的帧设置较少时,用户并没有机会看到 placeholder,所以业务上并不会有影响。placeholder 最显著的作用是削峰,较长的一帧耗时会被下几帧瓜分。
上面数据是应用简单商品 card 在瀑布流中的场景,应用机型为 Pixel XL。从数据上看,分帧使均匀耗时有所增加,然而90、99、最长帧耗时,都有显著的升高,丢帧数也有所缩小。
值得注意的是,对于 cell 过于简单的场景,即便一帧 build 一个都会超时,那么以 cell 为最小粒度的分帧就没有优化成果了,类比到在性能十分差的手机上,一般简单的 cell 的分帧可能会使晦涩度升高。这个时候须要升高 cell 复杂度或者放大分帧的粒度。
理论利用场景
PowerScrollView 曾经在闲鱼多个外围页面线上全量应用,如下图:
欠缺的能力、低劣的性能、较低的接入老本,都使得应用方受害颇多。
总结和瞻望
通过对列表容器能力的不断完善、晦涩度方面一直优化,目前 PowerScrollView 曾经可能更好的撑持闲鱼流式布局下的业务,给用户提供更好的应用体验。
但在一些低端机型上,长列表的体现依然不能让人称心;瀑布流等一些须要简单布局计算的场景,如何更好的优化布局计算过程,这些都是须要咱们持续摸索的方向。
目前复用实现还比拟毛糙,将来也会深刻到Flutter引擎,寻找晋升复用能力的办法,让 PowerScrollView 真正成为一个高效流式布局的解决方案。
另外在端到端研发方面,咱们在摸索将列表容器与动静模板相结合,实现端云一体的页面搭建解决方案。
关注【阿里巴巴挪动技术】微信公众号,每周 3 篇挪动技术实际&干货给你思考!
发表回复