[译] Chromium 下一代渲染架构(五):LayoutNG

1,998 阅读12分钟

本文是 RenderingNG 系列文章的第五篇:

  1. [译] Chromium 下一代渲染架构(一):RenderingNG

  2. [译] Chromium 下一代渲染架构(二):RenderingNG 架构概述

  3. [译] Chromium 下一代渲染架构(三):关键数据结构

  4. [译] Chromium 下一代渲染架构(四):VideoNG

  5. [译] Chromium 下一代渲染架构(五):LayoutNG

  6. [译] Chromium 下一代渲染架构(六):BlinkNG


我是 Ian Kilpatrick,是 Blink 布局团队的工程主管,跟 Koji lshii 是同事。在加入 Blink 团队之前,我是一名前端工程师(在 Google 设立独立的“前端工程师”角色之前),在 Google Docs、Google Drive 和 Gmail 团队工作。在担任该职位大约五年后,我赌了一把,转投 Blink 团队。我在 Blink 团队中高效地学习了 C++,并开始研究大规模的复杂的 Blink 代码库。即使在今天,我也只了解其中的一小部分。我很感激在这个阶段给我的时间,其实很多“前端工程师”转为“浏览器工程师”比我快。

在 Blink 团队中,我以前作为前端工程师的经验帮助了我。作为一名前端工程师,我经常遇到浏览器不一致、性能问题、渲染 bug 和缺少功能的情况。所以 LayoutNG 对我来说是个机会,我可以系统地解决 Blink 布局系统中的这些问题。LayoutNG 也代表了许多工程师多年来的共同努力。

在这篇文章中,我将解释如何通过大型架构的更改来减少和缓解各种类型的 bug 和性能问题。

布局引擎的宏观架构

以前,Blink 的布局树是“可变树”。

布局树中的每个对象都有输入信息,例如在父对象中的可用大小、浮动的位置。也有输出信息,例如对象的最终宽度和高度、x 和 y 的位置。

一次新的渲染会保留并修改这些对象。当样式发生变化时,我们将这个对象标记为脏对象,并将其所有祖先对象也都标记为脏对象。当渲染流水线的布局阶段运行时,遍历所有脏对象,然后运行布局使它们进入干净状态。

这种架构导致了许多问题,这些问题将在下面进行描述。但首先,让我们先考虑一下布局的输入和输出分别是什么。

在树中的一个节点上运行布局,输入是“样式和 DOM”,以及来自父节点布局系统(Grid、Block 或 Flex)的一些约束,然后运行布局约束算法产生输出。

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

我之前介绍了不可变片段树,描述了它是如何设计的,它让我们可以在增量布局时重用布局树中的大部分节点。

同时,还存储了生成片段树的父约束对象,将其用作缓存键,我们将在下面详细讨论。

为了匹配新的不可变架构,我们也重写了内联(文本)布局算法。它为内联布局生成不可变的 flat list,还有段落级缓存和每个段落的形状 段落级缓存有助于更快地完成布局,而段落的形状有助于为元素和单词应用字体特征。另外,还添加了一个使用 ICU 的 Unicode 双向算法,以及很多针对正确性的修复。

布局 bug 的四种类型

从广义上讲,布局 bug 分为四种不同的类型:正确性、失效不足、滞后、过度失效和性能。每个类型有着不同的根本原因。

正确性

当我们说渲染系统的 bug 时,通常指的都是正确性的问题。例如:“浏览器 A 有 X 行为,而浏览器 B 有 Y 行为”,或者“浏览器 A 和 B 都不对”。以前我们花了很多时间在这上面,过程中一直在与现有系统作斗争。一个常见的故障模式是针对一个 bug 做了非常有针对性的修复,但几周后发现导致了其他看似无关的部分出现了 bug。

如前几篇文章所述,这说明我们的系统非常脆弱。特别是对于布局系统,我们在类之间没有明确的契约,导致浏览器工程师依赖于他们不应该依赖的状态,或者错误地解释了系统另一部分提供的某些值。

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

现在 LayoutNG 清楚地定义了布局系统中所有组件之间的契约,我们发现更改代码更有信心了。我们还从优秀的 Web 平台测试(WPT)项目中受益匪浅,这个项目让多方可以为同一个通用的 Web 测试套件做出贡献。

现在如果我们在稳定通道上发布了一个有回归 bug 的版本,那这个场景通常在 WPT 中没有相关的测试,也不是由于对组件契约的误解造成的。此外,作为我们的 bug 修复政策的一部分,修复 bug 时会同时添加新的 WPT 测试,以确保其他浏览器不会再犯同样的错误。

失效不足

如果你曾经遇到过一个神奇的 bug:只要调整浏览器窗口大小或切换 CSS 属性,bug 就会神奇地消失,那么你就遇到了失效不足的 bug。这种 bug 的原因是,可变树的一部分节点被认为是干净的而没有重新布局,但父约束的一些变化导致这个节点的布局并不正确。

这在下面描述的两次遍历(遍历布局树两次以确定最终布局状态)布局模式中很常见。以前我们的代码看起来像这样:

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

此类 bug 的修复通常是这样:

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

对修复通常会导致严重的性能下降(请参考下面的过度失效),需要非常小心地处理才能修复。

新架构下,我们有一个不可变的父约束对象,它描述了从父布局到子布局的所有输入。我们将这个对象与不可变片段一起存储。这样,我们就有了一个统一的地方来 diff 两次输入,来确定子布局是否需要再执行一次布局。这种 diff 逻辑很复杂,但可以被很好地封装起来。调试此类问题就是手动检查两个输入,确定输入中的哪些内容发生了更改,从而确定是否需要再执行一次布局。

