原文地址在这里。
本文主要说了Flutter内部使用了怎样的算法和优化让Flutter如此强大。某些内容对比了Flutter和其他开发工具一致性算法的优劣,不过个人感觉还是太过简短,后面我会花更多的时间来研究这方面的内容,后续补上。最后还讲述了Flutter在API设计上是如何达到开发者的预期的。由于译者水平有限,疏漏之处还请见谅。
我没有全部翻译,因为我更加关心的是一致性算法的部分。在文章的最后一节中,主要讲述了API的人性化设计,知道固然好,了解的不深入也不会有什么损失。
本文描述了Flutter的内部工作原理。Flutter的widget是用激进组合的方式工作的,所以用户在构建UI的时候会用到很多的widget。为了支持这个工作量,Flutter使用了亚线性算法来处理布局、构建组件以及树数据结构。还包括了其他的一些常量及的优化。综合考虑其他的一些细节,这样的设计也会让开发者更加的容易的使用回调来创建无限滚动列表中对用户可见的部分。
激进组合
Flutter的特点之一就是激进组合(aggressive composability)。一般组件(widget)都是由其他的更加基本的组件逐步组合而成的。比如Padding
就是一个组件,而不是一个组件的某个属性。总之,用户的UI是有很多,很多的组件组成的。
组件会形成树状结构,最末尾的节点都是RenderObjectWidget
类型的,叶子节点的组件都会被用来创建绘制到屏幕上的节点。一颗绘制树就是一个保存了用户界面几何信息(大小,位置等)的数据结构。这些几何信息是在layout阶段计算出来的,并在绘制(painting)和碰撞检测(hit test)被用到。基本上Flutter的开发人员不会直接去创建绘制对象(render object),而是通过组件(widget)来操作绘制树。
为了在组件层支持激进组合,Flutter对组件层和绘制树层使用了很多的算法和优化。这些都会在随后的章节中介绍。
亚线性布局(Sublinear layout)
如何应对大量的组件和绘制对象(render object),如何获取好的性能?关键就是有效的算法!这其中最关键的就是layout算法,这个算法决定了绘制对象的几何信息,比如大小和位置。某些工具的布局算法的时间复杂度到了O(N²)甚至更差(比如,在某些约束下做固定点的迭代)。Flutter的目标是首次布局计算达到线性性能,在更新已存在布局的时候达到亚线性性能,尤其相对于大量增加的绘制对象,布局计算消耗的时间只会缓慢增加。
Flutter每一帧只执行一次布局计算,每次布局计算都是单步操作(single pass)。约束会通过父对象调用每个子对象的layout方法传递下去。子对象会递归地执行自己的layout方法并在返回的时候把几何信息向上返回。一旦一个绘制对象从它的layout方法返回了,那么这个绘制对象不会再被访问1,一直到下一帧(frame)的布局计算。这样的方式把本来会分成两步:测量(measure)和布局的计算集合成了一步(single pass)。这样,每个render object在计算布局的时候最多被访问两次2:一次是沿着树向下的访问,一次是沿着树向上的访问。
Flutter对这个协议(protocol)有多个实现(specialization)。最常见的具象就是RenderBox
,作用于一个二维笛卡尔坐标系。在这个box布局中,它的约束是一个最大、最小宽度和一个最大、最小高度。在布局的时候,child
就是通过在边界中选择一个值作为它的几何信息。当child从布局中返回,父对象知道了child在父对象坐标系3的位置。注意:子对象的布局和它的位置无关,因为直到子对象从layout返回才能直到它的位置。因此,父对象可以任意给子对象定位,而不需要再次计算布局。
总的来说,在布局期间,唯一从父对象流向子对象的就是约束(constraints)。唯一从子对象流向父对象的数据就是几何信息。这些不变量可以大幅度减少布局计算所需的工作量。
- 如果子对象没有把它自己的布局标记为脏(dirty),子对象可以立即从layout计算中返回。因为,父对象给子对象的约束和上一次布局的约束是一样的。
- 无论何时,一个父对象调用子对象的layout方法,父对象都要指出是否需要子对象返回的size数据。很多情况下都是不需要的,那么父对象就不需要重新计算布局。即使子对象选择了一个新的size,子对象也自己保证这个新的size适配于已经存在的约束。
- 紧约束只会被明确唯一而且有效的几何信息满足。比如,最小和最大宽度是相等的,最小和最大高度也是相等的,那么能满足这个约束的就只有一个宽度和一个高度值。如果父对象提供了紧约束,那么无论子对象layout的计算结果如何,父对象本身不需要重复计算自身的layout,即使父对象使用了子对象的layout返回的size,因为子对象不能在没有父对象提供的约束的基础上修改它自身的size。
- 一个绘制对象可以声明它仅仅使用父对象提供的约束来计算几何信息。这样的声明明确告知了框架就算子对象重新计算了layout,父对象也不需要计算。即使父对象的约束不是紧约束,即使父对象的layout依赖于子对象的大小。因为子对象不能在没有父对象的新约束的情况下修改大小。
这么多优化的结果是:当绘制对象树(render object tree)里包含了脏(dirt)节点的时候,只有这些脏节点和他们周围有限的子树需要在layout的被访问到。
亚线性组件构建(Sublinear widget building)
和layout算法类似,Flutter的组件构建算法也是亚线性的。在构建之后,所有组件都被element树持有,这个树也保持了UI的逻辑结构。Element树非常必要,因为组件本身是不可修改的(immutable)。也就是说如果有多个wiget的话,他们是不会记得他们之中的父子节点关系的。Element树也持有StatefuleWidget
的state对象。
在用户数据或者其他操作之后,一个element可以变为脏(dirty),比如一个开发者对state对象调用了setState()
。那么Flutter会保留一个“脏”element的列表,并在构建阶段直接跳过去而忽略掉其他干净(clean)的element。在构建阶段,数据单向的由上向下从element树流动,也就是说在构建阶段,element的节点只会被访问一次。一旦element变成干净的(clean)就不会变成脏的,因为它的祖先element都是干净的了4。
由于组件是不可修改(immutable)的,如果父对象使用同一个组件重新构建,而且组件对应的element没有把自己标记为脏,那么这个element可以在构建阶段立即返回。而且,element只需要比较两个widget引用的ID来确定两个widget是否为同一个。这个优化叫做二次投影模式,具体来说就是一个组件包含了一个构建前的子组件,在构建的时候把它保存为了一个成员变量。
在构建的时候,Flutter也会避免使用InheritedWidget
来访问父链。如果所有组件都访问父链,比如获取当前的主题颜色,那么根据树的深度,构建将变成O(N²)。这样的耗时就回非常之多。为了避免这样的情况发生,Flutter在每个element上都有一个InheritedWidget
的哈希表。很多的element只会引用同一个哈希表,只有element引用了新的InheritedWidget
才会发生改变。
线性一致(Linear reconciliation)
与普遍认为的不同,Flutter不会进树级别的找不同。而且使用了一个O(N)算法:独立检测每个element的子element列表来决定是否要重用这个element。子列表一致性算法的优化分为以下几种情况:
- 就得子列表是空
- 两个列表是同一的
- 在列表里的明确的一个地方有插入或者删除一个或者多个组件
- 如果每个列表里包含了有同一个Key的组件,那么两个组件是被认为是匹配的
通常的方法就是从头到尾的对比两个列表里的每个组件的运行时类型(runtime type)和key,极有可能会在每个列表里发现一段包含了所有不匹配的组件,以及他们的范围(range) 。Flutter会把旧的列表里的组件根据他们的key,放进一个哈希表里。接下来,Flutter遍历新的列表的范围(range),并从哈希表中查找匹配的key。不匹配的会被抛弃,匹配的则使用新的组件重新构建。
树的分解(Tree surgery)
重用element对于性能来说非常之重要,因为element持有两种很重要的数据:状态组件的状态和底层的绘制对象。当Flutter可以重用一个element的时候,UI某个逻辑部分的状态得以保留,并且之前计算出来的layout数据也可以重用,基本可以避免整个子树的遍历。事实上,Flutter支持保留了状态和布局的非本地(non-local)树修改。
开发者可以通过使一个widget和一个GlobalKey
关联的方式来执行非本地树修改。每一个全局键(global key)在整个app里都是唯一的,并且注册在了一个线程相关的哈希表里。在构建阶段,开发者可以把一个有全局键的组件移动到element树的任意位置。而不是在那个位置上再建一个全新的组件。Flutter会检查哈希表,然后把组件挂在新的父组件下,并保留整个子树。
在子树里的绘制对象可以保留他们的布局信息,因为布局的约束是唯一从树的父对象流向子对象的数据。新的父对象会被标记为脏(dirty)因为它的子对象列表已经发生了改变。但是如果新的父对象传过来的layout数据和旧的parent传过来的是一样的,那么这个子对象会立刻返回,停止遍历。
全局键和非本地树修改广泛用于英雄转化(hero transition)和导航(navigation)。
常量因素优化
在这些算法优化之外,要达到及机组和还需要几个常量因素优化。这些优化对于上面提到的算法也至关重要。
-
子模型无关:和其他的使用子列表的工具不同,Flutter的绘制树不会依赖于特定子模式。比如,
RenderBox
类有一个抽象的visitChildren()
方法而不是实际的firstChild和nextSibling接口。许多子类都支持一个单一的child,直接做为一个类成员变量,而不是一列子节点。比如,RenderPadding
支持一个唯一的child。这样只会有一个耗时更短的,更简单的layout方法会被执行。 - 可视绘制树,逻辑组件树:在Flutter里,绘制树的操作是设备无关(device-indepent)的视觉坐标系统上进行的。也就是说x轴上更小的值在更左边,即使当前阅读方向是从右到左的。组件树是基于逻辑坐标系的,也就是说开始结束值的解析是依赖于阅读方向的。从逻辑坐标系到视觉坐标系的转化是组件树到绘制树之间手递手完成的。这样的方式会更加的高效,因为在绘制树中布局和绘制(painting)的计算比组件到绘制树的转化更为频繁,可以避免重复的坐标系转化。
-
文本有特殊的绘制对象处理:大量的绘制对象是不会处理复杂的文本的,文本只会被专门的绘制对象处理,
RenderParagraph
。这是一个绘制树的叶子节点。文本的处理不需要继承的方式,而是用组合的方式。如此一来就可以避免RenderParagraph
重新计算它所持有的文本在父节点传递了同样的约束的条件下再次计算布局数据。着很常见,即使是在树分解中也一样。 - 可观察对象:Flutter使用观察者模型和响应式模型。显而易见,响应式已经是主流,但是Flutter在叶子节点的数据结构上使用了观察者模型。比如,动画的值发生改变的时候会通知一个观察者列表。Flutter把这些观察者对象从组件树传递到了绘制树,回执书可以直接观察这些对象并在他们发生改变的时候,在绘制管道的某个合适的实际把他们置为无效。比如,对一个动画值的修改只会出发绘制(painting)而不是构建和绘制两个都出发。
鉴于通常都是硕大的组件数来说,这样的优化带来的性能提升非常显著。
Element树和绘制树的拆分
绘制对象树(绘制树)和Element树是同构的(严格的说,绘制树是element树的一个子集)。一个明显的简化是把这两个数组合成一个。然而,在实践中把这两个数分开有很多的益处。
- 性能:当布局发生更改,只有布局树中相关的部分需要遍历。由于激进组合的原因,element树里总是会有很多需要跳过的节点。
- 明确:对关注点的分离允许组件和绘制对象各自聚焦在各自的焦点上。这样极大的简化了API,也极大的降低了风险和测试的负担。
- 类型安全:绘制树可以保证在运行时它节点的类型都是合适的,如此绘制树可以保证类型的安全。组合组件不关心布局的坐标系,因此在element树里,检查绘制对像的类型会导致一次树遍历。
无限滚动
无限滚动列表的实现对于各种工具来说都是非常之难。Flutter在构建的时候提供了一个非常简单的接口来实现这个功能。一个列表,当用户滚动的时候,使用了一个回调来实现可视的时候显示一个组件。支持这个功能需要用到viewport-aware布局和按需构建组件。
Viewport感知布局
和Flutter多数的东西一样,可滚动组件也是用组合的方式组成的。在可滚动组件的外面是一个Viewport
,的子组件它可以扩展到可视窗口外面的部分,还可以滚动到视图内。然而,一个viewport有一个RenderSilver
类型的子组件,而不是RenderBox
类型的子组件。RenderSilver
类型有一个视图感知接口。
这个silver布局协议和盒式布局的协议结构上是一致的,也会给子节点传递约束并返回几何信息。然而,约束和几何信息在两者之间却不同。在silver协议里,子节点收到的是viewport数据,包括剩余的可视空间。他们返回的几何数据让很多种和滚动相关的效果成为可能,包括可折叠的header和视差效果。
不同的silver填充viewport剩余空间的方式是不同的。比如,一个silver可以生成一列子组件,一个挨着一个排列,直到这个silver显示了全部的子组件或者用光了所有的空间。类似地,一个silver生成一个二维的grid,子组件只填满这个grid可视的部分。因为他们可以感知到剩余的空间还有多少。silver还可以生成有限的子组件,虽然他们也可以生成无限的子组件。
silver可以通过组合生成不同的可滚动布局和效果。比如,一个单独的viewport可以有一个可折叠的heaer,下面跟着一个线性列表和一个grid。所有的三个silver都会根据silver协议来互动,从而生成在viewport里可视的子组件,不管他们是属于header,list还是grid的。
按需创建组件
如果Flutter有一个严格的先构建再布局再绘制的管道(pipeline),前述内容在构建无限滚动列表的时候就非常的低效了,因为viewport里剩余多少空间可以使用的数据只有在layout的阶段才可以知道。不采用其他手法的前提下,布局阶段对于构建填充剩余空间的组件来说太迟了。Flutter把管道中的构建和布局两个阶段互相交叉,解决了这个问题。在布局阶段的任何时刻,Flutter都可以按需构建新的组件,只要这些组件是当前执行布局的绘制对象的子组件。
交叉构建和布局可以实现,完全是因为构建和布局算法中严格的数据传递控制。尤其是,在构建阶段,数据只可以向下传递。当一个绘制对象在计算布局的时候,布局遍历还没有访问到这个绘制对戏的子树,也就是说子树生成的写入还不能改写当前布局的计算结果。类似的,一旦布局从一个绘制对象返回了,在这次布局计算中这个绘制对象讲不会再被访问到,也就是说任何后一步布局计算生成的写入都不能影响当前绘制对象用于构建子树的数据。
另外,线性一致性和树分解对于滚动中高效的更新element都非常的有必要。当element滚动进或出可视区域的时候,对修改绘制树来说也同样的重要。
API开发者友好设计
只有在框架可以被正确使用的时候,快才有意义。为了达到API友好的效果,Flutter和开发者进行了广泛的体验研究。这些有时肯定了之前的某些决定,有时会帮助确定某些功能的优先级,有时又改变了API设计的方向。比如,Flutter的API都有丰富的文档。UX研究肯定了这些文档的价值,但是也明确了示例代码和图标的作用。
这一节讨论Flutter的API的设计以备急用。
特定的API符合开发者特定的理解
Flutter的Widget
, Element
和RenderObject
树节点的基类没有子模型(child model)。这也让某个节点可以成为它要适用的某个节点的子模型。
多数的组件对象都只有一个子组件,因此只暴露了一个child
参数。某些组件支持很多的子组件,所以暴露了一个叫做children
的列表参数。某些组件没有任何的子组件,所以也不会暴露任何的参数。类似的,RenderObjects
暴露特定的子模型API。RenderImage
是一个叶子节点,没有子对象的概念。RenderPadding
接受一个单一的child,所以它只有一个单独的引用指向一个child。RenderFlex
接受未知数量的child并使用一个链表管理他们。
在某些特殊的情况下,需要更复杂的子模型。RenderTable
绘制对象接收的是一个二维数组,这个类对应的getter和setter来控制行和列的数量,还有一定数量的方法可以替换某个x,y下标的child。
Chip
组件和InputDecoration
对象的属性也和相关控件相符合。一刀切的子模型会强制语义至于子模型分层的顶端,比如,定义第一个child为前缀值,第二个child为后缀值,那么这个特定的子模型(child model)可以被用于特定的命名属性。
这种灵活性让这些树的每个节点按照它的角色来操作。很少会把一个cell插入到一个table里,因为所有其他的cell都会变形,移位。类似地,也很少会使用下标而不是引用从一个flex行删除某个元素。
RenderParagraph
对象是组极端的例子:它有一个完全不同的child,TextSpan
。在RenderParagraph
范围内,RenderObject
树会变成一个TextSpan
树。
总体上,让API的设计符合开发者预期,不只是子模型上的努力。
某些简单的组件的存在就是为了让开发者在解决的某个问题的时候可以找他他们。给一个行或列添加空间,一旦你知道方法就回变得非常简单:使用Expanded
组件和一个零大小的SiezedBox
子组件,不过其实这还不是最好的方法。如果你搜索space的话你会找到Spacer
组件,这个组件内部就使用了Expanded
和SizedBox
。
类似的还有隐藏一个组件的子树也很容易,只要不要在构建的时候包含这个组件的子树。开发者希望有一个组件可以达到这个效果,那么就有了Visibility
组件。
显示参数
UI框架都会有很多的参数,一般来说开发者很少会记得构造函数里的每个参数的语义。Flutter使用响应模式,所以在构建的时候会用到很多的构造函数。有了Dart语言的命名参数,Flutter的API就可以保证每个build方法都清晰,容易理解。
这个模式可以扩展到每个用了很多参数的方法上。尤其是每个bool类型的参数,所以方法里的每个true
和false
都是自带文档属性的。
避免掉坑
一个在Flutter里普遍使用的技术是定义一种错误条件不存在的API。这样避免了对于错误的过多关注。
比如,一个插值方法允许一端或者插值的两端都为null,Flutter没有把它定义为错误。而是:插值的两端都为null则返回null,如果有一端为null,那么就相当于是给某个特定类型的0值插入值。也就是说,开发者如果意外给插值方法传入了null值,那么不会发生错误,而是会输出一个合理的值。
在Flex
布局算法里有一个更加细微的例子。这个布局的概念是flex的绘制对象的空间会有它的一个或者多个子组件分割,所以flex布局的大小应该是占满可用空间。在最初的设计中,提供一个无线的空间会出错:这样隐式的表明flex布局是无限大小,一个没有用的布局。然而,API做了修改,这样当一个无限大小的空间赋值给flex绘制对象的时候,它会变成这些子组件需要的大小,减少了可能的错误情况。
积极报告错误情况
不是所有的错误都可以通过设计避免的。对于那些在debug的时候依旧存在的问题,Flutter通常都会今早的捕获异常,并且及时报告。断言(assert)的使用非常普遍。构造函数的参数也都检查到了细节。生命周期也都有监控,一旦发生错误就会抛出异常。
在某些情况下,这些发挥到了极致:比如,当运行单元测试的时候,不管测试的是什么,每个RenderBox
子类都会检查固有size方法是否满足固有size的契约。这可以帮助发现那些API中不容易暴露的问题。
抛出的异常也包含了竟可能丰富的信息。
响应式模型
基于可变树的API都要经历一种混乱:创建树的最初状态的操作集合和后续的更新的操作结合存在很大的不同。Flutter的绘制层使用了这个模型,这是一种维护一个持久树的有效方法,也是使布局和绘制高效的关键所在。然而,直接操作绘制层会显得非常奇怪,更糟糕的是还可能引入bug。
Flutter的组件层使用了响应式的模式来组合组件,并以此来操作底层的绘制树。这一API把树的创建和修改多个步骤合成为一个树的描述(build)步骤,每当APP的状态(state)发生了改变,那么UI就会生出新的配置,这个配置是由开发者控制的。之后Flutter对树的修改做必要的计算来反映出新的修改。
插值
Flutter鼓励开发者根据当前APP状态的,作出相应的配置。也就是说,App状态变了,那么对应的组件也发生了变化。这个时候需要一种机制可以保证这些变化是有动画效果来过渡的。
比如,在状态1的时候有S1,界面上包含了一个圆圈。但是在下一个状态S2,它变成了一个方框。没有任何动画机制的话,这个显得很突兀。一个隐式的动画会让圆圈经过几帧之后再变成方框,体验会更好。
总结
Flutter的口号是:“everything is a widget”,也就是使用基本的组件来构建复杂的组件。激进组合的结果会导致开发者使用大量的组件,这就需要仔细地设计算法和数据结构,这样才能高效的处理组件。再加上另外的设计,这些数据结构也让开发者可以很容易的创建无限滚动列表组件。
注
1 对于布局计算是这样的。不过他还是会在绘制,建立无障碍树(如果需要的话),以及碰撞测试的时候被再次访问。
2 在实际运行中会更加复杂。某些布局需要固有维度或者baseline的测量,这样会导致对相关子树的额外遍历(激进缓存就是为了应对这种极差情况而准备的)。这种情况非常罕见。尤其shrink-wrapping并不是一定需要固有维度。
3 技术上来说,child的位置不在RenderBox
几何信息里,所以也不需要布局的时候计算。大多数情况下绘制对象都把他们的child定位在相对于它的origin的(0,0)上,这样也不需要计算或者存储任何的东西。有些绘制对象不到最后专门计算它的子节点位置的时候都尽量避免对位置的计算,如果这些子组件最后不用绘制的话也就不会有计算了。
4 这条规则存在一个例外。在按需构建组件一节关于这一点的讨论,某些组件会因为布局约束的改变而重新构建。如果一个组件在同一帧中因为无关的原因把它自己标记为脏,那么它也会布局约束的更改影响,它会更新两次。这个构建只会发生在组件本身而不会影响到它的子组件。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。