万字长文 Flutter 运行机制白皮书

1,973 阅读27分钟

这是我参与2022首次更文挑战的第2天,活动详情查看:2022首次更文挑战

本篇文章翻译自官方的👉Inside Flutter,这篇文章介绍了 Flutter 内部运行机制,从组合优先的设计、到节点树高效运行的原理、再到滑动的处理,看完这篇文档的翻译,大家就知道为啥 everything is a widget 的 Flutter 可以高效运行了。

以下是正文


本文档介绍了 Flutter 内部的工作机制,正是有这些机制的存在,Fluttter 才可以高效的运行。 Flutter Widget 的构建方式是积极组合,因此,我们经常在 UI 中看到大量的 Widget。 为了让 积极组合——aggressive composition 这种方式可以有效的实践,Flutter 在布局和构建中使用了次线性算法,并有与之相配合的数据结构,出了这两点之外,还有一些常量因子的优化,这些都让树的操作更加高效。再加上一些额外的细节,开发者可以使用回调很简单的创建一个无线滚动的列表,并且回调能够很准确的构建需要显示的 Widget。

aggressive composition——积极组合

Flutter 最与众不同的一个方面就是它的 aggressive composition——积极组合。Widget 使用组合其他 Widget 的方式进行构建,而且这些其他的 Widget 也是由更加基础的 Widget 构建。比如说,Padding 是一个 Widget,而不是其他 Widget 的属性。所以,Flutter 的 UI 是许许多多的 Widget,远远超过开发者代码中明确开发的 Widget。

Widget 会递归的构建触底到 RenderObjectWidgetsRenderObjectWidgets 也是 Widget,这种 Widget 会在 render 树上构建节点。render 树是一种数据结构,存储着 UI 的几何信息,这些几何信息是在 layout 阶段计算完成,并且会在 painting 和 hit testing 阶段被使用。一般情况下,Flutter 开发者不需要直接写开发 render 对象,使用与之匹配的 Widget 就可以了。

为了支持这种 Widget 层组合的方式,Flutter 在三棵树上使用了一些高效的算法和机制,下面我们就依次介绍。

次线性布局

由于存在大量的 Widget 和 Render 对象,高性能的关键就是高效算法。性能中最重要的是布局,我们知道布局决定了几何信息,比如尺寸、位置等等。一些其他的 UI 框架的布局算法是 O(N²)的,甚至更差。Flutter 的目标是:初始化布局的时候是线性开销,而在之后的更新的时候是次线性的开销。大多数情况下,都是对已经存在的布局,进行更新,毕竟初始化只有一次。通常情况下,花在布局上的时间应该比花在渲染渲染上的时间更少。

Flutter 在每一帧都会执行布局,并且布局算法的入参仅仅只有一个 —— ConstraintsConstraints 是约束的意思,会从父节点传递给子节点,如果有多个子节点,那么就传递给每一个子节点。子节点也会递归的执行自己的布局,并且沿着树向上👆返回 geometry。重要的是,只要一个渲染对象,已经从它的布局方法返回了,那么在下一帧之前,渲染对象都不会被再次 visited。这样布局方法就完成了两件事:第一,接受从父节点传进来的约束信息,第二,布局完自身,并返回给父节点自己的集合信息。这样每一个渲染对象在布局期间最多被访问两次:一次是向下传递约束的时候,一次是向上传递几何信息的时候。

上面讲的通用的布局协议中,Flutter 也有一些特性。最常用的特性是 RenderBox,这个类定义了二维的笛卡尔坐标系。在布局中,约束由四个值定义:最大最小宽度、最大最小高度。在布局期间,子节点需要在父节点给定的约束内,确定自己的几何信息。在子节点布局完成后,也就是 layout 方法返回了,父节点会在字节的坐标体系内,决定子节点的位置。注意一点,子节点的布局不与依赖它的位置,因为位置是在布局之后决定的。因此,父节点可以在不重新布局子节点的情况下,自由的多次摆放子节点。

