跳到主内容

Flutter 内部原理

从 Flutter 的创始工程师之一那里了解 Flutter 的内部工作原理。

本文档介绍了 Flutter 工具包的内部工作原理,正是这些原理使得 Flutter 的 API 成为可能。由于 Flutter 的 Widget 是通过激进的组合方式构建的,因此使用 Flutter 构建的用户界面包含大量的 Widget。为了支持这种工作负载,Flutter 在布局和构建 Widget 时采用了亚线性算法,并使用数据结构使“树手术”(Tree surgery)变得高效,同时还包含许多常量级优化。结合一些额外的细节,这种设计还使得开发者能够使用回调函数轻松创建无限滚动列表,这些回调函数仅构建用户当前可见的 Widget。

激进的组合性

#

Flutter 最显著的特点之一是其激进的组合性(aggressive composability)。Widget 是通过组合其他 Widget 来构建的,而这些 Widget 本身又是通过越来越基础的 Widget 构建出来的。例如,Padding 是一个 Widget,而不是其他 Widget 的一个属性。因此,使用 Flutter 构建的用户界面由许多、许多的 Widget 组成。

Widget 构建递归在 RenderObjectWidgets 处触底,这些 Widget 用于在底层渲染(render)树中创建节点。渲染树是一种存储用户界面几何结构的数据结构,它在布局阶段进行计算,并在绘制点击测试(hit testing)阶段使用。大多数 Flutter 开发者不会直接编写渲染对象,而是通过 Widget 来操作渲染树。

为了在 Widget 层支持激进的组合性,Flutter 在 Widget 层和渲染树层都使用了许多高效的算法和优化,这些将在后续小节中介绍。

亚线性布局

#

面对大量的 Widget 和渲染对象,实现高性能的关键在于高效的算法。其中最重要的是布局(layout)的性能,即确定渲染对象几何结构(例如大小和位置)的算法。一些其他工具包使用时间复杂度为 O(N²) 或更差的布局算法(例如某些约束领域中的不动点迭代)。Flutter 的目标是实现初始布局的线性性能,以及后续更新现有布局时亚线性的布局性能。通常,布局所花费的时间随渲染对象数量增加的增长速度应低于线性增长。

Flutter 每帧执行一次布局,且布局算法以单次遍历方式工作。约束(Constraints)由父对象通过调用每个子对象的布局方法向下传递到树中。子对象递归地执行它们自己的布局,然后通过从其布局方法返回,将几何结构(geometry)向上传递回树中。重要的是,一旦渲染对象从其布局方法返回,该渲染对象在下一帧布局之前不会再次被访问1。这种方法将原本可能分开的度量和布局过程合并为一次遍历。因此,在布局期间,每个渲染对象最多被访问两次2:一次是在向下遍历树时,另一次是在向上返回时。

Flutter 对此通用协议有几种特殊实现。最常见的是 RenderBox,它在二维笛卡尔坐标系中运行。在盒子布局中,约束是最小/最大宽度和高度。在布局期间,子对象通过在这些边界内选择一个尺寸来确定其几何结构。子对象完成布局返回后,父对象决定子对象在父坐标系中的位置3。注意,子对象的布局不能依赖于其位置,因为位置是在子对象从布局返回后才确定的。因此,父对象可以自由地重新定位子对象,而无需重新计算其布局。

更一般地说,在布局期间,唯一从父级流向子级的信息是约束,而唯一从子级流向父级的信息是几何结构。这些不变性可以减少布局期间所需的工作量。

  • 如果子对象没有将自己的布局标记为脏(dirty),并且父对象给出的约束与子对象在上次布局期间收到的约束相同,那么子对象可以立即从布局方法返回,从而截断遍历路径。

  • 每当父对象调用子对象的布局方法时,父对象会指示它是否使用了子对象返回的尺寸信息。如果父对象不使用该尺寸信息(这种情况很常见),那么即使子对象选择了新的尺寸,父对象也无需重新计算其布局,因为父对象可以保证新尺寸会符合现有约束。

  • 紧约束(Tight constraints)是指那些只能由一个唯一有效几何结构满足的约束。例如,如果最小和最大宽度相等,且最小和最大高度相等,那么唯一满足这些约束的尺寸就是该特定的宽高。如果父对象提供紧约束,则即使父对象在其布局中使用了子对象的尺寸,每当子对象重新计算布局时,父对象也无需重新计算其布局,因为在没有父对象的新约束下,子对象无法改变尺寸。

  • 渲染对象可以声明它仅使用父对象提供的约束来确定其几何结构。这样的声明告知框架,即使约束不是紧约束,且父对象的布局依赖于子对象的尺寸,父对象也无需在子对象重新计算布局时重新计算其布局,因为在没有父对象的新约束下,子对象无法改变尺寸。

