RenderingNG 深入探究:BlinkNG

Stefan Zager
Stefan Zager
Chris Harrelson
Chris Harrelson

Blink 是指 Chromium 的网络平台实现,涵盖了合成之前的所有呈现阶段,最终阶段是合成器提交。您可以阅读本系列的上一篇文章,详细了解闪烁渲染架构。

Blink 最初是 WebKit 的一个分支,而 WebKit 本身是 KHTML 的一个分支,其历史可追溯至 1998 年。它包含 Chromium 中一些最古老(也是最关键)的代码,到了 2014 年,它就已经开始显现其存在时间了。同年,我们在名为 BlinkNG 的旗下开展了一系列雄心勃勃的项目,目标是解决 Blink 代码的组织和结构方面的长期缺陷。本文将探讨 BlinkNG 及其组成项目:我们为何要构建它们、他们取得的成就、影响其设计的指导原则,以及它们未来可以改进的机会。

BlinkNG 前后的渲染管道。

正在渲染 NG 之前的版本

Blink 中的渲染管道在概念上总是分成阶段(stylelayoutpaint 等),但抽象屏障是渗漏的。从广义上讲,与渲染相关的数据由长期存在的可变对象组成。这些对象可以随时修改,并且经常被连续的渲染更新回收和重复使用。无法可靠地回答诸如以下简单问题:

  • 样式、布局或绘制的输出是否需要更新?
  • 这些数据何时才能得到“最终”值?
  • 何时可以修改这些数据?
  • 何时删除此对象?

这方面的例子有很多:

Style 会根据样式表生成 ComputedStyle;但 ComputedStyle 不是不可变的;在某些情况下,它会在后续流水线阶段中进行修改。

Style 会生成 LayoutObject 树,然后 layout 会使用大小和定位信息对这些对象进行注解。在某些情况下,layout 甚至会修改树结构。布局的输入和输出之间没有明确分离。

Style 将生成用于确定合成过程的配件数据结构,并在 style 之后的每个阶段就地修改这些数据结构。

在较低级别,呈现数据类型主要由专用树(例如 DOM 树、样式树、布局树、绘制属性树)组成;和渲染阶段以递归树遍历的形式实现。理想情况下,树漫步应该包含:在处理给定的树节点时,我们不应访问位于该节点的子树之外的任何信息。在 pre-RenderingNG 上从来不是这样的;树状走步会从正在处理的节点的祖先实体中经常访问的信息。这使得系统变得非常脆弱且容易出错。此外,除了树根之外,不可能从任何其他地方开始走树。

最后,渲染管道中有许多入口散布在整个代码中:由 JavaScript 触发的强制布局、文档加载期间触发的部分更新、为事件定位做准备的强制更新、显示系统请求的计划更新以及仅向测试代码公开的专用 API 等等。甚至还有一些递归和可重入的路径(即,从一个阶段的中间跳到另一个阶段的开头)。每个入口坡道都有自己的独特行为,在某些情况下,渲染的输出将取决于触发渲染更新的方式。

我们做出的更改

BlinkNG 由许多大大小小的子项目组成,它们的共同目标是消除前面所述的架构缺陷。这两个项目有一些相同的指导原则,旨在使渲染管道更像是真实的管道:

  • 统一入口点:我们应始终从流水线的起点进入。
  • 功能阶段:每个阶段都应具有明确定义的输入和输出,并且其行为应该是“函数式”的,即确定性和可重复性,输出应仅依赖于定义的输入。
  • 常量输入:在阶段运行时,任何阶段的输入都应始终保持恒定。
  • 不可变输出:阶段结束后,其输出在呈现更新的其余部分期间应该是不变的。
  • 检查点一致性:在每个阶段结束时,到目前为止生成的渲染数据应保持自洽的状态。
  • 工作重复信息:每个事物只计算一次。

完整的 BlinkNG 子项目列表会让阅读变得很乏味,但以下是一些特别的结果。

文档生命周期