一般来说:在布局期间,从父节点流向子节点的唯一信息是约束,从子节点流向父节点的唯一信息是几何信息。这样的规则和数据的唯一性减少了布局的工作量:

  • 如果子节点没有把自身的布局标记为 dirty,子节点就可以立即从布局方法中返回。并且只要本次父节点给的约束和上一次布局是一样的,也会立即返回。

  • 不管什么时候父节点调用了子节点的布局方法,父节点都需要表明它是否使用子节点返回的几何信息。假如父节不使用子节点的尺寸信息,那么即使子节点的尺寸信息发生了改变,父节点都不会重新计算布局,因为父节点已经保证了子节点尺寸在约束之内。

  • Tight 约束是指几何信息是固定的唯一的。比如,宽度的最小值和最大值相等,高度的最小值和最大值相等。那么尺寸就是唯一的宽*高。 如果父节点给子节点的约束是 Tight 约束,子节点重新布局的时候,父节点也不需要再次计算布局信息,因为子节点不会改变大小。

  • 一个渲染对象可以仅仅根据父节点的约束来确定自己的几何信息。这样的设置就相当于告诉 framework:渲染对象重新布局的时候,其父节点不需要重新布局,即使父节点传递的约束不是 tight 的,即使父布局的依赖子节点的大小。因为这种情形下,子节点的大小只会因为父布局的约束改变而改变。

正是因为这些优化,当 render 树的某些节点标记为 dirty 时,也仅仅需要布局这些 dirty 节点以及其有限的子节点。

Sublinear widget building

和布局算法相似,Flutter 的 Widget 构建算法也是次线性的。在构建之后,Widget 节点会被 Element 树持有,这些 Widget 就保留着 UI 的逻辑结构。Element 树是非常有必要的,因为 Widget 本身是不变的,它的不变性就意味着,它们不会保留着和其他节点的父子关系。并且 Element 树还持有 StatefulWidget 的 state 对象。

为了响应用户的输入或者其他的事件,一个 Element可能会变成 dirty 的,比如说,开发者调用了 setState() 方法。framework 保留了一个 dirty Element 的列表,并且 会在 build 阶段跳过干净的 Element,直接跳到 dirty 列表中进行构建。在 build 阶段,Element 树的信息流动是单向的,这就意味着每一个 Element 在 build 期间最多会被访问到一次。 在build期间,一个 Element 只要是干净的,就不会被再次标记为 dirty,因为它的祖先节点都是干净的。

因为 Widget 本身是 immutable 的,所以如果 Element 没有被标记为 dirty,那么当父 Element 节点用身份认证相同的 Widget 重新构建子 Element 时,Element 可以立即从 build 中返回。除此之外,Element 仅仅比较新旧 Widget 的 identity,就可以确定两个 Widget 是否相同。开发者利用这种机制来实现 reprojection 模式,一个 Widget 包裹一个预加载完成的成员变量 Widget,并且将成员变量作为 build 方法的一部分。

在 build 期间,Flutter 尽量避免直接遍历父节点的链,一般使用 InheritedWidgets。如果 Widget 直接遍历父节点的链,这种方式的 build 复杂度将是 O(N²) 的,N是树的深度。为了避免这样的父节点的链遍历,framework 把信息沿着 element 树层层下放,信息就是 Element 中持有的一个InheritedWidget 的 hash 表。一般情况下,Element 引用的表是相同的,如果一个 Widget 是 InheritedWidget,那么它背后的 Element 的表的元素就会比它父节点的表多一个元素(它本身的InheritedWidget)。

线性一致

Flutter 决定是否复用 element 的方式与主流的方案不同,主流方案是使用 tree-diffing 算法,而 Flutter 独立的检查每一个子节点是否复用,这样的话算法复杂度是 O(N) 的。子节点线性一致性算法对下面的情况进行了优化:

  • 旧子节点列表是空的
  • 两个列表是相同的
  • 在列表确定的位置插入和删除 Widget
  • 新旧列表包含一个相同 Key、相同类型的 Widget