由于这些优化,当渲染对象树包含脏节点时,在布局期间只会访问这些节点及其周围有限的子树部分。

亚线性 Widget 构建

#

与布局算法类似,Flutter 的 Widget 构建算法也是亚线性的。构建完成后,Widget 由 Element 树持有,Element 树保留了用户界面的逻辑结构。Element 树是必要的,因为 Widget 本身是不可变的,这意味着(除其他事项外)它们无法记录其与其他 Widget 之间的父子关系。Element 树还持有与有状态 Widget(StatefulWidgets)关联的 State 对象。

响应用户输入(或其他激励)时,Element 可能会变脏(dirty),例如当开发者在关联的 State 对象上调用 setState() 时。框架会维护一个脏 Element 列表,并在构建(build)阶段直接跳转到它们,跳过干净的 Element。在构建阶段,信息单向流向 Element 树,这意味着每个 Element 在构建阶段最多被访问一次。一旦被清理,Element 就不会再次变脏,因为通过归纳法,其所有祖先 Element 也都是干净的4

由于 Widget 是不可变的,如果一个 Element 没有将自己标记为脏,并且父对象使用相同的 Widget 重建该 Element,那么该 Element 可以立即从构建方法返回,从而截断遍历。此外,Element 只需要比较两个 Widget 引用的对象标识,即可确定新 Widget 与旧 Widget 是否相同。开发者利用这一优化来实现重投影(reprojection)模式,即 Widget 将存储在成员变量中的预构建子 Widget 包含在其构建方法中。

在构建期间,Flutter 还通过 InheritedWidgets 避免了向上遍历父级链。如果 Widget 频繁地遍历父级链(例如为了确定当前主题颜色),那么构建阶段的时间复杂度将达到 O(N²),这在激进组合带来的深层树结构中会变得非常大。为了避免这种父级遍历,框架通过在每个 Element 上维护 InheritedWidget 的哈希表,将信息向下推送到 Element 树中。通常,许多 Element 会引用同一个哈希表,该表仅在引入新 InheritedWidget 的 Element 处发生更改。

线性调解(Reconciliation)

#

与普遍看法相反,Flutter 并不采用树差异(tree-diffing)算法。相反,框架通过使用 O(N) 算法独立检查每个 Element 的子列表来决定是否重用 Element。子列表调解(reconciliation)算法针对以下情况进行了优化:

  • 旧子列表为空。
  • 两个列表完全相同。
  • 列表中的某处有 Widget 的插入或移除。
  • 如果每个列表中包含具有相同 Key5 的 Widget,则这两个 Widget 会被匹配。

通用的方法是通过比较每个 Widget 的运行时类型和 Key 来匹配两个子列表的开头和结尾,可能会在每个列表的中间找到一个包含所有未匹配子节点的非空范围。然后,框架根据 Key 将旧子列表范围内的子节点放入哈希表中。接下来,框架遍历新子列表中的该范围,并根据 Key 查询哈希表以获取匹配项。未匹配的子节点将被丢弃并从头开始重建,而匹配的子节点则会使用它们的新 Widget 进行重建。

树手术(Tree surgery)

#

重用 Element 对性能至关重要,因为 Element 拥有两项关键数据:有状态 Widget 的状态,以及底层的渲染对象。当框架能够重用 Element 时,用户界面该逻辑部分的状态得以保留,之前计算的布局信息也可以复用,通常可以避免遍历整个子树。实际上,重用 Element 的价值非常大,以至于 Flutter 支持非局部(non-local)的树变动,并能保留状态和布局信息。

