RenderingNG 深入探究:LayoutNG

伊恩·基尔帕特里克
Ian Kilpatrick
石光治
石光治

我是 Ian Kilpatrick, 和我和 Koji Ishii 一起在 Blink 布局团队担任工程主管。 在加入 Blink 团队之前,我是一名前端工程师(在 Google 担任“前端工程师”之前),负责在 Google 文档、云端硬盘和 Gmail 中构建功能。担任此职位大约五年后,我冒着很大的胆量切换到了 Blink 团队,在工作中有效地学习了 C++,并尝试加深对庞大复杂的 Blink 代码库的学习。 即使在今天,我也只能了解其中的一小部分。 非常感谢您在此期间的宝贵时间。 许多“前端工程师补救”的经历让许多“浏览器工程师”转变为“浏览器工程师”,这让我感到非常高兴。

在 Blink 团队工作时,我曾以个人的经验为指导。 作为前端工程师,我经常会遇到浏览器不一致、性能问题、渲染错误和功能缺失问题。LayoutNG 让我有机会在 Blink 的布局系统中系统地解决这些问题,它代表着许多工程师多年来付出的努力。

在这篇博文中,我将解释诸如此类的大规模架构更改如何减少和减少各种类型的 bug 和性能问题。

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

之前,Blink 的布局树是我所说的“可变树”。

如以下文本所述显示树。

布局树中的每个对象都包含输入信息(例如父项施加的可用尺寸、任何浮点数的位置)和输出信息(例如对象的最终宽度和高度或其 x 和 y 位置)。

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

我们发现此架构导致了许多类别的问题,下面将对其进行介绍。 但首先,让我们回过头来考虑一下布局的输入和输出是什么。

在此树中的节点上运行布局从概念上讲,会采用“样式加 DOM”以及父布局系统(网格、块或弹性)的任何父约束条件,然后运行布局约束算法并生成一个结果。

之前介绍的概念模型。

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

fragment 树。

我之前介绍过不可变 fragment 树,描述了它如何能够将先前树的大部分内容重复用于增量布局。

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

此外,还重写了内嵌(文本)布局算法,以匹配新的不可变架构。 它不仅能为内嵌布局生成不可变的扁平列表表示法,还提供段落级缓存(加快重新布局速度)、逐段调整字体功能(以便在元素和字词中应用字体功能)、使用 ICU 的新 Unicode 双向算法、大量正确性修复等。

布局 bug 的类型

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

正确性

当我们考虑呈现系统中的 bug 时,我们通常会考虑正确性,例如:“浏览器 A 有 X 行为,浏览器 B 有 Y 行为”,或者“浏览器 A 和浏览器 B 都无法正常使用”。 以前,我们就是在这上面花费大量时间,在此过程中,我们不断与系统作斗争。 一种常见的故障模式是对某个 bug 进行非常有针对性的修复,但在几周后发现我们导致了系统的另一个(看似不相关的)部分回归。

之前的博文所述,这是一个系统非常脆弱的迹象。特别是在布局方面,所有类之间都没有明确的约定,这会导致浏览器工程师依赖于不应该依赖的状态,或错误地解读来自系统其他部分的某个值。

例如,在一年多的时间里,我们有大约 10 个与 Flex 布局相关的链条。 每次修复都会导致部分系统存在正确性或性能问题,进而导致另一个 bug。

现在 LayoutNG 明确定义了布局系统中所有组件之间的协定,我们发现可以更加放心地应用更改。 此外,出色的 Web 平台测试 (WPT) 项目也让多方都能为一个通用的 Web 测试套件做出贡献。

今天,我们发现,如果我们在稳定渠道上发布真实回归,它通常在 WPT 代码库中没有相关测试,而且不是因为对组件协定的误解。此外,根据 bug 修复政策,我们始终会添加新的 WPT 测试,以帮助确保浏览器不会再出现同样的错误。

无效化

如果您曾经遇到过一个神秘的 bug:调整浏览器窗口大小或切换 CSS 属性神奇地导致该 bug 消失了,那您就遇到了失效不足的问题。 实际上,可变树的一部分被视为干净,但由于父约束的某些变化,它并不能代表正确的输出。

这种情况在下文所述的双遍历(遍历布局树两次以确定最终布局状态)布局模式下非常常见。之前的代码如下所示:

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

此类 bug 的修复方法通常如下所示:

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

修复此类问题通常会导致严重的性能下降(请参阅下面的过度失效),并且很难给出正确结果。

