RenderingNG 深入探究:LayoutNG

Ian Kilpatrick
Ian Kilpatrick
Koji Ishi
Koji Ishi

我是 Ian Kilpatrick 和石井康司 (Koji Ishii) 是 Blink 布局团队的工程主管。 在加入 Blink 团队之前, 我是一名前端工程师(在 Google 担任“前端工程师”之前), 构建功能。 在担任这个职位大约五年后,我大胆地冒险转到 Blink 团队, 在工作中有效学习 C++, 并尝试对超级复杂的 Blink 代码库进行扩容。 即便是今天,我也只能理解其中的一小部分。 我非常感激自己在这个时期付出的宝贵时间。 大量“找回前端工程师”这一事实让我感到安心无忧顺利转型为一名“浏览器工程师”先于我。

我之前在 Blink 团队的工作经历对我个人有重要指导。 作为一名前端工程师,我经常遇到浏览器不一致的问题, 性能问题、呈现错误和功能缺失 让我有机会利用 LayoutNG 在 Blink 的布局系统中系统地解决这些问题, 代表着许多工程师的我们所做的努力。

在本博文中,我将说明像这样的大规模架构变更如何减少和缓解各种类型的 bug 和性能问题。

布局引擎架构的 30,000 英尺视图

之前,我将 Blink 的布局树称为“可变树”。

按照以下文本中的说明显示树。

布局树中的每个对象都包含输入信息, 例如父级规定的可用尺寸 所有浮点数的位置和输出信息, 例如,对象的最终宽度和高度,或其 x 和 y 位置。

在渲染之间保留这些对象。 当样式发生变化时 我们将该对象标记为 dirty,同样在树中将其所有父项标记为 dirty。 在渲染管道的布局阶段运行时, 然后清理结构树,遍历所有脏对象,然后运行布局,使它们保持干净的状态。

我们发现,这种架构导致了许多类别的问题, 我们将在下文中加以介绍 但首先,让我们回过头来考虑一下布局的输入和输出是什么。

从概念上讲,在该树中的某个节点上运行布局需要采用“样式 + DOM”, 以及来自父布局系统(grid、block 或 flex)的任何父约束条件, 运行布局约束算法,并生成一个结果。

之前介绍的概念模型。

我们的新架构正式化了这种概念模型。 我们仍然有布局树,但它主要用于保留布局的输入和输出。 对于输出,我们会生成一个全新的不可变对象,称为“fragment 树”。

fragment 树。

我介绍了 之前不可变的 fragment 树, 描述了如何将其设计为将上一个树的大部分内容重复用于增量布局。

此外,我们还存储生成该 fragment 的父级约束对象。 我们将其用作缓存键,我们将在下文中对此进行详细介绍。

我们还重写了内嵌(文本)布局算法,以匹配新的不可变架构。 它不仅能生成 不可变扁平列表表示法 ,此外还提供段落级缓存以加快重新布局速度, 设置每个段落的形状,以将字体功能应用到各种元素和字词中, 使用 ICU 的全新 Unicode 双向算法,大量正确性修复,等等。

布局 bug 的类型

布局 bug 大体上分为四类, 每种类型都有不同的根本原因。

正确性

我们通常考虑渲染系统中的错误, 例如:“浏览器 A 的行为为 X,而浏览器 B 的行为为 Y”, 或“浏览器 A 和浏览器 B 都损坏”。 以前,这是我们花大量时间完成的 在此过程中,我们一直在与系统作斗争。 一种常见的故障模式是针对一个 bug 进行针对性的修复, 但在几周后,我们发现,我们导致系统另一部分(看似无关的)出现了衰退。

先前的博文中所述, 这表明系统非常脆弱。 具体而言,就布局而言,任何类之间都没有清晰的协定, 导致浏览器工程师不该依赖状态, 或错误解读系统其他部分的某个值。

例如,我们曾在一年多的时间里遇到大约 10 个 bug, 是与 Flex 布局相关的 每次修正都会导致系统的某个部分出现正确性或性能问题, 从而导致另一个 bug。