一般的比较方法是:从开始到结束比较新旧两个列表的 Widget 的类型和 Key,可能会在每个列表的中间找到一个包含所有不匹配的子列表的非空范围。然后,框架会根据 Element 的 Key,将旧的子节点放置在 hash 表中。之后,框架会顺序的生成新的 Element 节点,生成之前会先从前面的 hash 表中查找相同的 key 的 Element,如果没找到就用新的 Widget 生成新的 Element,并且执行后面的 生命周期流程,如果找到了就直接复用。

树分离

因为 Element 中持有两个关键的信息:StatefulWidget 的 state 和 背后的 render 对象,所以 Element 复用机制对整个性能提升都非常重要。因为 Element 可以复用,所以 state 中 UI 的逻辑信息和计算好的布局信息都可以被复用,这样就避免了整颗子树的重新遍历渲染。事实上,复用机制非常有价值,基于此 Flutter 支持了 non-local 树的变化。

开发者可以使用 GlobalKey 机制来实现 non-local 树的变化,Widget 可以设置 Key 属性,有一种 Key 是 GlobalKey。每一个 GlobalKey 在应用内都是唯一的,并且注册在了指定线程的 hash 表中。在布局期间,开发者可以将 GlobalKey 的 Widget 整棵子树放到 Element 树中的任意位置。framework 会检查 hash 表,将新位置的上一级作为自己的新的父节点,保留整棵子树,而不是重新创建。

同样,Element 重新指定父节点的情形,也会影响 render 对象,这种情况下 render 对象会保留它们的布局信息,因为从父节点流向子节点的布局信息没有变化。由于新的父节点的子节点替换了,所以新的父节点会被标记为 dirty,但是如果新的父节点和旧的父节点的约束信息一致,那么子节点的布局过程会立即执行完毕,并返回几何信息。

开发者经常使用 GlobalKey 和 non-local 树变化机制来实现效果,比如 hero 切换、navigation 路由。

Constant-factor 优化

在这些算法上的优化之外,组合优先机制的实现也依赖一些 constant-factor 优化。在主要算法的基础上,这些优化是最重要的。

  • Child-model agnostic.  Unlike most toolkits, which use child lists, Flutter’s render tree does not commit to a specific child model. For example, the RenderBox class has an abstract visitChildren() method rather than a concrete firstChild and nextSibling interface. Many subclasses support only a single child, held directly as a member variable, rather than a list of children. For example, RenderPadding supports only a single child and, as a result, has a simpler layout method that takes less time to execute.

  • Child-model 无关  大多数的 UI 框架指定子节点是一个数组,而 Flutter 的渲染树没有指定 child model 具体是什么。比如,RenderBox 的抽象方法是 visitChildren() ,而不是相对具化的 firstChild 和 nextSibling。很多子类仅仅只有一个子节点,并且成员变量直接持有子节点。比如,RenderPadding 仅仅有一个子节点,这样 layout 方法处理的就更少了,花费的时间也更少。

  • 可视的 render 树, 逻辑的 widget 树  在 Flutter 中,渲染树是设备无关的,并且在视觉坐标体系内。这就是说,即使当前的阅读方向是从右往左,render 的 X 轴也是越往左越小。Widget 树是逻辑的坐标系统,这就是说 start 和 end 的值取决于阅读方法的解释。视觉坐标体系和逻辑坐标体系的转换是在 Widget 树和 Render 树之间完成的。渲染树布局和绘制的计算比 widget-到-render 树的切换更频繁,这样就避免了重复的坐标转换,所以这种方法非常高效。

  • 特定的渲染对象处理文本  大多数渲染对象是对文本的处理无感知的,文本是由特定的 RenderParagraph 处理的,它是一个渲染树的叶子节点。开发者使用组合的方式将此节点组合到自己的 UI 中,而不是子类泛化 RenderParagraph 的方式。这种方式的好处就是只要父节点的约束一样,那么 RenderParagraph 就可以避免重复计算。

  • 可观察对象  Flutter 使用观察者模式和响应式编程两种方式。虽然响应式是主导,但是 Flutter 对一些叶子节点使用观察者模式。比如,当动画的值改变时,Animation 会通知所有的监听者。Flutter 将这些可观者的对象从 Widget 树传递到渲染树,当数值变化的时候,渲染树上的对象会直接响应变化,无视渲染管线。比如,颜色动画 Animation<Color> 的改变仅仅改变了绘制阶段,而不是构建+绘制阶段。