开发者可以通过将 GlobalKey 关联到其 Widget 来执行非局部树变动。每个全局 Key 在整个应用程序中都是唯一的,并注册在线程特定的哈希表中。在构建阶段,开发者可以将带有全局 Key 的 Widget 移动到 Element 树中的任意位置。框架不会在该位置构建一个新的 Element,而是会检查哈希表,并将现有的 Element 从其旧位置重新挂载到新位置,从而保留整个子树。

重挂载子树中的渲染对象能够保留其布局信息,因为在渲染树中,布局约束是唯一从父级流向子级的信息。新父级的布局被标记为脏,因为其子列表发生了变化,但如果新父级传递给子级的布局约束与子级从旧父级收到的相同,子级可以立即从布局中返回,从而截断遍历。

全局 Key 和非局部树变动被开发者广泛用于实现 Hero 动画和页面导航等效果。

常量级优化

#

除了这些算法层面的优化,实现激进的组合性还依赖于几个重要的常量级优化。这些优化在上述主要算法的叶子节点处最为重要。

  • 不依赖特定的子模型。 与大多数使用子列表的工具包不同,Flutter 的渲染树并不承诺使用特定的子模型。例如,RenderBox 类具有一个抽象的 visitChildren() 方法,而不是具体的 firstChildnextSibling 接口。许多子类只支持单个子节点(直接作为成员变量持有),而不是子列表。例如,RenderPadding 只支持一个子节点,因此具有更简单的布局方法,执行时间更短。

  • 视觉渲染树,逻辑 Widget 树。 在 Flutter 中,渲染树在与设备无关的视觉坐标系中运行,这意味着 x 坐标值较小的一侧始终在左边,即使当前的阅读方向是从右向左。Widget 树通常以逻辑坐标运行,即使用起始(start)和结束(end)值,其视觉解释取决于阅读方向。从逻辑坐标到视觉坐标的转换是在 Widget 树和渲染树交接时完成的。这种方法更高效,因为渲染树中的布局和绘制计算比 Widget 到渲染树的交接更为频繁,且可以避免重复的坐标转换。

  • 由专门的渲染对象处理文本。 绝大多数渲染对象都不了解文本的复杂性。相反,文本由专门的渲染对象 RenderParagraph 处理,它是渲染树中的一个叶子节点。开发者不是通过继承一个文本感知的渲染对象,而是通过组合将文本合并到用户界面中。这种模式意味着 RenderParagraph 只要父级提供相同的布局约束,就可以避免重新计算其文本布局,这在即使进行“树手术”时也很常见。

  • 可观察对象。 Flutter 同时使用模型观察和响应式范式。显然,响应式范式占主导地位,但 Flutter 对某些叶子数据结构使用了可观察的模型对象。例如,Animation 在其值发生变化时会通知观察者列表。Flutter 将这些可观察对象从 Widget 树移交给渲染树,后者直接观察它们,并在它们改变时仅使流水线中相应的阶段失效。例如,Animation 的改变可能仅触发绘制阶段,而不是同时触发构建和绘制阶段。

综上所述,并将这些优化应用到由激进组合产生的大型树结构中,这些优化对性能有着巨大的影响。

Element 树与 RenderObject 树的分离

#

Flutter 中的 RenderObjectElement (Widget) 树是同构的(严格来说,RenderObject 树是 Element 树的一个子集)。一个显而易见的简化是将这些树合并为一棵树。然而,在实践中,将这些树分开有许多好处:

  • 性能。 当布局发生变化时,只需遍历布局树的相关部分。由于组合,Element 树通常有很多额外的节点,这些节点原本必须被跳过。

  • 清晰度。 更清晰的关注点分离使得 Widget 协议和渲染对象协议各自能够针对其特定需求进行专门优化,简化了 API 界面,从而降低了引入 Bug 的风险和测试负担。

  • 类型安全。 渲染对象树可以更具类型安全性,因为它可以在运行时保证子节点将具有适当的类型(每个坐标系例如都有其对应的渲染对象类型)。组合 Widget 可以不了解布局期间使用的坐标系(例如,同一个暴露应用模型部分的 Widget 既可以用于盒子布局,也可以用于 Sliver 布局),因此在 Element 树中,验证渲染对象的类型将需要遍历树。

