干货 | Flutter控件CustomScrollView原理解析及应用实践

2021-07-22 15:40:22 浏览数 (1)

作者简介

popeye,携程软件技术专家,关注移动端跨端技术,致力于快速,高性能地支撑业务开发。

说起移动端跨平台解决方案,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还是比较简单的。

后续我们也会在应用继续深入的基础上,在功能上做进一步的丰富以及在性能上考虑如何做进一步的优化。

0 人点赞