【简单翻译】flutter的设计哲学( inside flutter)

文章目录

简单翻译

英文链接来自于

对应的版本为 11 月 11 日版本 连接为

后续如有改动,请以最新的英文版本为准,有翻译不准确的地方请参照英文版本自行理解


概述


本文档描述了 Flutter 工具包的内部工作原理,使 Flutter 的 API 成为可能。因为 Flutter 小部件是使用积极的可组合性(aggressive composition)构建的,所以使用 Flutter 构建的用户界面具有大量小部件。

为了支持这种工作量,Flutter 使用次线性算法来布局和构建小部件,这些数据结构使树形手术变得高效,并且具有许多常量因子优化。

通过一些额外的细节,这种设计还使开发人员可以使用回调来轻松创建无限滚动列表,这些回调可以构建用户可见的小部件。

积极的可组合性(Aggressive composability)


Flutter 最独特的一个方面是其积极的可组合性。

小部件是通过组合其他小部件构建的,这些小部件本身是由逐步更基本的小部件构建的。例如,Padding 是一个小部件而不是其他小部件的属性。因此,使用 Flutter 构建的用户界面由许多小部件组成。

小部件构建递归在 RenderObjectWidgets 中触底,这些小部件在底层渲染树中创建节点。渲染树是一种数据结构,用于存储用户界面的几何图形,该几何图形在布局期间计算并在绘制和命中测试期间使用。大多数 Flutter 开发人员不直接创建对象,而是使用小部件操纵渲染树。

为了在小部件层支持积极的可组合性,Flutter 在小部件和渲染树层使用了许多有效的算法和优化,这些将在以下小节中介绍。

次线性布局

使用大量小部件和渲染对象,良好性能的关键是高效的算法。最重要的是布局的性能,布局是确定渲染对象的几何(例如,大小和位置)的算法。其他一些工具包使用 O(N²)或更差的布局算法(例如,某些约束域中的定点迭代)。 Flutter 的目标是初始布局的线性性能,以及随后更新现有布局的常见情况下的次线性布局性能。通常,布局所花费的时间量应该比渲染对象的数量更慢。

Flutter 每帧执行一个布局,布局算法一次完成。约束通过父对象向下传递,父对象在每个子对象上调用布局方法。子项递归地执行自己的布局,然后通过返回布局方法将几何返回到树中。重要的是,一旦渲染对象从其布局方法返回,该渲染对象将不再被访问,直到下一帧的布局。这种方法将可能单独的度量和布局传递组合成单个传递,因此,每个渲染对象在布局期间最多访问两次:一次在树下,一次在树上。

Flutter 有这个通用协议的几个专业。最常见的专业是 RenderBox,它以二维笛卡尔坐标运算。在框布局中,约束是最小和最大宽度以及最小和最大高度。在布局期间,子项通过选择这些边界内的大小来确定其几何。孩子从布局返回后,父母决定孩子在父母坐标系中的位置。请注意,孩子的布局不能取决于孩子的位置,因为孩子的位置直到孩子从布局返回后才确定。因此,父母可以自由地重新定位孩子,而无需重新计算孩子的布局。

更一般地说,在布局期间,从父节点传递到子节点的唯一信息是约束,并且从子节点流向父节点的唯一信息是几何体。这些不变量可以减少布局期间所需的工作量:

  • 如果孩子没有将自己的布局标记为脏,则孩子可以立即从布局返回,切断步行,只要父母给孩子的约束与孩子在前一个布局中收到的约束相同。

  • 每当父级调用子级的布局方法时,父级指示它是否使用从子级返回的大小信息。如果经常发生父级不使用大小信息,那么如果子级选择新大小,则父级不需要重新计算其布局,因为父级保证新大小将符合现有约束。

  • 严格约束是指只能通过一个有效几何体来满足的约束。例如,如果最小和最大宽度彼此相等并且最小和最大高度彼此相等,则满足这些约束的唯一尺寸是具有该宽度和高度的尺寸。如果父级提供严格约束,则父级无需在子级重新计算其布局时重新计算其布局,即使父级在其布局中使用子级的大小,因为子级无法在没有父级的新约束的情况下更改大小。

  • 渲染对象可以声明它仅使用父级提供的约束来确定其几何。这样的声明通知框架该子渲染对象的父级在子级重新计算其布局时不需要重新计算其布局,即使约束不紧,即使父级的布局取决于子级的大小,因为子级无法更改大小没有来自其父级的新约束。