所有这些相互配合让优先组合的机制更好落地,更加高效。

Element 树和 RenderObject 树分离

RenderObjectElement 树是同形的,更准确的说,RenderObject 树是 Element 树 的子集。一个很明显的简化是把三棵树结合到一起,但是,实际上把三棵树分开更有益:

  • 性能  当布局改变的时候,仅仅需要遍历需要改变的部分。由于组合,Element 树上有很多额外的节点,这些节点需要跳过。
  • 解耦  Widget 协议按着自己的需要运行,Render 协议按着自己的需要制定,更加指责单一,减少 bug 风险和单元测试。
  • 类型安全  因为在运行时 Render 对象能够保证子节点是指定的类型,所以渲染对象更加的类型安全。Widget 可以不知道布局时的坐标系统,比如,相同的 Widget 既可以在盒子布局中,也可以用在 sliver 布局中。并且,在 Element树中可以沿着树找到指定类型的渲染节点。

无线滚动

大家都知道,无线滚动列表对任何框架都是非常复杂且重要的需求。Flutter 基于  builder 模式,使用简单明了的 API 就可以完成这个需求。用户滚动的时候,ListView 使用 builder 回调就可以让 Widget 显示到窗口。它们的背后是 viewport-aware 布局 和 按需加载

Viewport-aware 布局

像大多数 Flutter 情形一样,滚动 Widget 也是使用组合构建的。滚动 Widget 的外部是 Viewport 组件,这个组件的作用是 可以让内部组件的尺寸大于外部。这就是说,虽然内部非常大,但是可以滚动到视图内部。Viewport 的子节点们不是 RenderBox,而是特定的 RenderSliverRenderSliver 有自己的布局协议。

Sliver 布局协议从结构上和 Box 布局协议相同,从上向下传输约束,从下向上传输集合信息。但是,两者的输入和输出是不同的。Sliver 布局协议中的约束包含了:可视空间的剩余大小等信息。几何信息 实现了相关的滚动效果,包含了折叠的头部、视差等。

不同的 Sliver 使用不同的方式填充剩余可视空间。比如,子节点是线性列表的 Sliver 按顺序摆放子节点,直到 子节点摆放完毕或者空间占满。相似的,子节点是二维表格的 Sliver 只填充表格可视的部分。因为 Sliver 知道剩余空间是多少,所以设置的是无限数量的子节点,也可以出构造合适数量的子节点。

Sliver 可以组合在一起,来实现定制的滚动布局和效果。比如单一的 Viewport 可以实现折叠头,加上线性列表和表格列表的效果。通过布局协议,Sliver 可以相互协调,从而只构造出真正需要显示在屏幕上的子节点,避免了子节点到底是属于头部呢,列表呢还是网格呢这样的复杂判断。

按需构建 Widget

假如 Flutter 是严格的 build-then-layout-then-paint 管线,那么前面提到的无限列表实现起来就很麻烦很费劲。因为可视空间的信息仅仅在布局阶段是可用的。 如果没有其他的机制,布局阶段太晚了,导致不能构建填充空间的 Widget。Flutter 使用交错 build 和 layout 阶段的方式来解决这个问题。在绘制阶段的任意地方,framework都能够按需开启新 Widget 的构建,只要新 Widget 是当前正在渲染的 render 对象的子孙节点