现在,LayoutNG 明确定义了布局系统中所有组件之间的协定, 我们发现,在实施更改时可以更加放心。 出色的网络平台测试 (WPT) 项目也为我们带来了极大的便利, 这允许多方参与构建同一个网络测试套件。

如今,我们发现,如果我们针对稳定渠道发布真正的回归问题, 它在 WPT 代码库中通常没有关联的测试 而不是因对组件协定的误解而导致的。 此外,作为我们错误修复政策的一部分,我们始终会新增 WPT 测试, 这有助于确保任何浏览器都不应再次犯同样的错误

失效中

如果您曾遇到一个神秘的错误,即调整浏览器窗口的大小或切换 CSS 属性会神奇地消除错误, 你遇到了失效不足的问题 实际上,可变树的一部分被视为干净, 但由于父级限制条件的一些更改,该结果并不能代表正确的输出。

这在二次调用中非常常见 (遍历布局树两次以确定最终布局状态)布局模式,如下所述。 之前,我们的代码如下所示:

if (/* some very complicated statement */) {
  child->ForceLayout();
}

此类错误的修复方法通常如下:

if (/* some very complicated statement */ ||
    /* another very complicated statement */) {
  child->ForceLayout();
}

这类问题的修复通常会导致性能严重下降 (请参阅下面的“过度失效”),并且会非常谨慎地进行更正。

目前(如上所述),我们有一个不可变的父约束对象,该对象描述了从父布局到子布局的所有输入。 我们将其与生成的不可变 fragment 一起存储。 因此 我们有一个集中的位置,用于比较这两个输入,以确定子项是否需要执行另一次布局传递。 此差异逻辑虽然复杂,但易于管理。 对这类无效不足问题进行调试时,通常需要手动检查这两个输入 并决定输入中发生了什么变化,以要求再次传递布局。

修正此差异代码通常很简单, 且易于进行单元测试,因为可以轻松创建这些独立对象。

<ph type="x-smartling-placeholder">
</ph> 固定宽度和百分比宽度的图片比较。 <ph type="x-smartling-placeholder">
</ph> 固定宽度/高度元素不在乎为其给定的可用尺寸是否会增加,但基于百分比的宽度/高度会增加。available-sizeParent Constraints 对象上表示,作为差异算法的一部分,将执行此优化。

上述示例的差异代码如下:

if (width.IsPercent()) {
  if (old_constraints.WidthPercentageSize() 
    != new_constraints.WidthPercentageSize())
   return kNeedsLayout;
}
if (height.IsPercent()) {
  if (old_constraints.HeightPercentageSize() 
    != new_constraints.HeightPercentageSize())
   return kNeedsLayout;
}

迟滞

这类 bug 类似于低失效率。 从本质上讲,在之前的系统中,确保布局具有幂等性非常困难,也就是说, 使用相同的输入重新运行布局,会产生相同的输出。

在以下示例中,我们只需在两个值之间来回切换 CSS 属性。 然而,这样会导致“不断增长”矩形。

<ph type="x-smartling-placeholder">
</ph>
该视频和演示显示了 Chrome 92 及更低版本中存在的迟滞错误。此问题已在 Chrome 93 中修复。

对于之前的可变树, 很容易引入这样的错误 如果代码在错误的时间或阶段读取了对象的大小或位置 (例如,我们没有“清除”以前的尺寸或位置) 我们会立即添加一个细微的迟滞 bug。 由于大多数测试侧重于单个布局和渲染,因此这些 bug 通常不会在测试中出现。 更令人担忧的是,我们知道,要让一些布局模式正常运行,需要一些这种迟缓。 我们遇到过一些错误,需要执行优化来移除布局传递, 但会引入一个“bug”因为布局模式需要两次传递才能获取正确的输出。

<ph type="x-smartling-placeholder">
</ph> 一张树状图,演示了上述文本中描述的问题。 <ph type="x-smartling-placeholder">
</ph> 根据之前的布局结果信息,会导致非幂等布局