作为这些优化的结果,当渲染对象树包含脏节点时,在布局期间仅访问那些节点以及它们周围的子树的有限部分。

次线性小部件构建

与布局算法类似,Flutter 的小部件构建算法是次线性的。构建之后,小部件由元素树保存,元素树保留用户界面的逻辑结构。元素树是必要的,因为小部件本身是不可变的,这意味着(除其他外),它们不能记住它们与其他小部件的父或子关系。元素树还包含与有状态窗口小部件关联的状态对象。

响应于用户输入(或其他刺激),元素可能变脏,例如,如果开发人员在关联的状态对象上调用 setState()。框架保留一个脏元素列表,并在构建阶段直接跳转到它们,跳过干净的元素。在构建阶段,信息在元素树中单向流动,这意味着在构建阶段期间每个元素最多访问一次。清洁后,元素不会再次变脏,因为通过感应,它的所有祖先元素也都是干净的。

因为窗口小部件是不可变的,所以如果元素没有将自身标记为脏,则元素可以立即从构建返回,如果父级使用相同的窗口小部件重建元素,则会切断步行。此外,元素只需要比较两个窗口小部件引用的对象标识,以确定新窗口小部件与旧窗口小部件相同。开发人员利用此优化来实现重投影模式,其中窗口小部件包括在其构建中存储为成员变量的预构建子窗口小部件。

在构建期间,Flutter 还避免使用 InheritedWidgets 遍历父链。如果窗口小部件通常走他们的父链,例如确定当前的主题颜色,则构建阶段将在树的深度变为 O(N 2),由于积极的组合,这可能非常大。为了避免这些父行为,框架通过在每个元素上维护一个 InheritedWidgets 的哈希表来向下推送元素树中的信息。通常,许多元素将引用相同的哈希表,该哈希表仅在引入新的 InheritedWidget 的元素上更改。

线性对比

与流行的看法相反,Flutter 不使用树差异算法。相反,框架通过使用 O(N)算法独立地检查每个元素的子列表来决定是否重用元素。子列表协调算法针对以下情况进行了优化:

  • 旧子列表为空。
  • 两个列表是相同的。
  • 在列表中的一个位置插入或删除一个或多个小部件。
  • 如果每个列表包含具有相同键的窗口小部件,则匹配两个窗口小部件。

一般方法是通过比较每个小部件的运行时类型和键来匹配两个子列表的开头和结尾,可能在包含所有不匹配子节点的每个列表的中间找到非空范围。然后,框架将旧子列表中的子项放入基于其键的哈希表中。接下来,框架遍历新子列表中的范围,并按键查询哈希表以进行匹配。无法对比的孩子被丢弃并从头开始重建,而匹配的孩子则用他们的新小部件重建。

树手术(Tree surgery)

重用元素对性能很重要,因为元素拥有两个关键数据:状态小部件的状态和底层的渲染对象。当框架能够重用元素时,保留用户界面的逻辑部分的状态,并且可以重用先前计算的布局信息,通常避免整个子树遍历。事实上,重用元素非常有价值,Flutter 支持非本地树突变,可以保留状态和布局信息。

开发人员可以通过将 GlobalKey 与其中一个小部件相关联来执行非本地树突变。每个全局密钥在整个应用程序中都是唯一的,并使用特定于线程的哈希表进行注册。在构建阶段,开发人员可以使用全局键将窗口小部件移动到元素树中的任意位置。框架将检查哈希表,并将现有元素从其先前位置重新显示到新位置,而不是在该位置构建新元素,而是保留整个子树。