而 build 和 layout 阶段的交错能够实现的原因,也是 build 和 layout 算法对信息的严格控制。尤其是,在 build 阶段,信息仅仅可以向下传播。当一个 render 正在执行布局,布局还没遍历到某个子节点,那么该子节点就不会受到影响。同样的,只要布局已经完成了,渲染对象就不会再次被访问到,这样意味着写操作不会影响用于布局字节点的约束信息。

除此之外,对于滚动过程中有效更新 Element,以及元素在 viewport 边缘滚入和滚出有效更新渲染树来说,线性协调和树分解是十分关键的。

此外,线性协调和树操作对于在滚动过程中有效地更新元素和在视口边缘滚动元素进入或退出视图时修改渲染树是必不可少的。

API 中的人体工程学

只有框架能够真正的高效使用,效率才重要。为了让 Flutter API 设计的更加易用,从广泛的 UX(用户体验)的角度,Flutter 已经与开发者反复测试。这些工作有时检验了之前已经设计决策,有时会帮助明确特性的优先级,有时会改变 API 设计的方向。比如,Flutter API 有很多很多的文档。UX 肯定了文档的价值,也强调了示例代码和算法说明的重要性。

本节主要讨论一些让 API 更加易用的点。

与开发者心连心的 API

Flutter 节点的基类是:WidgetElement, 和  RenderObject。这些是三棵树的节点,但是并没有定义具体的孩子模型。这就让每个节点可以定义适合自己的孩子模型。

大多数的 Widget 对象仅仅有一个孩子节点 Widget,并且因此仅仅暴漏了 child 参数。一些 Widget 需要多个孩子节点,因此暴漏了数组类型的 children 参数。一些 Widget 也没有任何子节点,也不保留内存,那么就不设置参数了。相似的,RenderObjects 也暴漏了特定的 API 为子节点。RenderImage 是叶子节点,并且没有子节点的概念。RenderPadding 需要一个子节点,所以它有 child 的成员变量。RenderFlex 需要管理一个任意数量的孩子数组,它暴漏了数组参数。

在特殊一些例子中,需要更加复杂的孩子模型。RenderTable 以表格的形式渲染孩子,所以它的构造方法需要一个数组的数组,这类也暴漏了 getters 和 setters 方法来控制行和列,并且有指定的方法来替换 x 和 y 定位的单个元素,添加一行,添加一组新的孩子,使用一个数组和列数来替换整个孩子。从实现来看,渲染对象没有使用链表数组,而是使用的 index 数组。大多数渲染对象使用的是链表数组。

Chip 组件和 InputDecoration 对象有一些和槽点相匹配的字段。槽点存在组件上。一个放之四海而皆准的孩子尺寸模型是强制 semantics 是孩子数组的第一个元素。比如,定义第一个孩子是 prefix 值,第二个是 suffix,特定的孩子模型允许使用特定的命名属性。

因为 Flutter 的灵活性三棵树上的节点可以用适合自己的行为进行操作。我们一般不会想要在表格中插入一个元素,导致其他的元素都被包裹。同样地,我们一般都是通过引用删除 Flex 的子节点,而不是通过索引的方式删除子节点。

RenderParagraph 是最极端的 case:它的字节点是完全不同的类型 —— TextSpan。在 RenderParagraph 的范围内,形成了一颗 TextSpan 树。

满足开发人员期望的专门化 API不仅仅是孩子模型,还有一些其他的设计。

Flutter 存在很多小组件,开发者可以使用它们来解决一些问题。只要知道如何使用 ExpandedSizedBox 组件就可以很容易的为 row 或者 column 添加一个空隙,这种开发方式是不必要的。我们搜索 space 的时候就可以找到 Spacer 组件,从而实现相同的效果。

同样地,隐藏一颗 Widget 子树很容易实现,只要我们不把它们写到我们的树上。但是,开发者想要通过一个组件来实现这样的效果。 Visibility 就是为了实现这样的效果,只要使用 Visibility 包裹子树即可。

