RenderingNG 深入探究:BlinkNG

斯特凡·扎格尔
Stefan Zager
克里斯·哈里森
Chris Harrelson

Blink 是指 Chromium 的 Web 平台实现,包含合成之前渲染的所有阶段,最终为合成器提交。如需详细了解闪烁渲染架构,请参阅本系列的上一篇文章

Blink 最初是 WebKit 的一个分支,而 WebKit 本身是 KHTML 的一个分支,其历史可追溯至 1998 年。它包含 Chromium 中一些最古老(也是最关键)的代码,而到 2014 年,它肯定已逐渐被淘汰。同年,我们以 BlinkNG 的名义开展了一系列雄心勃勃的项目,旨在解决 Blink 代码组织和结构长期存在的不足。本文将探讨 BlinkNG 及其组成项目:我们为何开展这些项目、他们取得的成就、影响其设计的指导原则,以及他们能够在未来进行改进的契机。

BlinkNG 前后的渲染管道。

渲染 NG 之前

Blink 中的渲染管道在概念上总是分为多个阶段(样式、布局、绘制等),但抽象的屏障存在泄露隐患。从广义上讲,与渲染相关的数据由长期存在的可变对象组成。这些对象可以(并且随时)被修改,并且通过连续的渲染更新频繁地回收和再利用。无法可靠地回答诸如以下简单问题:

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

这方面的例子有很多,包括:

Style 将基于样式表生成 ComputedStyle;但 ComputedStyle 不可更改;在某些情况下,它会由后续的流水线阶段修改。

Style 将生成一个 LayoutObject 树,然后 layout 使用尺寸和位置信息为这些对象添加注解。在某些情况下,布局甚至会修改树形结构。布局的输入和输出之间没有明显的分隔。

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

在较低层面上,渲染数据类型主要由专门的树(例如 DOM 树、样式树、布局树、绘制属性树)组成;而渲染阶段则以递归树遍历的形式实现。理想情况下,树状路径应包含:在处理给定的树节点时,我们不应访问根位于该节点的子树外部的任何信息。在 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 的系列文章中详细了解旧绘制失效系统的复杂性。

将子像素布局几何图形贴靠到绘制所需的整个像素边界就是一个示例,在这个示例中,我们有多个相同功能的实现,并且执行了很多多余的工作。绘制系统使用了一个像素贴靠代码路径,而当我们需要动态即时计算绘制代码之外的像素贴靠坐标时,会使用完全独立的代码路径。毫无疑问,每个实现都有自己的错误,并且它们的结果并非总是一成不变。由于没有缓存此信息,因此系统有时可能会重复执行完全相同的计算 - 这会给性能带来另一个压力。

以下是一些重要项目,消除了绘制前渲染阶段的架构缺陷。

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:Fix 等特征的全部复杂性的显现,这些关系很快就开始分崩离析。层层次结构增加了额外的非局部指针,表示层的“滚动父项”或“裁剪父项”,而不久之后,我们很难理解代码。

属性树通过将内容的溢出滚动和裁剪方面与所有其他视觉效果分开表示,从而解决了这个问题。这使系统能够正确模拟网站的真实视觉和滚动结构。接下来,我们所要做的就是在属性树之上实现算法,例如合成层的屏幕空间转换,或确定哪些层滚动,哪些没有滚动。

事实上,我们很快发现代码中还有许多其他地方引发了类似的几何问题。(关键数据结构博文提供了更完整的列表。)其中有几个代码存在与合成器代码相同的行为的重复实现;所有 bug 都有不同的子集;它们都没有正确建模真实的网站结构。然后,解决方案变得很明确:将所有几何算法集中到一个位置,并重构所有代码以使用它。

这些算法都依赖于属性树,因此属性树是 RenderingNG 的关键数据结构(即在整个流水线中使用的一种数据结构)。因此,为了实现集中几何代码这一目标,我们需要在流水线的早期阶段(在渲染前)中引入属性树的概念,并对现在依赖属性树的所有 API 进行更改,要求先运行预绘制,然后才能执行这些 API。

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

涂漆后合成:流水线绘制和合成

分层是弄清楚哪些 DOM 内容进入自己的合成层(而合成层又表示 GPU 纹理)。在 RenderingNG 之前,分层是在绘制之前(而不是之后)运行的(如需了解当前流水线,请参阅此处 - 请注意顺序的变化)。我们会先确定 DOM 的哪些部分进入哪个合成层,然后再绘制这些纹理的显示列表。当然,这些决定取决于多种因素,例如哪些 DOM 元素正在添加动画效果或正在滚动,哪些元素进行了 3D 转换,以及哪些元素绘制在哪些元素之上。

