说起移动端跨平台解决方案,Flutter 无疑是最近被谈到最多的话题。相对于 React Native 这样的前端技术栈,Flutter 更贴近于客户端的技术栈特性,所以迅速获得大批原移动端开发的热烈拥护,再加上其优秀的渲染性能和友好的开发模式,目前已经在业内被广泛使用。
携程酒店研发从去年底开始对 Flutter 进行可行性调研,在今年年初陆续完成了酒店详情页和酒店列表页的转 Flutter 工作,通过这项工作,实现了客户端技术栈的统一,大大提高了研发效率和双端一致性。在 Flutter 开发的过程中,对 CustomScrollView 的使用是比较多的,这也是我们开发过程中比较重要和复杂的控件。
图 1 CustomScrollView 可承载的子布局类型
CustomScrollView 是 Flutter 的 SDK 提供的实现长列表的控件。它像一个强大的粘合剂,如图 1 所示在此控件中我们可以将各种不同的布局,比如列表,网格,瀑布流,吸顶组件等,在其里面组合,实现较为复杂的页面。以往在 Native 的开发中,官方组件没有提供如此强大的组合能力,我们在 Native 中实现列表中组合不同布局,或者是通过 index 映射布局类型这种异构的方式,或者需要自己去自定义一个能够组合不同布局的控件,都没有 CustomScrollView 方便。
图 2 酒店详情页使用的主要 sliver 类型
图 2 是携程酒店详情页主要模块所使用到的布局类型。如上文提到,系统提供的布局方式还是很强大的,基本能够满足我们这个相对复杂页面大多数的布局要求,当然有些特殊的模块,需要去做一些定制,比如通过定制“paintOrigin”实现的日历模块的特殊吸顶交互等。
对于这个较复杂且使用广泛的组件的内部实现原理有较深入的了解,对于我们的应用以及后续的性能优化都有较大意义。因此本文将对其实现原理做一定的剖析,并就其在实际工作中的应用实践给出具体例子。
一、概述
1.1 Flutter 渲染流程简述
图 3 Flutter 渲染流程
FlutterUI 绘制的驱动主要可以简述如图 3 所示。可以看到,Flutter 的 Framewrok 在启动初始化后主要构建了四颗树 Widget、Element、RenderObject 和 Layer。然后在系统 Vsync 的驱动下,通过它们的改变生成出绘制每一帧画面的数据,然后显示到屏幕上。
其中的 Widget 树是平常接触最多的一颗树,它类似一颗配置数据树,配置页面的样子。而 RenderObject 树则是一颗真正的实现生成绘制内容树,完成各个控件的大小计算,布局,以及绘制数据,它的数据来源就是前面的 Widget 树。
中间的 Element 树更像是一个媒介,因为 Flutter 借鉴了当今比较流行的 React 的思想,它并不希望我们还是像以前在 Native 的时候直接去操作 RenderObject,而是希望我们在它的框架下面只配置我们想要什么,以及状态怎么改变,而最终的复杂的位置计算和如何绘制交给它解决。因此中间的 Element 树因此就应运而生,它会负责根据 Widget 树去生成和改变 RenderObject 树,当然这个过程中会做一定的 Diff 策略,从而尽量减少 RenderObject 树的变化,因为 RenderObject 树的变化相对来说是比较大的。
最终 RenderObject 树会生成 Layer 树,Layer 树是 Flutter engine 所需要的数据格式,Flutter engine 会利用这颗树进行相应渲染,并最终绘制在我们宿主平台提供给 Engine 的画布上。
图 4 CustomScrollView 的三层结构
CustomScrollView 作为 Flutter 提供的控件,其内部结构肯定也是上述这样,图 4 给出了其三层(Widget,Element,RenderObject)对应的结构图。
首先看一下其在 Widget 这层的主要构成。总的来说由两部分构成:第一部分是 Srollable,这层主要是接受用户手势同时根据配置参数,决定相应的滑动位置;第二部分是真正要显示的内容 ViewPort,这层会根据监听 Srollable 给设置的 offset,去将自己的显示内容也就是一个个的 sliver 展示出来。
中间的一层是 Viewport Element,然后就是最后的 RenderObject 层。RenderObject 主要是由展示窗口 RenderViewPort 和其具体的展示内容条目 List(Render sliver)组成。
这样的分层设计方式还是很清晰,解耦的,相对于以往 Native 将上述的大部分内容聚合在一个 View 类里面,Flutter 在这方面还是做了相应的设计的。尽量将不同职责的内容做了拆分,完成高内聚低耦合,从而能在多变的场景的应用中组合,实现相应的功能。
总的来说,不管是 Widget 还是 RenderObject 层,各自都可以对应的分成两部分,一部分负责监听用户手势然后计算自己对应滑动偏移值 Offset,还有一部分则是具体展示内容,以及相应地怎么布局。下面我们以一个垂直向下滚动的 CustomScrollView 为例对它的实现做一些具体的剖析。
二、Srollable
2.1 Srollable 总述
图 5 Srollable 的 Build 方法
首先我们来看一下 Srollable 的 builder 方法如图 5 所示。
在 Srollable 最外面一层是 Srollablescrope,这层可以理解为一个辅助层。我们可以利用它在 Srollable 的子 Widget 里很方便地锁定到对应的 Srollable。在 Srollable 中有一个“of”方法,这个方法就是依靠 Srollablescrope 的“Type”很方便地定位到 Srollable。
而在 Srollablescrope 的 child 层则是此方法的核心,主要是通过“RawGestureDetector”去监听了用户的滑动手势,从而让 Srollable 根据用户的滑动手势去做相应的位置变化。
2.2 触摸事件的监听
下面主要介绍一下主要的 4 个触摸事件处理:
1)DragDown
图 6 dragDown 触摸事件
如图 6 所示,这个事件主要是对应用户手指按下跟屏幕接触的时刻。
2)DragStart
图 7 dragStart 触摸事件
如图 7 所示,这个是手势 Recongnize 认为用户这次的操作已经达到了 drag 的标准,此时用户本次手势的操作才真正被认为是一个合法的 drag 动作的开始。
3)DrageUpdate
图 8 dragUpdate 触摸事件
如图 8 所示,这个手势代表用户在 dragStart 后在屏幕上 move 的更新值。
4)DragEnd
图 9 dragEnd 触摸事件
如图 9,dragEnd 这个手势代表用户的手离开了屏幕,也就意味着这次手势操作的结束。
通过这几个方法,我们可以看到,手势的开始是通过“scrollPosition”生成了一个 drag 对象,然后接下来的 update,end 都是让这个对象进行处理,因此这个对象才是真正决定了当前的 scrollView 如何应对用户的操作,而进行相应的改变的处理类。接下来我们就重点来看这个类都做了什么。
2.3 ScrollPosition
图 10 scrollPosition 类图
图 10 给出了” scrollPosition”主要关系的一个类图,下面我们具体看一下它们各自的作用。
1)首先可以看到” scrollPosition”是继承于 ViewportOffset 和 ScrollMetrics 这两个类。其中 ScrollMetrics 主要描述了 scroll 基本的一些状态信息。比如当前 Srollable 可视区域的大小,最小、最大的滑动 offset 限制,以及当前的 offset。而 ViewportOffset 则提供了很多改变 offset 的方式,比如不带任何过渡交互效果就直接滑动到某个 offset 的“jumpto”方法,还有可以以带动画的方式滑动到某个 offset 的“animateto”。同时可以看到 ViewportOffset 的父类是一个 ChangeNotifier,也就是说” scrollPosition”改变是可以被观察的。因此可想而知 Srollable 的子 child 也就是真正我们要显示的内容 ViewPort 会以观察者的模式监听它的改变,从而做出相应的变化。
2)再来看下 ScrollPhysics 这个重要的类,它主要决定了滑动位置处于一些边界场景情况下,对于用户的滑动应该怎么去反馈。比如说对于 overScroll 的反馈即用户滑动的位置超过 scrollview 的最大或最小活动限制的边缘时,在 Android 和 iOS 这两个平台上的表现是不一样的。在 Android 平台上默认是不让用户 overscroll 的,就是不能滑动超过边缘,而在 iOS 平台上则允许。
又比如我们经常使用的 PageView(它的原理与 scrollView 类似)。它要求每次滑动都是整页滑动。即使用户在滑动手抬起时,页面当前的 offset 位置还处于两个页面的过渡期间,不是一个整页。这时候 PageView 对应的 ScrollPhysics 就会再给一个自动的矫正滑动,让我们的页面滑动到对应的整页。
ScrollPhysics 在 SDK 中已经提供了好几种实现。比如提供给 Android 平台的“ClampingScrollPhysics”,提供给 iOS 平台的默认的是“BouncingScrollPhysics”。
这些不同类型的 ScrollPhysics 是可以组合使用的,ScrollPhysics 本身的设计也考虑到了这点。在构造一个 ScrollPhysics 时,我们可以传入一个默认的 ScrollPhysics,也就是说新的 ScrollPhysics 默认就会组合传入的 ScrollPhysics 特性。
接下来具体看一下这个类可以用来控制特性的一些重要的方法。
“applyPhysicsToUserOffset”方法:当用户手势滑动超出 scrollable 最大或最小的滑动界限时,也就是我们常说的 overscroll 状态时,对用户手势做出一定的矫正。比如通过算法转换压缩用户的滑动距离,从而体现出一定的阻尼效果,让用户感知到已经滑到边缘了,没有可以滑动的内容了。
“shouldAcceptUserOffset”方法:它配置用户是否能够滑动 scrollable。比如说 NeverScrollableScrollPhysics 的这个方法永远返回的都是 false,那也就意味着 scrollable 不允许用户通过手势去滑动它。当然一般情况我们实际使用时都是返回 true,允许滑动。
“applyBoundaryConditions”方法:它主要也是为“overscroll”场景服务。它决定了用户的滑动位置能否 overscroll。这个方法的返回值是一个矫正值,比如 BouncingScrollPhysics 永远返回的都是 0,也就是说它允许用户进行 overscroll。而“ClampingScrollPhysics”在 overscroll 状态的返回的是一个非 0 的矫正值,会将新的 offset 矫正到 scrollable 的 boundary 里面来,避免出现 overscroll。因此如果我们想要实现一个一端可以 overscroll,另一端不允许的 scrollable,就可以通过重写这个方法加以实现。
“createBallisticSimulation”方法:它主要是返回一个变化的方程式。其大多数的应用场景主要是用来在用户的操作或者说滑动结束时有个反弹的效果。比如在 PageView 中当用户滑动结束手抬起时,页面的滑动位置不是一个整页的位置,这个方法就会返回一个方程式,然后我们就看到了一个按照这个方程式变化反弹动画,滑动到一个整页的位置。类似的 iOS 平台上默认的 BouncingScrollPhysics 在 overscroll 时,手松开时也会有一个反弹的动画,也是由这个方程决定。
“recommendDeferredLoading”方法:它主要是提供给 scrollable 自己的显示内容子控件使用。其目的是为了提高性能,比如当我们做了“Fling”这样的快速动作后,scrollable 接下来可能会滑动一个非常大的距离,而在这个距离中间的很多很耗资源的数据在这个过程不需要加载,因为用户基本也不会看到。特别典型的比如图片,因此在这个过程中这些耗资源的组件就可以通过这个方法判断是否需要延迟加载,以提高性能。
总的来说 ScrollPhysics 还是非常重要的,它承担用户在 scrollable 上滑动各种特殊场景的效果逻辑。
3)ScrollContext:它主要是充当一个媒介角色,其真正的实现就是 ScrollableState,目的主要是让 scrollPosition 可以去改变 ScrollableState 的一些能力。比如说在做某个滑动的过程中,scrollable 中的内容是否能接受点击,以及控制用户能否对 scrollable 进行滑动。
4)ScrollActivity:这个类主要负责封装当 scrollable 接受到用户的各种手势事件后做各种不同的流程。
比如当用户的手势被确认识别成 drag 动作后就会发起一个“DragScrollActivity”,负责此后用户手势在此基础上的新的滑动变化的处理,一直到用户手势抬起结束后怎么反应。还有比如像用户在滑动过程中突然有系统框弹出该怎么处理等这些针对具体场景的处理,都封装成了特定的流程,定义在这个类的某个具体实现子类里面,由其负责具体处理。像上文讲的用户手松开后的一个反弹效果,对应就是“BallisticScrollActivity”。
5)Controller:这个类是我们在使用 CustomScrollView 时经常会设置的一个参数,它顾名思义就是一个控制器可以让我们去控制 ScrollView,设置参数让它去滚动。之所以能够控制,是因为在内部绑定了前面讲的 scrollPosition,因此能让我们利用它去控制 CustomScrollView 滑动,以及监听 CustomScrollView 最新的状态。
小结一下,scrollPosition 主要负责用来实现对 ScrollView 的 offset 计算怎么改变,而 physics 是 scrollPosition 用来做怎么改变的重要的规则和限制,而最终 scrollPosition 又通过 Controller 与外界的 CustomScrollView 的使用者串联,让外界可以操控和获得 CustomScrollView 的滑动状态。
至此 CustomScrollView 第一个重要的部分滑动位置改变的控制,我们基本就分析完了,接下来看一下有了这个具体的滑动的 Offset,显示的内容怎么展示。
三、ViewPort
3.1 整体布局流程
图 11 RenderViewport 布局流程
接下来我们来看真正展示内容的 ViewPort 它的 RenderObject(RenderViewport)是怎么布局的。如图 11 所示,是其布局的整个流程概况。可以看到其主体的流程还是比较简单的,从第一个 child 不断的遍历到最后一个 child,从而完成整个 ViewPort 的布局。
里面有个特殊场景会抛出 Error 的异常,我们在布局每个 child 的过程中,会把当前 scrollview 的 offset 作为输入给当前正在布局的 child,而某些 chid 在做内部布局的时候,可能会认为 scrollview 给的 offset 会有问题需要矫正。比如说用来展示长列表的 SliverList 在做内部布局的时候,如果 SliverList 发现自己的 child 已经全部布局完了,但是 scrollview 给的 offset 还没有填满,这时候就会认为 scrollview 给的 offset 太长了,会给一个矫正值,让它缩短回去。
3.2 吸顶效果(Pinned)的实现原理
实际开发中用的比较多的一个效果是吸顶。在 Native 的开发中,一般这个效果是我们自己去实现的。但是 CustomScrollview 很强大,直接提供了这个功能。
对应的控件是 SliverPersistentHeader,并将其 pinned 属性设置为 true,就可以实现吸顶效果。
图 12 RenderSliverPinnedPersistentHeader 的布局代码
其对应的 renderObject 是 RenderSliverPinnedPersistentHeader,它的布局代码如图 12 所示。重点关注一下其返回给 renderViewPort 的 SliverGeometry 中的 paintOrigin,这个参数直接给的就是“constraints.overlap”。那么这个参数在 renderViewport 中具体代表什么意思哪。
图 13 RenderViewport 布局流程
再回头来看 renderViewport 的 layoutChildSequence 方法。前面说了这个方法会遍历自己所有的子 sliver 然后逐个布局,在这个过程中我们着重关注一下 maxPaintOffset 和 layoutOffset 这两个变量。在普通场景下这两个值都是从 0 开始,随着对 child list 的遍历而做相应的递增,也就是说默认的情况下这两个 offset 都是相等的。但是参考图 13 所示,黄色部分的某个 pinned sliver child 模块如果前面已经出现了红色区域的吸顶部分,那么此时对于黄色的这个 child 这两个值的位置就不是一致的了。如图中所示,可以看到此时对于它的 PaintOffset 是比 layoutOffset 大的,而它们之间的差值就是作为输入传给黄色 sliver 的 overlap。可以看到 RenderSliverPinnedPersistentHeader 在自己的布局方法中,在返回给 renderViewPort 的“SliverGeometry”返回值中的 paintOrigin 就是直接赋的这个值。
然后再回到 renderviewport 里,可以看到 renderviewport 在拿到 child 的这个参数会做如图 14 所示的一个修正流程。
图 14 renderViewport 修正 LayoutOffset
也就是说 render viewport 会用子 sliver 回传的 paintOrigin 矫正一下最后真正绘制的 offset,经过这个矫正后的 offset 正好是图 13 中所示的已经吸顶(红色)部分的底部。当用户再继续往上滑动时,本应该滑出可视区域的黄色 sliver,因为上面讲的处理,将一直绘制在屏幕上方,因此实现了吸顶效果。
图 15 日历部分阶段性吸顶效果
有了这个参数我们可以很多特殊的处理,比如酒店详情页的日历,交互要求其是阶段性吸顶。就是说虽然要吸顶,但不是一直都是吸顶的,当房型区域滑出屏幕时要随着最后一个房型的底部同步滑出,如图 15 所示。我们知道 customscrollview 默认没提供这样的实现,后来就是通过监听最后一个房型的滑动位置,然后去改变日历吸顶组件中“paintOrigin”参数的值,从而完成了此效果。
3.3 Tab 按钮和锚定
图 16 Tab 按钮和锚定效果
如图 16 所示的 tab 变化和锚定是我们经常会遇到的场景,这个时候需要准确地知道要锚定的模块所对应的 offset 值,而 Tab 的变换就是一个反向的过程,即当前 scrollview 的 offset 对应到了哪个具体的模块。说白了就是需要一个转化公式,给定一个指定的模块我们需要知道其对应的 offset 值。
很庆幸 scrollview 直接提供了对应的接口,如图 17 所示。
图 17 获取指定 child 展示在可视区域内 offset 的函数
前面我们分析过 renderViewport 会在每次布局时对其所有的子 sliver 进行布局,同时每个 child 会返回它们自己的布局结果。那么在返回结果里面跟这个方法紧密相关的两个变量是 scrollExtent 和 maxScrollObstructionExtent,其中“scrollExtent”代表了这个 child 自己拥有的滑动距离,而 maxScrollObstructionExtent 则主要是为吸顶的 sliver 所服务的, 它表示这个吸顶的 sliver 处于吸顶状态时所占的吸顶区域的高度。
当我们要获得某个具体的 sliver 滑动到屏幕可视区域最上方所需要的 offset 时,其实就是把该 sliver 前方所有的 sliver 的 scrollExtent 相加,同时减去该 sliver 前面所有吸顶的 sliver 的 maxScrollObstructionExtent,就可以获得相应的 offset 值。
3.4 长列表的懒加载机制和其子 renderObject 的复用机制
接下来我们再看一下非常重要同时大家都很关注的长列表的懒加载机制和内存复用的机制。我们还是用展示向下布局的长列表“SliverList”作为代表来介绍一下。
3.4.1 懒加载机制
图 19 SliverList 的布局流程
如图 19 所示是 SliverList 布局的主要流程,大体可以分为三个阶段。第一个阶段和第二阶段主要是定位,定位在当前 scrollView 对应的 scrollOffset 下在可视窗口内用户所能看到的第一个 child 是谁。那么第一个阶段是从上一次布局结果的 firstChild 按其 index 的逆序往前找,找到第一个自己的 scrollOffset 比 scrollView 的 scrollOffset 小的 child。在这个过程中找到的 child 是有可能在用户的可视范围内的,再往前的 child 用户肯定是看不见了。
第二个阶段是一个相反的过程,它会从第一个阶段找到的那个 child 往后找,找到第一个 child 的尾部是超过 scrollView 的当前的 scrollOffset。那么这个 child 就是接下来用户在当前所能看到的第一个 child 了,本次的布局也只需从这个 child 开始,index 在这个 child 之前的 children 相应肯定是看不到的,因此本次布局和渲染会忽略它们。在这之后会定义一个游标 trailingChild 指向 child。
接下来就进入了第三阶段,真正创建和布局本次渲染所要的所有 child。算法也很清晰,一直往下逐个遍历和布局接下来 child,直到某个 child 的末尾超过了本次布局一开始提前限定的范围。这个范围一般是 scrollView 可视范围的窗口高度再加上一个 cache 距离。至此整个布局就全部结束了。可以看到对于一个有很多数据的列表来说,在本次布局中,只有用户可视范围内的 child 会参与其中,不在的都会被忽略,从而实现了懒加载,大大提高了绘制性能。
除了 SliverList,sdk 中的 Grid,开源的瀑布流组件 StaggeredGrid 等长列表实现懒加载的机制也是类似,只是排列自己子 child 的布局方式不一样。
3.4.2 内存的复用管理
在以往 Native 的开发中,内存的复用是大家非常关心的问题,因为长列表可能会对内存造成非常大的压力,从而出现 OOM。我们在接触 flutter 的时候也很好奇,下面来看一下 SliverList 在这块的处理。
图 20 SliverList 单个 child 的创建或重用
图 21 SliverList 单个 child 的销毁或回收
sliverList 创建和回收每个 scrollview 的 child 的方法分别如图 20 和图 21 所示。从创建的代码可以看到,其首先会去一个 keepAliveBucker 的 Map 里面根据该 child 的 index 去寻找有没有对应的 child 缓存。如果有,会重用这个缓存里面的 child,如果没有,则会使用 childManager 去真正地创建一个 child 对象。在 destory 方法中主要是一个逆向的过程,会首先判断输入的 child 是不是要做缓存的,如果是则放入缓存池,如果不是则会真正将其对象销毁。
图 22 keepAlive 后 keepAliveBucker 中节点的数量
可以看到这里面是否会做缓存主要是由一个 keepAlive 的标志决定的。对于 sliverList 默认情况下所有的 child 是不开启 keepAlive 的,也就是说每次布局只要是被认为不需要的 child 都会被销毁。而如果我们需要让某个 child 变为 keepAlive 状态,只需要在这个 child 的 widget 外面用“AutomaticKeepAliveClientMixin”包装一下,就可以实现对它做缓存。图 22 所示是把每个 child 都设置成 keepAlive 的状态后的缓存截图,可以看到 keepAliveBucker 这个 Map 里面缓存了每个 index 对应的 child,数量达到了 200 多个 child。
总的来说,Flutter 在长列表的内存复用这块基本没采取特别的优化措施。如果我们打开 child 的 keepAlive,也只是一个对应到 index 的简单的重用,并没有像 Native 那样去设计比较复杂的复用机制。
从我们之前的应用来看,不用 keepAlive 对于像 List,Grid 这样的普通布局在使用时性能还好,但是如果是瀑布流的布局,在 Android 某些机型上如果不开启 keepAlive 对性能有一定影响,当然开启后对内存的消耗也相应会增大。对于这块需要思考如何做进一步的优化。
四、结语
至此,对于 CustomScrollView 这个 Flutter 中比较复杂的且应用广泛的组件的大体运行机制我们就分析完了。应该说在应用的方便性上,相对以往 Native 中的组件在功能上还是更强大的,它像一个粘合剂,让我们可以在它里面组合各种不同的布局子组件,以往在 Native 的开发中这些大都需要我们自己去定制。当然在数据量很大的情况下,对内存使用这块的设计相对以前 Native 还是比较简单的。
后续我们也会在应用继续深入的基础上,在功能上做进一步的丰富以及在性能上考虑如何做进一步的优化。
作者简介:
popeye,携程软件技术专家,关注移动端跨端技术,致力于快速,高性能地支撑业务开发。
本文转载自:携程技术中心(ID:ctriptech)
评论