有了 LayoutNG,我们就有明确的输入和输出数据结构, 并且访问之前的状态是不允许的,因此我们在布局系统中广泛减少了此类 bug。

过度失效与性能

这与无效失效类别的 bug 完全相反。 通常,在修复无效系统错误的 bug 时,我们会导致性能下降。

我们经常不得不做出艰难的选择,更注重正确性而非效果。 在下一部分中,我们将深入探讨我们如何缓解这些类型的性能问题。

双通道布局和性能悬崖上升

Flex 和网格布局代表了 Web 布局表现力的变化。 不过,这些算法与前代的块布局算法有本质区别。

块布局(几乎在所有情况下)只需要引擎正好对其所有子级执行布局一次。 这对性能非常有帮助,但最终却无法达到 Web 开发者想要的表达方式。

例如: 通常,您希望所有子元素的大小都扩展至最大的子元素。 为了支持这一点,父布局(flex 或 grid) 将执行测量遍历,以确定每个子节点的大小, 然后布局传递,将所有子元素拉伸到此大小。 此行为是 flex 布局和网格布局的默认设置。

两组框,第一组显示测量遍历中框的固有尺寸,第二组在布局时的高度相同。

从性能方面来看,这两种两遍布局最初是可以接受的, 因为人们通常不会做深层嵌套 不过,随着更复杂的内容的出现,我们开始发现显著的性能问题。 如果您没有缓存测量阶段的结果, 布局树会在其测量状态和最终布局状态之间抖动。

<ph type="x-smartling-placeholder">
</ph> 图片说明中解释的一遍、二遍和三遍布局。 <ph type="x-smartling-placeholder">
</ph> 在上图中,我们有三个 <div> 元素。 简单的单通道布局(类似于块布局)将访问三个布局节点(复杂度 O(n))。 不过,对于两遍布局(例如 Flex 布局或网格布局), 这可能会导致本例的访问复杂度达到 O(2n)。
<ph type="x-smartling-placeholder">
</ph> 显示布局时间呈指数级增长的图表。 <ph type="x-smartling-placeholder">
</ph> 此图片和演示展示了采用网格布局的指数布局。将 Grid 移到新架构上后,Chrome 93 中修复了此问题

之前,我们会尝试为 flex 和网格布局添加非常具体的缓存,以应对这种性能悬崖。 这种做法行之有效(而且我们已经取得了 Flex 的成功), 但一直在不断地解决无效错误。

借助 LayoutNG,我们可以为布局的输入和输出创建明确的数据结构, 在此基础上,我们构建了测量和布局传递的缓存。 这会将复杂程度恢复为 O(n), 从而为 Web 开发者带来可预测的线性性能。 如果出现布局执行三遍布局的情况,我们也会直接缓存该遍历。 这为未来安全地引入更高级布局模式提供了机会 - 这个示例展示了 RenderingNG 从根本上 全面提升可扩展性。 在某些情况下,网格布局可能需要三遍布局,但目前极其少见。

我们发现,如果开发者遇到明显与布局相关的性能问题, 通常是由于指数布局时间 bug,而不是流水线布局阶段的原始吞吐量。 如果一个小幅度的增量更改(一个元素更改一个 css 属性)就导致了 50-100 毫秒的布局, 这可能是指数布局错误。

总结

布局是一个非常复杂的领域, 我们没有介绍各种有趣的细节,例如内嵌布局优化 (实际上整个内嵌子系统和文本子系统的工作原理), 甚至这里所讨论的概念也只是冰山一角, 并忽略了许多细节 不过,从长远来看,我们希望我们已经证明了系统地改进系统架构可以如何带来巨大收益。

尽管如此,但我们知道我们还有许多工作要做。 我们了解正在努力解决的问题类别(包括性能和正确性), 并对 CSS 即将推出的新布局功能充满期待。 我们相信 LayoutNG 的架构可让您更轻松、安全地解决这些问题。

Una Kravets 制作的一张图片(你知道哪张图片!)