重新构造的子树中的渲染对象能够保留其布局信息,因为布局约束是渲染树中从父级传递到子级的唯一信息。新父级被标记为脏,因为其子列表已更改,但如果新父级传递子级具有子级从其旧父级接收的相同布局限制,则子级可以立即从布局返回,从而切断遍历。

开发人员广泛使用全局键和非本地树突变来实现英雄过渡和导航等效果。

恒定因子优化(Constant-factor optimizations)

除了这些算法优化之外,实现积极的可组合性还依赖于几个重要的恒定因子优化。这些优化在上面讨论的主要算法的叶子中是最重要的。

  • 子布局模型不可知论者。与大多数使用子列表的工具包不同,Flutter 的渲染树不会提交给特定的子模型。例如,RenderBox 类有一个抽象的 visitChildren()方法,而不是具体的 firstChild 和 nextSibling 接口。许多子类仅支持单个子项,直接作为成员变量而不是子项列表。例如,RenderPadding 仅支持单个子节点,因此,布局方法更简单,执行时间更短。
  • 视觉渲染树,逻辑小部件树。在 Flutter 中,渲染树在与设备无关的视觉坐标系中操作,这意味着即使当前读取方向是从右到左,x 坐标中的较小值也始终向左。小部件树通常在逻辑坐标中操作,意味着具有开始和结束值,其视觉解释取决于阅读方向。从逻辑坐标到可视坐标的转换是在小部件树和渲染树之间的切换中完成的。这种方法更有效,因为渲染树中的布局和绘制计算比窗口小部件到渲染树的切换更频繁,并且可以避免重复的坐标转换。
  • 由专门的渲染对象处理的文本。绝大多数渲染对象都不了解文本的复杂性。相反,文本由专门的渲染对象 RenderParagraph 处理,RenderParagraph 是渲染树中的叶子。开发人员不是使用文本感知渲染对象进行子类化,而是使用合成将文本合并到用户界面中。这种模式意味着 RenderParagraph 可以避免重新计算其文本布局,只要其父级提供相同的布局约束,这是常见的,即使在树手术期间也是如此。
  • 可观察的对象。颤动使用模型观察和反应范例。显然,反应范式占主导地位,但 Flutter 对一些叶子数据结构使用可观察的模型对象。例如,动画在值发生变化时通知观察者列表。颤动将这些可观察对象从小部件树移交给渲染树,渲染树直接观察它们并且在它们改变时仅使管道的适当阶段无效。例如,对动画的更改可能仅触发绘制阶段,而不是构建和绘制阶段。

这些优化结合起来并总结了积极构图所产生的大树,对性能产生了重大影响。

无限滚动

无限的滚动列表对于工具包来说是非常困难的。 Flutter 支持基于构建器模式的简单接口的无限滚动列表,其中 ListView 使用回调按需构建窗口小部件,因为它们在滚动期间对用户可见。支持此功能需要视口感知布局和按需构建窗口小部件。

视口感知布局

像 Flutter 中的大多数东西一样,可滚动的小部件是使用合成构建的。可滚动窗口小部件的外部是一个视口,它是一个“内部较大”的框,这意味着它的子窗口可以超出视口的边界,并可以滚动到视图中。但是,视口不是具有 RenderBox 子元素,而是具有 RenderSliv​​er 子元素,称为Slivers,具有视口感知布局协议。

Slivers布局协议与框布局协议的结构相匹配,因为父级将约束传递给子级并返回接收几何。但是,约束和几何数据在两个协议之间不同 ​​。在Slivers协议中,向孩子们提供有关视口的信息,包括剩余的可见空间量。它们返回的几何数据可实现各种滚动链接效果,包括可折叠标题和视差。