DocumentLifecycle 类通过渲染管道跟踪进度。它允许我们执行基本检查,以强制执行前面列出的不变量,例如:

  • 如果我们要修改 ComputedStyle 属性,则文档生命周期必须为 kInStyleRecalc
  • 如果 DocumentLifecycle 状态为 kStyleClean 或更高,则 NeedsStyleRecalc() 必须对任何连接的节点返回 false
  • 进入 Paint 生命周期阶段时,生命周期状态必须为 kPrePaintClean

在实现 BlinkNG 的过程中,我们系统地消除了违反这些不变量的代码路径,并在整个代码中添加更多断言,以确保不会回归。

如果您曾深入探索低层级渲染代码,您可能会问自己:“我是如何到达这里的?”如前所述,渲染管道有多个入口点。之前,这包括递归和可重入调用路径,以及在中间阶段(而不是从头开始)进入管道的位置。在 BlinkNG 过程中,我们分析了这些调用路径,并确定它们都可以简化为两种基本场景:

  • 所有呈现数据都需要进行更新,例如,生成用于显示的新像素或针对事件定位运行点击测试时。
  • 我们需要特定查询的最新值,该值无需更新所有呈现数据即可得到解答。这包括大多数 JavaScript 查询,例如 node.offsetTop

现在只有两个进入渲染管道的入口点,与这两种场景相对应。可重入代码路径已被移除或重构,并且不能再从中间阶段开始进入流水线。这消除了渲染更新的确切时间和方式的迷惑,从而简化了系统行为的推理。

流水线样式、布局和预绘制

总体而言,绘制之前的渲染阶段负责:

  • 运行样式级联算法,计算 DOM 节点的最终样式属性。
  • 生成表示文档的框层次结构的布局树。
  • 确定所有框的大小和位置信息。
  • 将子像素几何图形舍入或贴靠到整个像素边界以进行绘制。
  • 确定合成层的属性(仿射转换、滤镜、不透明度或任何可以进行 GPU 加速的属性)。
  • 确定自上一个绘制阶段以来发生了更改,并且需要绘制或重新绘制的内容(绘制失效)。

此列表没有改变,但在 BlinkNG 推出之前,这项工作的大部分工作都是通过临时方式完成的,涉及多个渲染阶段,存在大量重复的功能和内置的低效问题。例如,style 阶段一直主要负责计算节点的最终样式属性,但在一些特殊情况下,我们要等到 style 阶段完成后才会确定最终的样式属性值。在渲染过程中,没有正式或强制的点,我们可以确定样式信息完整且不可变。

BlinkNG 之前出现的另一个问题是绘制失效的另一个好例子。以前,在绘制之前的所有渲染阶段中都散布着绘制失效操作。在修改样式或布局代码时,很难知道需要对绘制失效逻辑进行哪些更改,并且很容易犯错,进而导致失效或过度失效的 bug。您可以在这篇专门介绍 LayoutNG 的系列文章中详细了解旧版绘制失效系统带来的复杂性。

例如,将子像素布局几何图形贴靠整个像素边界以进行绘制就是一个例子,说明我们多次实现相同的功能,并执行了很多冗余工作。绘制系统使用一个像素贴靠代码路径,而当我们需要在绘制代码之外一次性实时计算像素贴靠坐标时,会使用完全独立的代码路径。毋庸多言,每个实现都有自己的 bug,且结果并非总是一致。由于没有这类信息的缓存,系统有时可能会重复执行完全相同的计算,这又给性能带来了压力。

下面这些重要项目消除了绘图前渲染阶段的架构缺陷。

Project Squad:打造风格阶段

此项目解决了样式阶段的两个主要缺陷,这导致其无法进行清晰的流水线处理:

样式阶段有两个主要输出:ComputedStyle,包含对 DOM 树运行 CSS 级联算法的结果;以及 LayoutObjects 树,用于为布局阶段建立操作顺序。从概念上讲,应严格在生成布局树之前运行级联算法;但之前,这两项操作是交错的。Project Squad 成功将这两个阶段拆分为不同的有序阶段。

以前,ComputedStyle 并非总是在样式重新计算期间获得其最终值;在几种情况下,ComputedStyle 会在后续流水线阶段中更新。Project Squad 成功重构了这些代码路径,因此在样式阶段结束后,ComputedStyle 绝不会被修改。

LayoutNG:对布局阶段进行流水线处理