由于创建这些独立的不可变对象是简单的,很容易对 diff 代码进行单元测试,所以 diff 代码的修复通常也比较简单。

如上图,固定宽度/高度元素不关心给定的可用大小是否增加,但是基于百分比的宽度/高度会增加可用大小父约束对象上表示,diff 算法会对这种情况做优化。

上面示例的 diff 代码是:

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

滞后(Hysteresis)

这种 bug 与失效不足是类似的。滞后的本质问题是,在以前的系统中,要确保布局幂等非常困难。布局幂等就是说,使用相同的输入重新运行布局会产生相同的输出。

在下面的示例中,我们只是简单地来回切换 CSS 属性。但是,这会产生一个“无限增长”的矩形。

上面这个视频和这个 demo 显示了 Chrome 92 及更低版本中的滞后 bug,它已在 Chrome 93 中修复。

如果使用之前的可变树,非常容易引入这样的 bug。比如代码在错误的时间或阶段读取了对象的大小或位置(比如没有“清除”先前的大小或位置),就导致了一个很隐藏的滞后 bug。这些 bug 通常不会出现在测试中,因为大多数测试都集中在单个布局和渲染上。更令人担忧的是,有的时候我们甚至需要一些滞后才能使某些布局模式正常工作。但是,我们可能在未来的时候为了优化而删除了其中一次布局遍历,就引入了一个 bug,因为某些布局模式确实需要两次遍历才能获得正确的输出。

上图说明了根据之前的布局结果信息,会导致非幂等的布局。

当使用 LayoutNG 时,我们有了明确的输入和输出数据结构,并且不允许访问之前的状态,所以布局系统中的此类 bug 就得到了大大的缓解。

过度失效和性能

这种类型的 bug 与失效不足正好相反。通常在修复失效不足的 bug 时,会同时触发性能下降。

我们经常不得不做出有利于正确性而不是性能的艰难选择。接下来,我们深入探讨如何缓解这类性能问题。

两次遍历布局兴起和性能问题

Flex 和 Grid 布局代表了 Web 布局的表达能力的提升。然而,这些算法与之前的 Block 布局算法有着根本的不同。

Block 布局(几乎在所有情况下)只需要引擎对所有子节点执行一次布局遍历。这对性能很有好处,但最终却达不到 Web 开发者期待的那种布局表达能力。

例如,你希望所有子节点的大小扩大到最大的大小。为了支持这一点,父布局(Flex 或 Grid)将执行一个度量(measure)遍历来确定每个子节点有多大,然后执行一个布局遍历将所有子节点拉伸到这个大小。这种行为是 Flex 和 Grid 布局的默认行为。

这些两次遍历布局最初在性能方面是可以接受的,因为人们通常不会将它们嵌套得很深。然而,随着更复杂的内容的出现,我们开始看到严重的性能问题。如果不缓存度量阶段的结果,布局树将在度量状态和最终布局状态之间来回切换。

在上图中,我们有三个<div>元素。一个简单的一次性布局(如 Block 布局)将访问三个布局节点(复杂度 O(n))

然而,对于两次遍历布局(如 Flex 或 Grid),这可能会导致此示例变成 O(2n) 的复杂度。

比如,上面这个示例的递归关系是:

  • layout(div) = 2 * layout(child)
  • layout(div) = 2 * 2 * layout(grandchild)
  • layout(div) = 2n

其中,n 是树的深度。如果每个节点的子节点不止两个,会变得更加复杂。

这个图和这个 demo 显示了具有 Grid 布局的指数复杂度。Chrome 93 中已将 Grid 布局迁移到了新架构,修复了此问题。

以前我们的做法是向 Flex 和 Grid 布局添加非常特殊的缓存,以对抗这种类型的性能问题。虽然这行得通,而且我们在 Flex 方面也取得了很大进展,但仍然一直在与失效不足和过度失效的 bug 作斗争。

LayoutNG 允许我们为布局的输入和输出创建显式的数据结构,并且在此之上我们构建了度量和布局的缓存。这将复杂度带回到 O(n),从而为 Web 开发者带来可预期的线性性能。如果有一个布局正在执行三次遍历布局,我们也将缓存这些遍历的结果。这为将来安全地引入更高级的布局模式提供了可能性,这也是 RenderingNG 从根本上解决可扩展性的一个例子。在某些情况下,Grid 布局可能需要三次遍历,但目前非常罕见。

我们发现,当开发者遇到布局性能问题时,通常是由于触发了一个指数时间复杂度的 bug,而不是由于流水线布局阶段的性能不足。如果一个小的增量更改(比如为一个元素更改单个 CSS 属性)导致了 50 - 100 毫秒的布局耗时,这可能是一个指数时间复杂度的布局 bug。

总结

布局是一个非常复杂的领域,我们没有涵盖所有细节,例如内联布局优化(整个内联和文本子系统的工作原理)。甚至这里讨论的概念也只是表面上的,掩盖了许多细节。但是,希望我们已经展示了如何系统地改进系统架构可以带来长期的巨大收益。

我们还有很多工作要做。我们知道了需要努力解决的问题类型(性能和正确性),并对 CSS 的新布局功能感到兴奋。相信 LayoutNG 的架构可以帮助我们更加安全、简单地解决这些问题。

原文链接:developer.chrome.com/blog/layout…