不同的条纹以不同的方式填充视口中可用的空间。例如,产生儿童线性列表的Slivers按顺序排列每个孩子,直到Slivers用完儿童或用完空间。类似地,产生二维儿童网格的Slivers仅填充其可见的网格部分。因为他们知道可以看到多少空间,所以即使他们有可能产生无限数量的儿童,也可以产生有限数量的儿童。

可以组合 Slivers 来创建定制的可滚动布局和效果。例如,单个视口可以具有可折叠标题,后跟线性列表,然后是网格。所有三个Slivers都将通过Slivers布局协议进行协作,以仅生成通过视口实际可见的子项,无论这些子项是否属于标题,列表或网格。

按需构建小部件

如果 Flutter 有一个严格的构建 - 然后 - 布局 - 然后 - 绘制管道,前面的内容将不足以实现无限滚动列表,因为通过视口可以看到多少空间的信息仅在布局阶段可用。如果没有额外的机器,布局阶段就太晚了,无法构建填充空间所需的小部件。 Flutter 通过交错管道的构建和布局阶段来解决这个问题。在布局阶段的任何时候,只要这些小部件是当前执行布局的渲染对象的后代,框架就可以开始按需构建新的小部件。

只有在构建和布局算法中对信息传播进行严格控制,才能实现交错构建和布局。具体而言,在构建阶段,信息只能在树下传播。当渲染对象正在执行布局时,布局遍历没有访问该渲染对象下面的子树,这意味着通过在该子树中构建而生成的写入不能使到目前为止已进入布局计算的任何信息无效。类似地,一旦布局从渲染对象返回,在此布局期间将永远不再访问该渲染对象,这意味着后续布局计算生成的任何写入都不会使用于构建渲染对象的子树的信息无效。

此外,线性协调和树形外观对于在滚动期间有效更新元素以及在元素在视口边缘滚动进出视图时修改渲染树至关重要。

API 人体工程学(API Ergonomics)

快速只有在框架能够有效使用时才有意义。为了引导 Flutter 的 API 设计更具可用性,Flutter 已经在开发人员的广泛用户体验研究中反复测试过。这些研究有时会确认已有的设计决策,有时可以帮助指导功能的优先级,有时会改变 API 设计的方向。例如,Flutter 的 API 记录很多;用户体验研究证实了此类文档的价值,但也强调了专门针对示例代码和说明性图表的需求。

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

专门用于匹配开发人员思维模式的 API

Flutter 的 Widget,Element 和 RenderObject 树中节点的基类未定义子模型。这允许每个节点专用于适用于该节点的子模型。

大多数 Widget 对象都有一个子 Widget,因此只显示一个子参数。一些小部件支持任意数量的子节点,并公开带有列表的子参数。有些小部件根本没有任何子节点,并且没有内存,也没有任何参数。同样,RenderObjects 公开特定于其子模型的 API。 RenderImage 是一个叶子节点,没有子节点的概念。 RenderPadding 只占用一个子节点,因此它具有存储单指针的单个指针。 RenderFlex 占用任意数量的子节点并将其作为链表进行管理。

在极少数情况下,使用更复杂的子模型。 RenderTable 渲染对象的构造函数接受一个子数组数组,该类公开控制行数和列数的 getter 和 setter,并且有一些特定的方法用 x,y 坐标替换单个子节点,以添加一行,以提供一个新的子数组数组,并用一个数组和一个列数替换整个子列表。在实现中,对象不像大多数渲染对象那样使用链接列表,而是使用可索引数组。

Chip 小部件和 InputDecoration 对象具有与相关控件上存在的插槽匹配的字段。如果一个通用的子模型将强制语义分层在子列表之上,例如,将第一个子项定义为前缀值,将第二个子项定义为后缀,则专用子模型允许用于替代使用的专用命名属性。

这种灵活性允许这些树中的每个节点以其最常用的角色进行操作。很少想要在表格中插入一个单元格,导致所有其他单元格环绕;同样,很少想要通过索引而不是通过引用从 flex 行中删除子项。

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

