RenderingNG 深入探究:LayoutNG

Ian Kilpatrick
Ian Kilpatrick
Koji Ishi
Koji Ishi

我是 Blink 布局团队的工程主管 Ian Kilpatrick,Koji Ishii 是我的同事。在加入 Blink 团队之前,我曾是一名前端工程师(在 Google 设立“前端工程师”这一职位之前),负责在 Google 文档、云端硬盘和 Gmail 中构建功能。在该职位上工作了大约五年后,我冒险转到了 Blink 团队,在工作中有效地学习了 C++,并尝试熟悉极其复杂的 Blink 代码库。即使到现在,我也只了解其中的一小部分。感谢您在此期间耐心等待。 令我感到欣慰的是,在我之前,许多“正在转型为前端工程师”的工程师都转型为“浏览器工程师”。

我在 Blink 团队任职期间,曾以往的经验给予了我个人指导。 作为一名前端工程师,我经常遇到浏览器不一致、性能问题、渲染 bug 和缺少功能的问题。通过 LayoutNG,我有机会帮助系统地解决 Blink 布局系统中存在的这些问题,它代表了许多工程师多年来的努力成果。

在本文中,我将介绍这样一项重大架构变更如何减少和缓解各种类型的 bug 和性能问题。

布局引擎架构的宏观视图

以前,Blink 的布局树被称为“可变树”。

显示如下文所述的树。

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

这些对象会在渲染之间保留。当样式发生变化时,我们会将该对象标记为脏,并将树中的所有父级标记为脏。运行渲染流水线的布局阶段后,我们会清理树、遍历所有脏对象,然后运行布局以使其处于清洁状态。

我们发现,这种架构会导致许多类问题,我们将在下文中加以说明。但首先,我们先回过头来考虑一下布局的输入和输出。

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

之前介绍的概念模型。

我们的新架构对此概念模型进行了形式化处理。我们仍然保留布局树,但主要用它来保存布局的输入和输出。 对于输出,我们会生成一个名为 fragment 树的全新不可变对象。

fragment 树。

我之前介绍了不可变 fragment 树,介绍了它是如何设计的,以便重复使用之前树的大部分内容来实现增量布局。

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

我们还重写了内嵌(文本)布局算法,以匹配新的不可变架构。它不仅会为内嵌布局生成不可变的扁平列表表示法,还具有段落级缓存以加快重新布局速度、按段落设置形状以跨元素和字词应用字体功能、使用 ICU 的新 Unicode 双向算法、大量正确性修复等。

布局 bug 的类型

大致而言,布局 bug 分为四类,每类都有不同的根本原因。

正确性

当我们考虑渲染系统中的 bug 时,通常会考虑正确性,例如:“浏览器 A 具有 X 行为,而浏览器 B 具有 Y 行为”,或“浏览器 A 和 B 都已损坏”。以前,我们花了很多时间来解决这个问题,在此过程中,我们一直在与系统斗争。一种常见的失败模式是,针对一个 bug 应用非常有针对性的修复程序,但几周后发现,我们导致了系统的另一个(似乎不相关)部分出现了回归问题。

之前的文章中所述,这表明系统非常脆弱。具体而言,我们在任何类之间都没有明确的合约,这导致浏览器工程师依赖于不应依赖的状态,或者误解系统其他部分的一些值。

例如,在某个时间点,我们在一年多的时间里发现了与 Flex 布局相关的约 10 个 bug 链。每次修复都会导致系统某个部分出现正确性或性能问题,进而导致另一个 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,以确定子项是否需要执行另一个布局传递。此差异比较逻辑很复杂,但井井有条。调试此类失效问题通常会导致手动检查两个输入,并确定输入中发生了哪些变化,以便确定是否需要进行另一次布局传递。

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

比较固定宽度图片和百分比宽度图片。
固定宽度/高度元素不关心为其分配的可用大小是否增加,但基于百分比的宽度/高度会。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 属性。不过,这会导致“无限增长”的矩形。

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

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

一棵树,展示了前文中所述的问题。
根据之前的布局结果信息,会导致非幂等布局

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

过度失效和性能

这与“失效不足”类 bug 完全相反。在修复失效不足 bug 时,我们经常会触发性能断崖。

我们经常不得不做出艰难的选择,以正确性为先,而非性能。在下一部分中,我们将深入探讨如何缓解此类性能问题。

两次绘制布局的兴起和性能下降

Flex 布局和网格布局代表了 Web 上布局表现力的转变。 不过,这些算法与之前的块布局算法有着根本性的不同。

在几乎所有情况下,块布局只要求引擎对其所有子项执行一次布局。这对性能有很大帮助,但最终的效果不如 Web 开发者所希望的那样出色。

例如,您通常希望将所有子项的大小扩展到最大的子项的大小。为了支持此功能,父布局(flex 或 grid)将执行测量传递以确定每个子项的大小,然后执行布局传递以将所有子项拉伸到此大小。这种行为是 flex 布局和网格布局的默认行为。

两组框,第一组显示测量传递中的框的固有尺寸,第二组显示布局时的所有框的高度均相同。

从性能方面来看,这些两次传递的布局最初是可以接受的,因为用户通常不会对其进行深层嵌套。不过,随着内容变得越来越复杂,我们开始遇到严重的性能问题。如果您不缓存测量阶段的结果,布局树将在其测量状态和最终布局状态之间进行大量重复计算。

图注中介绍了单次、两次和三次遍历布局。
在上图中,我们有三个 <div> 元素。简单的单次遍历布局(例如,块布局)将访问三个布局节点(复杂度为 O(n))。 不过,对于两次传递的布局(例如 flex 或 grid),这可能会导致此示例的访问复杂度为 O(2n)。
显示布局时间呈指数级增长的图表。
此图片和演示展示了采用网格布局的对数布局。由于将 Grid 移到了新架构,Chrome 93 中已修复此问题

以前,我们会尝试向 Flex 和网格布局添加非常具体的缓存,以应对此类性能断崖。这种方法虽然可行(我们在 Flex 方面取得了长足进步),但我们一直在与过度和过少失效 bug 作斗争。

借助 LayoutNG,我们可以为布局的输入和输出创建明确的数据结构,此外,我们还构建了测量和布局传递的缓存。这会使复杂性恢复为 O(n),从而为 Web 开发者带来可预测的线性性能。如果布局正在执行三次布局,我们也会直接缓存该传递。这可能会为未来安全地引入更高级的布局模式提供机会,这是一个很好的例子,说明 RenderingNG 如何从根本上解锁全面的可扩展性。在某些情况下,网格布局可能需要三次布局,但目前这种情况非常罕见。

我们发现,当开发者遇到布局方面的问题时,通常是由于指数级布局时间 bug 而非流水线布局阶段的原始吞吐量所致。如果进行小幅增量更改(一个元素更改单个 CSS 属性)导致布局延迟 50-100 毫秒,则很可能是指数级布局 bug。

总结

布局是一个极其复杂的领域,我们并未涵盖各种有趣的细节,例如内嵌布局优化(整个内嵌和文本子系统的实际运作方式),甚至这里讨论的概念也只是皮毛而已,很多细节都没有提及。不过,我们希望已经证明,系统性地改进系统架构可以带来长期的巨大收益。

尽管如此,我们知道自己仍有大量工作要做。 我们知道存在一些问题(包括性能和正确性问题),正在努力解决这些问题,并对 CSS 即将推出的新布局功能感到兴奋。我们认为,LayoutNG 的架构可确保安全地解决这些问题。

一张图片(您知道是哪张!)由 Una Kravets 提供