这个庞大的项目是 RenderingNG 的基石之一,完全重写了布局渲染阶段。我们不会在本文中说明整个项目,但对于整个 BlinkNG 项目,有几个值得注意的方面:

  • 之前,布局阶段会收到由样式阶段创建的 LayoutObject 树,并使用大小和位置信息对该树进行注解。因此,输入与输出之间并没有完全分离。LayoutNG 引入了 fragment 树,它是布局的主要只读输出,并用作后续渲染阶段的主要输入。
  • LayoutNG 将 containment 属性引入布局:在计算给定 LayoutObject 的大小和位置时,我们不再查看根位于该对象的子树。更新给定对象的布局所需的所有信息均预先计算,并作为只读输入提供给算法。
  • 之前,存在布局算法并非严格正常运行的极端情况:算法的结果取决于之前最近的布局更新。LayoutNG 消除了这些情况。

预绘制阶段

以前,没有正式的绘制前渲染阶段,只有一袋布局后操作。预绘制阶段源于认识到一些相关函数可以最好地实现为在布局完成后系统地遍历布局树;最重要的是:

  • 发布绘制失效操作:当我们拥有不完整的信息时,在布局过程中,很难正确执行绘制失效操作。如果将内容拆分为两个不同的进程,就能更容易正确并非常高效:在样式和布局期间,可以使用简单的布尔标记将内容标记为“可能需要绘制无效化”。在预绘制树运行期间,我们会检查这些标志并根据需要发出失效通知。
  • 生成绘制属性树:稍后会对此过程进行更详细的说明。
  • 计算和记录像素贴靠绘制位置:记录的结果可供绘制阶段以及需要它们的任何下游代码使用,而无需任何冗余计算。

属性树:相同的几何图形

属性树是在 RenderingNG 的早期引入的,用于处理滚动的复杂性,此类滚动在 Web 上的结构不同于所有其他类型的视觉效果。在属性树之前,Chromium 的合成器使用单个“图层”层次结构来表示合成内容的几何关系,但是随着 position:fixed 等特征的完整复杂性显现,这种关系很快就分崩离析。层层次结构中生长了额外的非局部指针,指示“滚动父项”或“剪辑父级”不久后,理解代码就变得非常困难。

属性树通过将内容的溢出滚动和裁剪方面与所有其他视觉效果分开表示,解决了此问题。这使我们能够正确模拟网站真实的视觉和滚动结构。接下来点击“全部”我们必须基于属性树实现算法,例如合成层的屏幕空间转换,或者确定哪些层滚动,哪些没有滚动。

事实上,我们很快注意到,代码中的许多其他地方也出现了类似的几何问题。(关键数据结构博文中有更完整的列表。)其中一些代码重复实现与合成器代码相同的操作;存在一定比例的错误;都没有根据模型估算出的真实网站结构。随后,解决方案就变得清晰起来:将所有几何算法集中到一个位置,并重构所有代码以使用它。

这些算法全都依赖于属性树,这就是属性树是关键数据结构的原因,也就是在 RenderingNG 的整个流水线中使用这种结构。因此,为了实现集中几何代码的目标,我们需要更早地在管道中引入属性树的概念(即在预绘制中),并将目前依赖于属性树的所有 API 更改为要求在执行预绘制之前运行预绘制。

这个故事是 BlinkNG 重构模式的另一个方面:确定关键计算,进行重构以避免重复,并创建明确定义的流水线阶段来创建为它们提供数据结构的数据结构。我们会在所有必要信息都可用的正好点计算属性树;并确保属性树在后续渲染阶段运行时不会发生变化。

颜料后的复合材料:管道涂料和合成

分层是计算出哪些 DOM 内容进入自己的合成层(反过来又表示 GPU 纹理)的过程。在 RenderingNG 之前,分层在绘制之前(而不是之后)运行(请参阅此处了解当前的流水线 - 请注意顺序的更改)。我们首先要确定 DOM 的哪些部分进入哪个合成层,然后才绘制这些纹理的显示列表。当然,具体的决策取决于多种因素,如哪些 DOM 元素设置动画或滚动,哪些 DOM 元素进行了 3D 转换,哪些元素绘制在哪些元素上。