专门用于满足开发人员期望的 API 的整体方法不仅适用于儿童模型。

一些相当琐碎的小部件专门存在,以便开发人员在寻找问题的解决方案时会找到它们。一旦知道如何使用 Expanded 小部件和零大小的 SizedBox 子级,就可以轻松地为行或列添加空格,但发现该模式是不必要的,因为搜索空间会揭示 Spacer 小部件,它直接使用 Expanded 和 SizedBox 达到效果。

类似地,通过根本不在构建中包括窗口小部件子树,可以容易地隐藏窗口小部件子树。但是,开发人员通常希望有一个小部件来执行此操作,因此存在可见性小部件以将此模式包装在一个简单的可重用小部件中。

明确的论点(Explicit arguments)

UI 框架往往具有许多属性,因此开发人员很少能够记住每个类的每个构造函数参数的语义含义。由于 Flutter 使用反应范例,因此 Flutter 中的构建方法通常会对构造函数进行多次调用。通过利用 Dart 对命名参数的支持,Flutter 的 API 能够使这些构建方法保持清晰易懂。

此模式扩展到具有多个参数的任何方法,特别是扩展到任何布尔参数,以便方法调用中隔离的 true 或 false 文字始终是自我记录的。此外,为避免通常由 API 中的双重否定引起的混淆,布尔参数和属性始终以正形式命名(例如,enabled:true 而不是 disabled:false)。

摊铺陷阱(Paving over pitfalls)

在 Flutter 框架中的许多地方使用的技术是定义 API,使得不存在错误条件。这样可以避免考虑整个错误类别。

例如,插值函数允许插值的一端或两端为空,而不是将其定义为错误情况:两个空值之间的插值始终为空,并且从空值或空值插值等效于对给定类型插值为零模拟。这意味着不小心将 null 传递给插值函数的开发人员不会遇到错误情况,而是会获得合理的结果。

一个更微妙的例子是 Flex 布局算法。这种布局的概念是赋予 flex 渲染对象的空间在其子节点之间划分,因此 flex 的大小应该是整个可用空间。在原始设计中,提供无限空间会失败:这意味着 flex 应该是无限大小的,无用的布局配置。相反,调整了 API,以便在为 flex 渲染对象分配无限空间时,渲染对象会调整其大小以适应子节点的所需大小,从而减少可能的错误情况数。

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

通常,该方法是为输入域中的所有值定义有效解释。最简单的例子是 Color 构造函数。而不是取四个整数,一个用于红色,一个用于绿色,一个用于蓝色,一个用于 alpha,每个都可能超出范围,默认构造函数采用单个整数值,并定义每个位的含义(对于例如,底部的八位定义红色分量),因此任何输入值都是有效的颜色值。

一个更精细的例子是 paintImage()函数。此函数需要 11 个参数,其中一些具有相当宽的输入域,但它们经过精心设计,大部分彼此正交,因此很少有无效组合。

积极报告错误案例

聆听翻译 并非所有错误条件都可以设计出来。对于那些剩下的,在调试版本中,Flutter 通常会尝试很早地捕获错误并立即报告它们。断言被广泛使用。构造函数参数详细检查完整性。监视生命周期,当检测到不一致时,它们会立即引发异常。

在某些情况下,这是极端的:例如,在运行单元测试时,无论测试正在做什么,每个积极布局的 RenderBox 子类都会检查其内在的大小调整方法是否满足内部大小调整合同。这有助于捕获可能无法执行的 API 中的错误。

抛出异常时,它们包含尽可能多的信息。 Flutter 的一些错误消息会主动探测相关的堆栈跟踪,以确定实际错误的最可能位置。其他人走相关树来确定不良数据的来源。最常见的错误包括详细说明,包括在某些情况下用于避免错误的示例代码或指向其他文档的链接。

响应式范式(Reactive paradigm)