这导致了重大问题,因为或多或少地要求代码中包含循环依赖项,而这对于渲染管道来说是一个大问题。让我们通过一个示例来了解原因。假设我们需要使绘制失效(也就是说,我们需要重新绘制显示列表,然后再次对其进行光栅化)。invalidate导致需要使能失效的原因可能是 DOM 中的变化,或者样式或布局的变化。当然,我们只想对实际已更改的部分无效。这意味着要找出受影响的合成层,然后使这些层的部分或全部显示列表失效。

这意味着失效操作取决于 DOM、样式、布局和过去的分层决策(过去表示的对前一渲染帧的意义)。但目前的分层还依赖于所有这些因素。由于我们并非所有分层数据都有两个副本,因此很难区分过去和未来的分层决策。我们最终编写了很多采用循环推理的代码。如果我们不够小心,这有时会造成代码逻辑不正常或不正确,甚至会导致崩溃或安全问题。

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

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

“Composite After Paint”项目的第二个原因是出现基本合成 bug。指出这种错误的一种方式是,DOM 元素并不能很好地代表网页内容的有效或完整分层方案。由于合成是在绘制之前进行,它或多或少地依赖于 DOM 元素,而不是显示列表或属性树。这与我们引入属性树的原因非常相似,并且与属性树一样,如果您确定了正确的流水线阶段,在正确的时间运行该解决方案,并为其提供正确的关键数据结构,该解决方案将直接发挥作用。与属性树一样,这是一个很好的机会,可以保证绘制阶段完成后,其输出在所有后续流水线阶段中都是不可变的。

优势

如您所见,定义明确的渲染管道可以带来巨大的长期优势。远不止你想象的那么多:

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

案例研究:容器查询

容器查询是一项备受期待的网络平台功能(多年来,它一直是 CSS 开发者呼声最高的功能)。既然如此,为什么还不存在呢?其原因在于,在实现容器查询时,需要非常仔细地了解和控制样式与布局代码之间的关系。我们来详细了解一下。

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

如何解决此问题?这难道不是后向流水线依赖项,也就是说,Composite After Paint 等项目的解决相同问题吗?更糟糕的是,如果新样式更改了祖先实体的大小,该怎么办?这样不会导致无限循环吗?

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

但实际上,这还不够,有必要引入比单纯的规模控制更弱的密封类型。这是因为,通常希望容器查询容器能够根据其内嵌尺寸仅朝一个方向(通常为块)调整大小。因此添加了内嵌大小包含 (inline) 大小的概念。但从该部分的那篇长篇笔记中可以看出,在很长一段时间里,我们都无法清楚确定是否可以包含内嵌大小。

用抽象规范语言来描述包含情况是一回事,正确实现却是另一回事。回想一下,BlinkNG 的目标之一是将遏制原则引入构成渲染主逻辑的树状路径:遍历子树时,不需要从子树外部获取任何信息。实际上,如果呈现代码符合遏制原则,那么实现 CSS 包含会更加干净,也更容易实现(这并不完全是偶然的)。

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

此处显示的渲染管道实际上比当前的 RenderingNG 实现略早一些。该图显示分层不在主线程上,而目前仍在主线程上。不过,现在这只是时间问题,既然 Composite After Paint 已发货,而分层是在绘制之后完成。

为了理解为什么这很重要,以及它可能会引领其他地方,我们需要从更高的角度考虑渲染引擎的架构。提升 Chromium 性能最持久的障碍之一是,渲染程序的主线程同时处理主要的应用逻辑(即正在运行的脚本)和大部分渲染。因此,主线程经常饱和工作量,而主线程拥塞往往是整个浏览器的瓶颈。

幸好,不一定如此!Chromium 架构的这一方面可以追溯到 KHTML 天,当时单线程执行是主流编程模型。到多核处理器在消费级设备中普及之后,单线程假设已完全融入 Blink(以前称为 WebKit)中。长期以来,我们一直希望为渲染引擎引入更多的线程处理,但在旧系统中这绝对是不可能的。渲染 NG 的主要目标之一就是探寻如何脱离这个漏洞,使我们能够将部分或全部渲染工作移至另一个线程(或多个线程)。

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

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

在分支中等待的过程是主线程外合成,目的是通过将分层从主线程移到工作器线程,使渲染引擎与插图相匹配。与非阻塞提交一样,这会减少主线程的渲染工作负载,从而减少主线程上的拥塞。如果没有 Composite After Paint 进行架构方面的改进,这样的项目不可能实现。

流水线中还有更多项目(双关语意)!我们终于有了基础,让尝试重新分配渲染工作成为可能。我们迫不及待地想看到可能性!