无限滚动

#

无限滚动列表对工具包来说历来是众所周知的难题。Flutter 通过基于构建器(builder)模式的简单接口支持无限滚动列表,在该模式中,ListView 使用回调函数在 Widget 对用户滚动可见时按需构建它们。支持此功能需要视口感知布局按需构建 Widget

视口感知布局

#

像 Flutter 中的大多数事物一样,可滚动 Widget 是通过组合构建的。可滚动 Widget 的外部是一个 Viewport(视口),这是一个“内部更大”的盒子,意味着其子元素可以延伸到视口边界之外,并可以滚动进入视图。然而,视口并不拥有 RenderBox 子节点,而是拥有 RenderSliver 子节点,即所谓的 Slivers,它们具有视口感知的布局协议。

Sliver 布局协议与盒子布局协议的结构相匹配,即父级将约束向下传递给子级并获得几何结构作为回报。然而,这两种协议之间的约束和几何数据有所不同。在 Sliver 协议中,子级会获得有关视口的信息,包括剩余的可见空间量。它们返回的几何数据支持各种滚动相关效果,包括可折叠标题和视差效果。

不同的 Slivers 以不同的方式填充视口中的可用空间。例如,一个产生线性子列表的 Sliver 会按顺序布局每个子节点,直到该 Sliver 耗尽子节点或耗尽空间。同样,一个产生二维子节点网格的 Sliver 仅填充其网格中可见的部分。因为它们知道有多少空间是可见的,所以 Slivers 可以只产生有限数量的子节点,即使它们有潜力产生无限数量的子节点。

Slivers 可以被组合起来创建定制的可滚动布局和效果。例如,单个视口可以有一个可折叠的标题,后面跟着一个线性列表,然后是一个网格。所有三个 Slivers 都将通过 Sliver 布局协议进行协作,仅生成那些实际在视口中可见的子节点,无论这些子节点属于标题、列表还是网格6

按需构建 Widget

#

如果 Flutter 采用严格的“先构建-再布局-再绘制”流水线,上述内容将不足以实现无限滚动列表,因为关于视口可见空间大小的信息仅在布局阶段可用。没有额外的机制,布局阶段对于构建填充空间所需的 Widget 来说太晚了。Flutter 通过在流水线的构建和布局阶段交织处理解决了这个问题。在布局阶段的任何时刻,只要新 Widget 是当前执行布局的渲染对象的后代,框架就可以开始按需构建它们。

交织构建和布局之所以可能,仅归功于构建和布局算法中对信息传播的严格控制。具体而言,在构建阶段,信息只能向下传播。当渲染对象正在执行布局时,布局遍历尚未访问该渲染对象下方的子树,这意味着在该子树中通过构建产生的写入操作不会使迄今为止进入布局计算的任何信息失效。同样,一旦布局从一个渲染对象返回,该渲染对象在本次布局中将永远不会再次被访问,这意味着任何由后续布局计算产生的写入操作都无法使构建该渲染对象子树所使用的信息失效。

此外,线性调解和树手术对于在滚动时高效更新 Element,以及当 Element 滚动进入和离开视口边缘时修改渲染树至关重要。

API 人体工程学

#

只有当框架能够被有效使用时,速度才是有意义的。为了引导 Flutter 的 API 设计向更高的可用性发展,Flutter 在开发者的大规模 UX 研究中进行了反复测试。这些研究有时确认了既有的设计决策,有时帮助引导了功能的优先级排序,有时则改变了 API 设计的方向。例如,Flutter 的 API 有详细的文档;UX 研究证实了此类文档的价值,但也特别强调了对示例代码和说明性图表的需求。

本节讨论了 Flutter 在 API 设计中为辅助可用性而做出的一些决策。