可变的基于树的 API 受二元访问模式的影响:创建树的原始状态通常使用与后续更新完全不同的操作集。 Flutter 的渲染层使用这种范例,因为它是维护持久树的有效方法,这是高效布局和绘画的关键。然而,这意味着与渲染层的直接交互充其量是尴尬的,并且在最坏的情况下容易出错。

Flutter 的小部件层引入了一个使用反应范式来操作底层渲染树的组合机制。此 API 通过将树创建和树变异步骤组合到单个树描述(构建)步骤中抽象出树操作,其中,在每次更改系统状态之后,开发人员描述用户界面的新配置。 framework 计算反映这种新配置所必需的一系列树突变。

插值(Interpolation )

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

例如,假设在状态 S1 中接口由一个圆组成,但在状态 S2 中它由一个正方形组成。如果没有动画机制,状态更改将会有一个不和谐的界面更改。隐式动画允许圆在几帧上平滑平方。

每个可以隐式动画的特征都有一个有状态小部件,它保存输入当前值的记录,并在输入值改变时开始动画序列,在指定的持续时间内从当前值转换为新值。

这是使用 lerp(线性插值)函数使用不可变对象实现的。每个状态(在这种情况下为圆形和方形)表示为不可变对象,配置有适当的设置(颜色,笔触宽度等)并知道如何绘制自身。当在动画期间绘制中间步骤时,将开始和结束值传递给适当的 lerp 函数以及表示动画点的值,其中 0.0 表示开始,1.0 表示结束,函数返回表示中间阶段的第三个不可变对象。

对于圆到边的过渡,lerp 函数将返回一个表示“圆角正方形”的对象,其半径描述为从 t 值导出的分数,使用颜色的 lerp 函数插值的颜色和笔划使用 lerp 函数对双精度进行插值。该对象实现与圆形和正方形相同的界面,然后可以在请求时绘制自己。

这种技术允许状态机制,状态到配置的映射,动画机制,插值机制,以及与如何绘制每个帧完全相互分离有关的特定逻辑。

这种方法广泛适用。在 Flutter 中,可以插入基本类型(如颜色和形状),但也可以使用更精细的类型,例如装饰,TextStyle 或主题。这些通常由可以自己插值的组件构成,并且插入更复杂的对象通常就像递归插值描述复杂对象的所有值一样简单。

某些可插入对象由类层次结构定义。例如,形状由 ShapeBorder 接口表示,并且存在各种形状,包括 BeveledRectangleBorder,BoxBorder,CircleBorder,RoundedRectangleBorder 和 StadiumBorder。单个 lerp 函数不能具有所有可能类型的先验知识,因此接口定义了 lerpFrom 和 lerpTo 方法,静态 lerp 方法遵循这些方法。当被告知从形状 A 插入到形状 B 时,首先询问它是否可以从 A 变形,然后,如果它不能,则向 A 询问它是否可以变为 B.(如果两者都不可能,则该函数返回 A 从 t 小于 0.5 的值,否则返回 B.)

这允许任意扩展类层次结构,后面的添加能够在先前已知的值和它们自身之间进行插值。

在某些情况下,插值本身不能由任何可用类描述,并且定义私有类来描述中间阶段。例如,在 CircleBorder 和 RoundedRectangleBorder 之间进行插值时就是这种情况。

这种机制还有一个额外的优点:它可以处理从中间阶段到新值的插值。例如,在圆形到方形过渡的中途,形状可以再次改变,导致动画需要插入到三角形。只要三角形类可以从圆角方形中间类中获得,就可以无缝地执行转换。

结论(Conclusion)

Flutter 的口号“一切都是小部件”,围绕着构建用户界面,通过组合小部件来构建,小部件又由逐步更基本的小部件组成。这种积极组合的结果是大量的小部件需要精心设计的算法和数据结构才能有效地处理。通过一些额外的设计,这些数据结构还使开发人员可以轻松创建无限滚动列表,以便在可见时按需构建窗口小部件。