目前(如上所述),我们有一个不可变的父约束对象,该对象描述了从父布局到子项的所有输入。我们将其与生成的不可变 Fragment 一起存储。因此,我们集中到一个位置,用于对这两项输入执行 diff(差异比较)操作,以确定子项是否需要再执行一次布局传递。这种 diffing 逻辑比较复杂,但十分完善。 调试这类失效不足的问题通常会导致手动检查两个输入,并确定输入中的哪些更改导致需要另一次布局遍历。

对此差异代码的修复通常很简单,并且易于进行单元测试,因为创建这些独立对象非常简单。

对比固定宽度和百分比宽度的图片。
固定宽度/高度元素并不关心提供给它的可用尺寸是否增加,但基于百分比的宽度/高度会增大。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;
}

迟滞

此类错误类似于无效化错误。 从本质上讲,在之前的系统中,很难确保布局具有幂等性,也就是说,使用相同的输入重新运行布局会产生相同的输出。

在以下示例中,我们只是在两个值之间来回切换 CSS 属性。 不过,这会导致矩形“无限扩大”。

视频和演示显示了 Chrome 92 及更低版本中的迟滞 bug。此问题在 Chrome 93 中已修复。

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

一棵演示上文所述问题的树。
根据之前的布局结果信息,会产生非幂等布局

借助 LayoutNG,由于我们具有显式输入和输出数据结构,并且不允许访问之前的状态,因此我们广泛缓解了布局系统中这类 bug。

过度失效和性能

这与无效化的 bug 类正好相反。 通常,在修复失效不足的 bug 时,我们会触发性能悬崖。

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

两遍布局和性能悬崖的崛起

Flex 和网格布局代表了网络布局表现力的变化。 不过,这些算法与之前的块布局算法根本不同。

块布局(几乎在所有情况下)只需要引擎在其所有子项上只执行一次布局。 这对于提升性能很有帮助,但最终的表现力达不到 Web 开发者的期望。

例如,您通常需要将所有子项的尺寸展开至最大子项的尺寸。为了支持这一点,父布局(弹性或网格)将执行测量遍历以确定每个子项的大小,然后执行布局传递,将所有子项拉伸至此尺寸。此行为是 flex 和网格布局的默认设置。

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

这些双遍布局最初在性能方面是可接受的,因为用户通常没有深层嵌套它们。 然而,随着内容的复杂程度不断涌现,我们开始发现显著的性能问题。 如果您不缓存测量阶段的结果,布局树会在其 measure 状态和最终 layout 状态之间抖动。

图片说明中介绍的 1、2 和 3 波布局。
在上图中,我们有三个 <div> 元素。 简单的一遍布局(例如代码块布局)会访问三个布局节点(复杂度 O(n))。 不过,对于双遍历布局(如 flex 或网格),在此示例中,这可能会导致 O(2n) 访问变得复杂。
显示布局时间指数增长的图表。
此图片和演示显示了采用网格布局的指数布局。此问题在 Chrome 93 中因将 Grid 移至新架构而得以修复

之前,我们会尝试向 flex 和网格布局添加非常具体的缓存,以应对此类性能悬崖问题。 这种方法行之有效(我们在使用 Flex 时已经取得了很大的进步),但一直在与无效性 bug 作斗争。

我们可以通过 LayoutNG 为布局的输入和输出创建显式数据结构,在此基础上,我们构建了测量和布局传递的缓存。这会将复杂性带回 O(n),从而为 Web 开发者带来可预测的线性性能。 如果出现布局执行三遍布局的情况,我们也会直接缓存相应传递。 这可能会带来机遇,让我们有机会在未来安全地引入更高级的布局模式,这个示例展示了 RenderingNG 从根本上实现可扩展性的途径。 在某些情况下,网格布局可能需要三遍布局,但目前这非常罕见。

我们发现,当开发者遇到特定于布局的性能问题时,通常是由于指数级布局时间 bug 而不是流水线布局阶段的原始吞吐量导致的。如果细微的增量更改(一个元素更改单个 CSS 属性)导致布局耗时 50-100 毫秒,这很可能是一个指数级布局 bug。

总结

布局是一个非常复杂的领域,我们没有涵盖各种有趣的细节,例如内嵌布局优化(真正说明整个内嵌和文本子系统的工作原理),甚至这里讨论的概念都只是触手可及,并未做太多细节。 不过,我们希望我们已经展示了系统性地改进系统架构如何带来长期的巨大收益。

尽管如此,我们知道我们仍然还有许多工作要做。 我们了解正在努力解决的各类问题(性能和正确性),并对 CSS 中引入的新布局功能感到非常兴奋。我们相信 LayoutNG 的架构可以安全且易于解决这些问题。

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