专有 API 设计以契合开发者的思维模式

#

Flutter WidgetElementRenderObject 树中节点的基类没有定义子模型。这允许每个节点针对适用于该节点的子模型进行专门化。

大多数 Widget 对象只有一个子 Widget,因此只暴露一个 child 参数。一些 Widget 支持任意数量的子节点,并暴露一个接收列表的 children 参数。一些 Widget 则根本没有子节点,不预留内存,也没有任何参数。类似地,RenderObject 暴露了特定于其子模型的 API。RenderImage 是一个叶子节点,没有子节点的概念。 RenderPadding 接收单个子节点,因此存储单个指针。 RenderFlex 接收任意数量的子节点并将其作为链表管理。

在某些罕见的情况下,会使用更复杂的子模型。 RenderTable 渲染对象的构造函数接收一个子节点数组的数组,该类暴露了控制行数和列数的 Getter 和 Setter,并有特定的方法通过 x,y 坐标替换单个子节点、添加行、提供新的子节点数组,以及用单个数组和列计数替换整个子列表。在实现中,该对象不像大多数渲染对象那样使用链表,而是使用可索引的数组。

Chip Widget 和 InputDecoration 对象具有与相关控件上的插槽匹配的字段。如果采用“一刀切”的子模型,会将语义强行叠加在子列表之上(例如,定义第一个子节点为前缀值,第二个为后缀),而专用的子模型则允许改为使用专用的命名属性。

这种灵活性允许以最符合其角色惯用语的方式操作这些树中的每个节点。很少有人想在表格中插入单元格导致所有其他单元格重新排列;同样,很少有人想按索引而不是按引用从 Flex 行中删除子节点。

RenderParagraph 对象是最极端的情况:它拥有一个完全不同类型的子节点 TextSpan。在 RenderParagraph 边界处,RenderObject 树过渡为 TextSpan 树。

这种使 API 专门化以满足开发者期望的整体方法,不仅应用于子模型。

存在一些相当琐碎的 Widget,它们存在的目的就是为了让开发者在寻找问题解决方案时能找到它们。一旦知道如何操作,向行或列中添加空格很容易,只需使用 Expanded Widget 和一个零尺寸的 SizedBox 子节点即可,但探索出这种模式是不必要的,因为搜索 space 就能找到 Spacer Widget,它直接使用 ExpandedSizedBox 来实现该效果。

类似地,隐藏一个 Widget 子树也很容易,只需在构建时不包含该 Widget 子树即可。然而,开发者通常期望有一个 Widget 来执行此操作,因此 Visibility Widget 的存在就是为了将这种模式封装在一个琐碎的可重用 Widget 中。

显式参数

#

UI 框架往往拥有许多属性,开发者很少能记住每个类每个构造函数参数的语义含义。由于 Flutter 使用响应式范式,Flutter 中的构建方法通常包含许多构造函数调用。通过利用 Dart 对命名参数的支持,Flutter 的 API 能够保持此类构建方法的清晰易懂。

这种模式扩展到了任何具有多个参数的方法,特别扩展到了任何布尔参数,以便方法调用中孤立的 truefalse 字面量始终具有自文档化功能。此外,为了避免 API 中双重否定造成的常见混淆,布尔参数和属性始终以肯定形式命名(例如,enabled: true 而不是 disabled: false)。

填平陷阱

#

Flutter 框架在许多地方使用的一种技术是将 API 定义为错误条件不存在。这消除了整类的错误考量。

例如,插值函数允许插值的两端均为 null,而不是将其定义为错误情况:在两个 null 值之间进行插值始终为 null,从 null 值插值到非 null 值,或反之,等同于插值到给定类型的零模拟值。这意味着不小心将 null 传递给插值函数的开发者不会遇到错误情况,而是会得到一个合理的结果。

一个更微妙的例子是 Flex 布局算法。该布局的概念是,分配给 Flex 渲染对象的空间在各子节点之间划分,因此 Flex 的尺寸应为可用空间的全部。在最初的设计中,提供无限空间会导致失败:这意味着 Flex 应该被无限缩放,这是一个无用的布局配置。相反,API 进行了调整,以便当无限空间分配给 Flex 渲染对象时,渲染对象会自行调整大小以适应子节点所需的尺寸,从而减少了可能的错误情况。