这造成了一些严重问题,因为或多或少需要代码中存在循环依赖关系,这对渲染管道来说是个大问题。让我们通过一个示例来了解原因。假设我们需要使绘制无效(这意味着我们需要重新绘制显示列表,然后重新绘制它)。要求失效的原因可能是 DOM 发生变化,也可能是样式或布局发生了变化。但当然,我们只想让实际已更改的部分失效。这意味着需要找出受影响的合成图层,然后使这些图层的部分或全部显示列表失效。

这意味着失效操作取决于 DOM、样式、布局和以往的分层决策(过去:对上一渲染帧的意义)。但是,当前的分层还取决于所有这些因素。由于我们没有所有分层数据的两个副本,因此很难区分过去和未来的分层决策。因此,我们最终得到了大量具有循环推理的代码。如果我们不够小心,这有时会导致代码不逻辑或不正确,甚至崩溃或安全问题。

为了处理这种情况,我们早些时候引入了 DisableCompositingQueryAsserts 对象的概念。在大多数情况下,如果代码尝试查询过去的分层决策,则会导致断言失败,并在调试模式下导致浏览器崩溃。这有助于我们避免引入新的 bug。每当代码合理需要查询以往的分层决策时,我们都会添加代码,通过分配 DisableCompositingQueryAsserts 对象来允许查询。

我们计划随着时间的推移弃用所有调用点 DisableCompositingQueryAssert 对象,然后声明代码安全且正确。但我们发现,只要在绘制之前进行分层,许多调用实际上是不可能移除的。(我们直到最近才成功将其移除!)这是发现 Composite After Paint 项目的第一个原因。我们了解到,即使您已为某项操作明确定义了流水线阶段,但如果它在流水线中的错误位置,您最终还是会卡住。

Composite After Paint 项目的第二个原因是基础合成 bug。说明此错误的一种方式是,DOM 元素并不能很好地 1:1 表示网页内容的高效或完整分层方案。由于合成是在绘制之前出现的,因此它或多或少本身依赖于 DOM 元素,而不是显示列表或属性树。这与我们引入属性树的原因非常相似,与属性树一样,如果您找出合适的流水线阶段,在合适的时间运行,并提供正确的关键数据结构,解决方案便会直接得到解决。与属性树一样,这也很好地保证了绘制阶段一旦完成,其输出在所有后续流水线阶段中都是不变的。

优势

如您所见,定义明确的渲染管道可带来巨大的长期好处。远不止您想象的这么多:

  • 大大提高了可靠性:这非常简单。使用定义明确、易于理解的接口和更简洁的代码更易于理解、编写和测试。这使其更加可靠。它还使代码更安全、更稳定,崩溃次数和释放后使用 bug 更少。
  • 扩大测试覆盖范围:在 BlinkNG 的使用过程中,我们为套件添加了许多新的测试。这包括对内部构件进行重点验证的单元测试;回归测试,这些测试阻止我们重新引入已修复的旧错误(太多了!);以及对由所有浏览器共同维护的公开网络平台测试套件(用于衡量网站对标准的符合情况)的大量补充。
  • 易于扩展:如果系统分解为清晰的组件,则不必了解任何详细程度的其他组件,即可对当前组件进行改进。这使得每个人都可以更轻松地为渲染代码添加价值,而无需成为深厚的专家,并且也使得推理整个系统的行为变得更加轻松。
  • 性能:优化用意大利面代码编写的算法已足够困难,但在没有此类流水线的情况下,几乎不可能达到更大的效果,例如通用线程滚动和动画用于网站隔离的进程和线程。并行处理可以帮助我们大幅提高性能,但也极其复杂。
  • 退让和遏制:BlinkNG 推出了多项新功能,旨在以新颖的方式运用流水线。例如,如果我们只想在预算过期之前运行渲染管道,该怎么办?或者,针对已知与用户无关的子树跳过渲染?这正是 content-visibility CSS 属性实现的。如何让组件的样式取决于其布局?即容器查询

案例研究:容器查询

容器查询是即将推出的一项备受期待的 Web 平台功能(多年来一直是 CSS 开发者呼声最高的功能)。既然有这样一个很棒的想法,为什么它现在却没有?其原因在于,实现容器查询需要非常仔细地了解和控制样式和布局代码之间的关系。下面我们更详细地了解一下。