直接了当的参数

UI 框架有很多属性,而开发者很难记住每个构造方法的每个属性的含义。Flutter 使用响应式开发框架,build 方法中会有很多次的构造方法调用。利用 Dart 命名参数的支持,Flutter 的 API 让构建方法直截了当且便于理解。

这种模式扩展了方法多参数的情形,尤其是 boolean 类型的参数,大多数设置 true 或者 false 都可以自文档,便于理解。此外,避免语义的混淆,boolean 参数和属性从命名上是相反的,比如 enabled: true 而不是 disabled: false

填平

定义 API 是 Flutter framework 中到处在用的技术,比如不存在的错误条件等等。 这样从思虑上就删除了整个错误类。

比如插值函数,插值函数允许插值的两端存在 null 值,而不是定义一个错误的 case:两端是 null 的值总是 null 的,从 null 值开始插值,相当于从 0 开始插值,到 null 值,相当于到 0 值。 这就是说,开发者给差值函数传递 null 值 也会得到一个合理的结果,不会出现错误的情况。

更加巧妙的例子是 Flex 的布局算法。布局的思路是:给定 flex 渲染对象的空间会被它的的子节点们分割,因此,flex 的大小就是占满整个可用空间。在原始的设计中,提供一个无限的边界会失败,因为 这种情形下 Flex 是无限,这是一个无用的配置。API 的设计就被调整了,当 Flex 的约束是无限大的时候,Flex 的大小就适应子节点门的大小,从而减少了错误的情况。

这种方法也用在了构造方法上,有一种情形是,构造方法需要的参数类型和传给它的参数类型不一致。比如,PointerDownEvent 构造方法不允许 down 属性设置为 false,所以构造方法没有 down 字段的设置,并且始终让 down 为 true。

通常来说,这种方法为输入定义了有效的解释。最简单的例子是 Color 构造方法,没有定义四个输入整数参数(一个红色、一个绿色、一个蓝色和一个透明度,并为每个值定义正确的范围),默认的构造方法仅仅定义了一个整数值,并且定义了每个字节的含义。因此,任何输入都是合法的颜色值。

更加体现设计的例子是 paintImage() 方法。这个方法需要 11 个参数,每一个有自己的域。但是但是它们被精心设计成彼此正交的,这样就很少有无效的组合了。

错误优先上报

并不是所有的错误条件都可以被设计出来。对于剩下的错误,在 Debug 模式下,Flutter 一般会尽可能早的捕获,并且会立即报告出来。Assert —— 断言被广泛应用。构造函数参数会被详细检查,生命周期被监视。当检测到不一致时,它们会立即抛出异常。

在一些用例中,可能是非常极端的:比如,当单元测试的时候,不管正在测试啥,RenderBox 的子类都会检查是够满足约束条件。这有助于可能不会执行到的错误。

当错误被抛出,错误里面可能包含很多可用的信息。一些 Flutter 错误会打印堆栈信息,并尽可能的定位到真实的 Bug 位置。一些会遍历树去找错误的代码。最常见的错误包括详细的说明,在某些情况下包括避免错误的示例代码,或者链接到进一步的文档。

响应式模式

二分访问导致基于可变树的 API 困扰:创建树的初始状态通常与后续的更新操作不一样。Flutter 的渲染层是用了这种响应式,因为这种响应式可以有效的维护持久树,这也是可以高效的布局和绘制。但是,这意味着直接和渲染对象进行交互很费劲,也容易出现 Bug。

Flutter 的 Widget 层引进了组合机制,并且使用了响应式来操作背后的渲染树。 API 将树的创建和更新组合到了单一的 build 中,抽象了树的操作。应用的状态发生变化时,新的 UI 配置(Widget)会创建出来,然后 framework 会响应 Widget 的变化,计算出应该呈现的 UI (计算 Element 和 Render 树)。

插值