这种方法还用于避免创建允许产生不一致数据的构造函数。例如,PointerDownEvent 构造函数不允许将 PointerEventdown 属性设置为 false(这会自相矛盾);相反,该构造函数没有 down 字段的参数,并始终将其设置为 true

通常,该方法是为输入域中的所有值定义有效的解释。最简单的例子是 Color 构造函数。它不接收四个整数(分别代表红、绿、蓝和 alpha),每个整数都可能超出范围,而是接收一个单一的整数值,并定义每一位的含义(例如,最低八位定义红色分量),这样任何输入值都是有效的颜色值。

一个更复杂的例子是 paintImage() 函数。此函数接收十一个参数,其中一些参数的输入域非常广泛,但它们经过精心设计,大部分相互正交,因此几乎没有无效组合。

积极地报告错误情况

#

并非所有错误条件都能通过设计排除。对于剩余的错误,在调试构建中,Flutter 通常会尝试尽早捕获并立即报告它们。断言(Asserts)被广泛使用。构造函数参数被详细点检。生命周期受到监控,当检测到不一致时,它们会立即抛出异常。

在某些情况下,这被发挥到了极致:例如,运行单元测试时,无论测试在做什么,每个执行布局的 RenderBox 子类都会积极检查其固有尺寸(intrinsic sizing)方法是否满足固有尺寸契约。这有助于捕获原本可能未被触发的 API 错误。

当抛出异常时,它们会包含所有可用的信息。Flutter 的一些错误消息会主动探测相关的堆栈跟踪,以确定实际 Bug 最可能的位置。其他消息则遍历相关的树以确定错误数据的来源。最常见的错误包括详细说明,在某些情况下甚至包含避免该错误的示例代码,或指向进一步文档的链接。

响应式范式

#

可变的基于树的 API 遭受二分访问模式的困扰:创建树的初始状态通常使用与后续更新非常不同的一组操作。Flutter 的渲染层使用了这种范式,因为它是维护持久树的有效方式,这对于高效布局和绘制至关重要。然而,这意味着与渲染层的直接交互充其量是尴尬的,往坏了说是容易出错的。

Flutter 的 Widget 层引入了一种使用响应式范式的组合机制7来操作底层渲染树。该 API 通过将树创建和树变动步骤合并为一个单一的树描述(构建)步骤来抽象化树操作,在每次系统状态改变后,开发者描述用户界面的新配置,框架计算为反映此新配置所需的系列树变动。

插值(Interpolation)

#

由于 Flutter 框架鼓励开发者描述与当前应用状态相匹配的接口配置,因此存在一种在这些配置之间进行隐式动画的机制。

例如,假设在状态 S1 中接口由一个圆组成,而在状态 S2 中由一个正方形组成。如果没有动画机制,状态变化将导致刺眼的接口跳变。隐式动画允许圆在几帧内平滑地变为正方形。

每个可以隐式动画化的功能都有一个有状态的 Widget,它会记录输入值的当前状态,并在输入值发生变化时开始动画序列,在指定的持续时间内从当前值过渡到新值。

这是使用不可变对象的 lerp(线性插值)函数实现的。每个状态(在此例中为圆和正方形)都被表示为一个不可变对象,该对象配置了适当的设置(颜色、描边宽度等)并且知道如何绘制自身。当动画期间需要绘制中间步骤时,开始和结束值与表示动画点位置的 t 值一起传递给相应的 lerp 函数,其中 0.0 代表 start,1.0 代表 end8,该函数返回代表中间阶段的第三个不可变对象。

对于圆到正方形的过渡,lerp 函数将返回一个代表“圆角正方形”的对象,其半径为从 t 值导出的分数,颜色使用颜色的 lerp 函数进行插值,描边宽度使用双精度浮点数的 lerp 函数进行插值。该对象实现了与圆和正方形相同的接口,因此在被请求时能够绘制自身。