容器查询允许应用于元素的样式取决于祖先的布局尺寸。由于布局尺寸是在布局期间计算的,这意味着我们需要在布局后运行样式重新计算;但 style recalc 会在布局之前运行!这种先有鸡蛋矛盾的情况是我们无法在 BlinkNG 推出之前实现容器查询的完整原因。

如何解决此问题?它不是向后流水线依赖项(即 Composite After Paint 等项目所解决的同样问题)吗?更糟糕的是,如果新样式更改了祖先实体的大小,该怎么办?这种情况有时会导致无限循环吗?

原则上,循环依赖关系可以通过使用 include CSS 属性来解决,该属性使得在元素外部呈现不依赖于该元素的子树内的呈现。这意味着容器应用的新样式不会影响容器的大小,因为容器查询需要包含

但实际上,这还不够,我们还需要引入比大小遏制更弱的遏制类型。这是因为通常希望容器查询容器能够根据内嵌尺寸,仅在一个方向上调整大小(通常是块)。因此,添加了“内嵌大小包含”的概念。但从该部分的很长的备注可以看出,很长一段时间内,我们完全不清楚是否可以使用内联大小包含。

在抽象规范语言中描述包含是一回事,而正确实现则是另一回事。回想一下,BlinkNG 的一个目标是将遏制原则引入到构成主要渲染逻辑的树游走中:在遍历子树时,不需要从子树外部获取任何信息。然而,如果呈现代码符合包含原则,那么实现 CSS 包含会更清晰且更容易(当然,这并非偶然)。

未来:非主线程合成...等等!

此处显示的渲染管道实际上比当前的 RenderingNG 实现更快。它将分层显示为在主线程之外,而当前它仍在主线程上。然而,完成绘制只是时间问题,现在 Composite After Paint 已推出,分层发生在绘制后。

为了理解这一点为何重要,以及它可能指向其他哪些地方,我们需要从较高的有利位置考虑渲染引擎的架构。在提高 Chromium 性能方面,最持久的阻碍之一是,渲染程序的主线程会同时处理主应用逻辑(即运行脚本)和大部分渲染。因此,主线程经常被工作饱和,而主线程拥塞往往是整个浏览器的瓶颈。

好消息是,情况并非如此!Chromium 架构的这一方面可以追溯到 KHTML 时期,当时单线程执行是主流编程模型。当多核处理器在消费级设备中普及时,单线程假设已完全融入 Blink(以前称为 WebKit)中。很长时间以来,我们一直想在渲染引擎中引入更多线程处理,但这在旧系统中根本无法实现。Rendering NG 的主要目标之一就是从这个漏洞中挖掘出来,将渲染工作部分或全部转移到另一个线程。

现在,BlinkNG 即将完成,我们已经开始探索这个领域;非阻塞提交是第一次尝试更改渲染程序的线程处理模型。Compositor commit(或者简称为 commit)是主线程和合成器线程之间的同步步骤。在提交期间,我们会为在主线程上生成的渲染数据创建副本,以供在合成器线程上运行的下游合成代码使用。进行此同步时,主线程的执行会停止,而复制代码在合成器线程上运行。这样做是为了确保在合成器线程复制数据时,主线程不会修改其渲染数据。

非阻塞提交将消除主线程停止并等待提交阶段结束的需要 - 主线程将继续工作,同时提交在合成器线程上并发运行。非阻塞提交可减少专门在主线程上渲染工作的时间,从而减少主线程上的拥塞,并提升性能。截至撰写此文(2022 年 3 月)之时,我们已经有一个非阻塞提交的有效原型,正准备详细分析其对性能的影响。

后续等待的是 Off-main-thread Compositing(主线程外合成),其目标是将分层从主线程移到工作器线程,使渲染引擎与插图保持一致。与非阻塞提交一样,这会通过减少主线程的渲染工作负载来减少主线程的拥塞。如果没有对 Composite After Paint 进行架构改进,这样的项目根本不可能实现。

我们还在开发更多项目(双关语)!我们终于打下了基础,可以对重新分配渲染工作进行实验,我们非常期待看到可能性!