因为 Flutter 的框架鼓励开发者根据应用的状态来描述 UI 配置,存在一种隐士的机制实现在这些配置之间进行动画。

比如,假如 S1 状态对应的 UI 是圆形,S2 状态对应的 UI 是 方形。如果没有动画机制的话,状态改变时 UI 会非常生硬的变化。因为饮食动画的存在,变化会在几帧内非常平滑的完成。

隐示动画的特征是:有一个 StatefulWidget,该 Widget 持有当前动画的值、动画开始的时间、动画的范围。并且能够完成当前值到下一个值的转换。

这种动画的实现依赖于 lerp 方法。每一个状态代表了一个不可变的对象,该对象有适当的配置(颜色、线宽等等),并且知道如何绘制自己。在动画期间,会绘制中间步骤也就是过渡效果,开始和结束的值以及时间点 t 会传递给 lerp function 方法,然后,该函数会返回出一个不可变对象,对象代表了动画的过渡效果。

这是通过使用“lerp”(线性插值)函数实现的,它使用的是不可变对象。每种状态(在本例中为圆形和方形)都表示为一个不可变对象,该对象配置了适当的设置(颜色、描边宽度等),并且知道如何绘制自己。是时候画动画的中间步骤,开始和结束值传递给适当的“昆虫蜜”函数以及* t *值代表点沿着动画,其中0.0表示“开始”和1.0代表了“结束”[8](docs.flutter.dev/resources/i… a8),函数返回第三个表示中间阶段的不可变对象。

对于圆形变为方形的过度,lerp 方法会返回一个代表 “rounded square” 的对象,对象就是 t 时刻下,圆角的大小。颜色的插值器的 lerp 方法会返回代表颜色的不可变对象。同样,线宽的插值器会返回代表 double 宽度的不可变对象。不管是代表颜色还是形状,这些对象都是实现了相同的接口,并且可以能够用于绘制。

这种技术可以让状态机制、状态与 UI 的映射、动画机制、插值机制和绘制的逻辑机制完全解耦。

这种方法被广泛应用。在 Flutter 中,像 Color 和 Shape 这样的基本类型可以被插值,但是像 Decoration、 TextStyle 、 Theme 这样复杂的类型也可以插值。复杂类型通常被可以插值的组件构建,并且插值复杂类型可以像基本类型那样简单,基本类型的递归差值就实现了复杂对象的效果。

一些可插值的对象被固定的类体系定义。比如,ShapeBorder 接口表示形状,并且有一些已经写好的形状:BeveledRectangleBorderBoxBorder、 CircleBorder、 RoundedRectangleBorder 和 StadiumBorder。一个单一的 lerp 方法不能够提前了解到所有的可能类型,因此,接口就替换了 lerpFrom 和 lerpTo 方法的定义。当告诉从形状 A 到形状 B 插值,首先会检查 B 是否可以 lerpFrom A 变过来,如果不能,然后会检查 A 是否可以 lerpTo 到 B。(如果这两种情况都不可能,那么 t 小于 0.5 就返回 A,大于 0.5 返回 B)

这样类的层次结构就可以任意扩展,在前后两个值之间任意插值。

在一些情形中,插值器本身不能够被可用的类描述,并且定义一个私有类来描述差值过程。比如 CircleBorderRoundedRectangleBorder 的差值。

这种插值机制还有另外一种好处:可以处理从过程中到新的值的差值。比如,在圆形到方形的过渡中间,动画中在插入一个三角形,形状也可以再次改变。只要三角形的类可以 lerpFrom rounded-square 类,过渡就可以无缝的切换执行。

总结

Flutter 的slogan 是 “everything is a widget” 。通过组合的方式来实现 UI,并且 高级的复杂的 Widget 都是基础的 Widget 组合而成。这样的结果就是页面中有大量的 Widget,这就需要认真的设计算法和数据结构,来保证程序运行的高效。正是这些额外的设计,开发者才可以很简单的创建出无限滚动的列表,构建出按需加载的 Widget。