这种技术允许状态机制、状态到配置的映射、动画机制、插值机制以及关于如何绘制每一帧的特定逻辑完全彼此分离。

这种方法具有广泛的适用性。在 Flutter 中,不仅 ColorShape 等基本类型可以进行插值,更复杂的类型如 DecorationTextStyleTheme 也可以。这些类型通常由本身可以插值的组件构成,而对更复杂的对象进行插值通常就像递归地插值描述复杂对象的所有值一样简单。

一些可插值对象由类层次结构定义。例如,形状由 ShapeBorder 接口表示,存在各种各样的形状,包括 BeveledRectangleBorderBoxBorderCircleBorderRoundedRectangleBorderStadiumBorder。单个 lerp 函数无法预见所有可能的类型,因此接口定义了 lerpFromlerpTo 方法,静态 lerp 方法会将调用委派给这些方法。当被告知从形状 A 插值到形状 B 时,首先询问 B 是否可以 lerpFrom A,如果不能,则询问 A 是否可以 lerpTo B。(如果都不行,则函数在 t 值小于 0.5 时返回 A,否则返回 B。)

这允许类层次结构被任意扩展,后续添加的类能够实现之前已知的类与自身之间的插值。

在某些情况下,插值本身无法由任何可用类描述,因此会定义一个私有类来描述中间阶段。例如,在 CircleBorderRoundedRectangleBorder 之间进行插值就是这种情况。

该机制还有一个额外的优势:它可以处理从中间阶段到新值的插值。例如,在圆到正方形的过渡中途,形状可能会再次发生变化,导致动画需要插值到三角形。只要三角形类可以 lerpFrom 圆角正方形的中间类,过渡就可以无缝执行。

结论

#

Flutter 的口号“一切皆 Widget”围绕着通过组合 Widget 来构建用户界面,而这些 Widget 本身又由越来越基础的 Widget 构成。这种激进组合的结果是产生了大量的 Widget,需要精心设计的算法和数据结构来高效处理。通过一些额外的设计,这些数据结构也使得开发者能够轻松创建无限滚动列表,当 Widget 对用户可见时按需构建它们。


脚注

  1. 至少对于布局而言。它可能会为了绘制、必要时为了构建可访问性树以及必要时为了点击测试而被重新访问。

  2. 当然,现实情况要复杂一些。某些布局涉及固有维度或基线测量,这些确实涉及相关子树的额外遍历(使用了积极的缓存来减轻最坏情况下二次性能的可能)。然而,这些情况非常罕见。特别是,对于缩减封装(shrink-wrapping)的常见情况,并不需要固有维度。

  3. 从技术上讲,子节点的位置不是其 RenderBox 几何结构的一部分,因此实际上不需要在布局期间计算。许多渲染对象隐式地将其唯一的子节点定位在其自身原点相对的 0,0 处,这不需要任何计算或存储。一些渲染对象避免在最后一刻(例如绘制阶段)之前计算其子节点的位置,以避免如果后续不绘制它们则完全免除该计算。

  4. 此规则有一个例外。正如按需构建 Widget 部分所讨论的,一些 Widget 可以因布局约束的变化而重新构建。如果一个 Widget 在同一帧中因布局约束的变化而受到影响,同时又因无关原因标记自己为脏,它将被更新两次。这种冗余构建仅限于 Widget 本身,不会影响其后代。

  5. Key 是一个可选关联到 Widget 的不透明对象,其相等性运算符用于影响调解算法。

  6. 为了可访问性,并为了给应用程序在 Widget 构建到屏幕上出现之间提供几毫秒的额外时间,视口会在可见 Widget 之前和之后创建(但不绘制)几百像素的 Widget。

  7. 这种方法最早由 Facebook 的 React 库推广。

  8. 实际上,t 值被允许超过 0.0-1.0 的范围,对于某些曲线确实如此。例如,“弹性”曲线为了表现反弹效果会短暂超调。插值逻辑通常能够根据需要在外推开始或结束之外的部分进行处理。对于某些类型,例如在插值颜色时,t 值实际上被限制在 0.0-1.0 范围内。