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

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

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

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

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

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

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

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


Blink 是 Chromium 对 web 平台的实现,包含渲染流水线中合成阶段之前的所有渲染阶段。你可以在本系列之前的文章中阅读有关 Blink 渲染架构的更多信息。

Blink 最初是 WebKit 的一个分支,而 Webkit 是 KHTML 的一个分支,KHTML 的历史可以追溯到 1998 年。Chromium 中还有一些最古老的(也是最关键的)代码,到 2014 年的时候这些代码真的已经垂垂老矣。在那一年,我们以 BlinkNG 的名义开展了一系列雄心勃勃的项目,目标是解决 Blink 代码在组织和结构上长期存在的缺陷。本文将探讨 BlinkNG 及其组成项目:我们为什么做这些项目,这些项目取得了什么成就,这些设计遵循什么指导原则,以及这些项目为未来提供了哪些机会。

NG 之前的渲染

Blink 中的渲染流水线在概念上是被划分为一个个阶段(样式、布局、绘制等),但各阶段之间的隔离和解耦是不够的。从广义上讲,与渲染相关的这些数据由生命周期很长的可变对象组成。这些对象可以随时被修改,并且经常在渲染更新中被回收和重用。这导致我们很难回答下面这几个简单的问题:

扫描二维码关注公众号,回复: 14294712 查看本文章
  • 样式、布局或绘制的输出是否需要更新?
  • 这些数据什么时候会得到它们的“最终”值?
  • 什么时候可以修改这些数据?
  • 这个对象什么时候会被删除?

这方面的例子很多,比如:

Style 阶段会根据样式表生成 ComputedStyle;但 ComputedStyle 不是一成不变的;在某些情况下,它会被后面的阶段修改。

Style 阶段也会生成一个 LayoutObject 树,然后布局阶段会为这些 LayoutObject 填入大小和位置信息。在一些情况下,在布局阶段甚至会修改树的结构。布局阶段的输入和输出是没有明确分开的。

Style 阶段还会生成决定合成过程的辅助数据结构 而这些数据结构却在 Style 阶段之后的每个阶段中都被直接修改。

再深入细节看看,渲染的数据类型主要由一些树组成(例如,DOM 树、样式树、布局树、绘制属性树),而渲染阶段则被实现为树的递归遍历。理想情况下,一次树遍历应该是封闭的,也就是说:处理某个给定的节点时,除了以该节点为根的子树,不应该再访问其他任何信息。在 RenderingNG 之前的实现中,从来都不是这种理想情况:一次树遍历会频繁地访问当前节点的祖先信息,这让系统非常脆弱且容易出错。而且,这也导致除了整个树的根节点,我们不可能从任何其他节点开始树遍历。

最后,在整个代码中散布着许多进入渲染流水线的入口,例如:由 JavaScript 触发的强制布局、在文档加载期间触发的部分更新、为确定 Event Target 的强制更新、显示系统请求的计划更新以及仅用于测试的专用 API。甚至还有一些递归和可重入的路径可以进入渲染流水线(即从一个阶段的中间跳转到另一个阶段的开头)。这些入口中的每一个都有自己的特殊行为,这导致在某些情况下,渲染的输出将取决于触发渲染更新的方式。

我们改变了什么

BlinkNG 由许多大大小小的子项目组成,所有这些子项目的共同目标都是消除前面描述的架构缺陷。为了使渲染流水线更像真正的流水线,这些项目共享一套指导原则:

  • 统一的入口点:我们应该始终从流水线的入口开始流水线的处理。
  • 功能阶段:每个阶段都应该有明确定义的输入和输出,其行为应该是函数式的,即确定性和可重复性,输出应该只取决于前面定义的输入。
  • 静态的(Constant)输入:任何阶段的输入在阶段运行时都应该有效地保持不变。
  • 不可变的(Immutable)输出:一旦一个阶段完成,它的输出在渲染的后续过程中应该是不可变的。
  • 检查点一致性:在每个阶段结束时,前面生成的所有渲染数据应该是一致的。
  • 重复计算消除:每件事只计算一次。

BlinkNG 的子项目有很多,为了不让文章变得冗长,下面列出其中一些特别重要的项目。

文档生命周期

DocumentLifecycle 类通过渲染流水线跟踪我们的进度。它让我们可以进行一些基本的检查,用来强制执行前面列出的那些不变性,例如:

  • 如果我们要修改 ComputedStyle 属性,那么生命周期必须是 kInStyleRecalc
  • 如果生命周期状态为 kStyleClean 或更高,则 NeedsStyleRecalc() 必须返回 false。
  • 进入绘制阶段时,生命周期状态必须为 kPrePaintClean

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

如果你曾经看过底层渲染代码,你可能会问自己,“我是怎么到这里的?” 确实如前所述,渲染流水线有多个入口点。以前,这包括递归和可重入的调用,以及在中间的某个阶段进入流水线,而不是从头开始。在实现 BlinkNG 的过程中,我们分析了这些调用路径,并确定它们都可以简化为两个基本场景:

  • 所有的渲染数据都需要更新 —— 例如,生成新的像素来进行显示或为事件定位进行命中测试。
  • 我们需要一个特定查询的最新值,不需要更新所有渲染数据就可以回答。包括大多数 JavaScript 查询,例如 node.offsetTop

现在渲染流水线只有两个入口点,对应这两个场景。可重入代码路径已被删除或重构,并且不再可能从中间阶段开始进入流水线。这消除了许多谜团,我们确切地知道渲染更新会在什么时间以什么方式发生,从而更容易推断系统的行为。

样式、布局和 pre-paint 流水线

总的来说,绘制之前的渲染阶段负责以下内容:

  • 运行样式层叠算法,计算 DOM 节点的最终样式属性。
  • 生成布局树,表示文档的 box 层次结构。
  • 确定所有 box 的大小和位置信息。
  • 将子像素(sub-pixel)级别的几何图形捕捉到整个像素(pixel)的边界用来绘制。
  • 确定合成层的属性(变换、过滤、透明度或任何其他可以被 GPU 加速的东西)。
  • 确定哪些内容自上一次绘制以来发生了变化,需要绘制或重新绘制(绘制失效)。

这个列表在 BlinkNG 的前后没有改变,但是在 BlinkNG 之前,大部分工作都是以一种特别的方式完成的,分布在多个渲染阶段,有很多重复的功能和一些低效的做法。例如,样式阶段主要负责计算节点的最终样式,但也有一些特殊情况下,我们直到样式阶段完成后才能确定最终样式。在渲染过程中没有一个正式的或强制的执行时机,我们可以确定样式信息是完整的和不可变的。

还有一个很好的例子来说明在 BlinkNG 之前遇到的问题,那就是绘制失效。以前,绘制失效遍布在所有的渲染阶段。在修改样式或布局的代码时,很难知道需要对绘制失效的逻辑进行哪些更改,所以很容易引入失效不足或过度失效的 Bug。你可以在本系列专门介绍 LayoutNG 的文章中阅读关于绘制失效系统的复杂性的更多信息。

再看一个案例,将子像素级别的几何图形捕捉到整个像素的边界用来绘制,这是对同一个功能进行了多次实现的一个案例,因此多做了很多冗余工作。绘制系统使用了一套像素捕捉的代码,而当我们需要在绘制代码之外一次性即时计算像素捕捉的坐标时,就会使用另一套完全独立的代码。不用说,每个实现肯定都有自己的 Bug,并且它们的计算结果并不总是一致的。并且因为没有缓存这些信息,系统有时会重复执行完全相同的计算 —— 这是另一个性能优化点。

以下是一些重要的项目,它们消除了在绘制阶段之前的阶段中的架构缺陷。

Project Squad:样式阶段流水线化

这个项目解决了样式阶段的两个主要缺陷,正是这两个缺陷导致样式阶段的流水线不够清晰:

样式阶段有两个主要输出:ComputedStyle 是在 DOM 树上运行 CSS 层叠算法的结果;LayoutObjects 树,它确定了在未来的布局阶段的操作顺序。从概念上讲,运行 CSS 层叠算法应该被严格控制在生成布局树之前发生;但以前这两个操作没有分开,是相互交错的。Project Squad 成功地将它们划分为两个不同的、串行的阶段。

以前,ComputedStyle 在样式重新计算期间并不总是能得到它的最终值;在一些情况下,ComputedStyle 在样式阶段之后的阶段中还被更新了。Project Squad 成功地重构了这些代码路径,因此 ComputedStyle 在样式阶段之后永远不会再被修改。

LayoutNG:布局阶段流水线化

这是个具有里程碑意义的项目 —— RenderingNG 的基石之一 —— 它完全重写了布局渲染阶段。我们不会在这里对整个项目进行评价,但要提几个对整个 BlinkNG 项目有重要贡献的方面:

  • 以前,布局阶段接收由样式阶段创建的 LayoutObject 树,并在其中填充大小和位置信息。因此,输入与输出没有明确的分离。LayoutNG 引入了片段树(fragment tree),它作为布局阶段的主要输出,是只读的,也作为后续渲染阶段的主要输入。
  • LayoutNG 为布局带来了 containment 属性:在计算给定的 LayoutObject 的大小和位置时,我们不再需要查看除了以该对象为根的子树之外的信息。更新布局所需的所有信息都是预先计算好的,作为只读输入提供给算法。
  • 以前,布局算法存在不符合函数式的一些边界 Case,比如:算法的结果取决于最近一次布局更新,而不是仅取决于它的输入。LayoutNG 消除了这些情况。

pre-paint 阶段

之前没有正式的 pre-paint 渲染阶段,只是在布局阶段之后有一堆的操作。pre-paint 阶段源于对一些相关功能的认识,我们发现,如果在布局阶段完成后对布局树进行一次系统的遍历,可以最好地实现这些功能。其中最重要的是这些:

  • 使绘制失效(Issuing paint invalidations) :在布局过程中,我们的信息还不完整,很难正确地使绘制失效。如果将绘制失效提取到布局之后执行,则更容易做到正确,并且非常有效:在样式和布局期间,可以使用简单的布尔标志将内容标记为“可能需要绘制失效”。在 pre-paint 的布局树遍历期间,我们检查这些标志并在必要时使绘制失效。
  • 生成绘制属性树:这个过程在文章后面会进一步详细描述。
  • 计算和记录像素捕捉的绘制位置:绘制阶段可以使用这里记录的结果,这些结果也可以由任何需要它们的下游代码使用,不再需要任何冗余计算。

属性树:一致的几何图形

属性树是在 RenderingNG 早期引入的,用于处理滚动的复杂性。在 Web 上,滚动与所有其他类型的视觉效果相比,都有着不同的结构。在属性树之前,Chromium 的合成器使用单个“层”来表示合成内容的几何关系,但随着 position:fixed 等功能的出现,复杂性变得明显,这种关系很快就崩溃了。而层的层次结构增加了额外的非本地指针(指向外部的指针),这些指针用来指示层的“父级”,不久之后这块代码就很难理解了。

属性树将内容的溢出滚动和裁剪与所有其他视觉效果分开表示,来解决上面的问题。这种方案使得正确建模网站的视觉效果和滚动结构成为可能。接下来,我们要做的“全部”就是在属性树之上实现算法,例如合成层的屏幕空间变换,或者确定哪些层滚动,哪些不滚动。

事实上,我们很快注意到代码中还有许多其他地方提出了类似的几何问题。其中好几个地方实现的功能与合成器代码做的事情相同;这些不同的实现都有各自不同的 Bug;而且他们都没有正确地模拟真实的网站结构。找到了问题,解决方案就变得清晰:将所有几何算法统一在一个地方并重构所有使用它的代码。

这些算法都要依赖于属性树,这就是为什么属性树是 RenderingNG 的关键数据结构(在整个流水线中都被使用)的原因。因此,为了实现统一几何代码的目标,我们需要在流水线中更早地引入属性树的概念 -- 在pre-paint 阶段 -- 并更改现在依赖于这些代码的所有 API,要求在执行这些 API 之前必须先运行 pre-paint。

这个例子代表了 BlinkNG 重构中的一个模式:识别关键计算,重构以避免重复它们,并创建定义良好的流水线阶段来创建合适的数据结构,提供给这些计算。在这个案例中,当所有必要的信息刚好都可用时,我们就计算属性树;并且我们确保在之后的渲染阶段中属性树不会改变。

Composite After Paint:绘制和合成流水线化

分层是确定哪些 DOM 内容进入哪些合成层(最终,它代表 GPU 纹理)的过程。在 RenderingNG 之前,分层在绘制之前运行,而不是之后。我们将首先决定 DOM 的哪些部分进入哪个合成层,然后才为这些纹理绘制显示列表。自然地,这个决定取决于哪些 DOM 元素正在动画或滚动,或具有 3D 变换,以及哪些元素绘制在它上面等因素。

这引起了很大的问题,因为它或多或少地要求代码中存在循环依赖,这对于渲染流水线来说是一个大问题。让我们通过一个例子来看看为什么。假设我们需要使绘制失效(意味着我们需要重新绘制显示列表,然后再次光栅化它)。绘制失效可能来自 DOM 的更改、样式的更改或布局的更改。但当然,我们只想使实际更改的部分失效。这意味着要找出受影响的合成层,然后使这些层的部分或全部显示列表失效。

这意味着失效取决于 DOM、样式、布局和过去的分层决策(过去指前一个渲染帧)。但是当前的分层也取决于所有这些事情。而且由于我们没有所有分层数据的两个副本,因此很难区分过去和未来的分层决策。所以我们最终得到了很多循环推理的代码。如果我们不小心的话,这有时会导致不合逻辑或不正确的代码,甚至崩溃或出现安全问题。

为了应对这种情况,我们很早就引入了 DisableCompositingQueryAsserts 对象的概念。大多数情况下,如果代码尝试查询过去的分层决策,在调试模式下则会导致断言失败并使浏览器崩溃。这有助于我们避免引入新的错误。在代码真的需要查询过去的分层决策时,我们会通过分配一个 DisableCompositingQueryAsserts 对象放入代码以允许此次查询。

我们的计划是,随着时间的推移,处理掉所有调用 DisableCompositingQueryAssert 对象的代码,然后认为代码安全且正确。但我们发现,只要分层发生在绘制之前,许多调用基本上是不可能删除的。这是我们发起 Composite After Paint 项目的第一个原因。我们学到的是,即使你有一个明确定义的操作流水线阶段,如果它在流水线中的错误位置,你最终会卡住。

发起 Composite After Paint 项目的第二个原因是一个底层的合成问题 DOM 元素不是一种很适合的数据结构,用来 1:1 的完整表示网页内容的分层。但由于合成是在绘制之前,所以合成会或多或少地依赖于 DOM 元素,而不是显示列表或属性树。这与我们引入属性树的原因非常相似,找出正确的流水线阶段,在正确的时间运行它,为其提供正确的关键数据结构。还是与引入属性树一样,这是一个很好的机会,可以保证一旦绘制阶段完成,其输出对于所有后续流水线阶段都是不可变的。

好处

如你所见,定义明确的渲染流水线会产生巨大的长期利益。甚至比你想象的还要多:

  • 大大提高了可靠性:这个是显而易见的。简洁的代码和定义良好的接口使它更易于理解、编写和测试,从而更可靠。而且,还使代码更安全、更稳定,崩溃更少,Bug 更少。
  • 更高的测试覆盖率:在 BlinkNG 的过程中,我们添加了许多新的测试。这包括有针对性的内部验证的单元测试;阻止我们重新引入我们已修复的 Bug 的回归测试(太多了!);以及许多对公共的、共同维护的 Web 平台测试套件的补充,所有浏览器都使用这个测试套件来衡量是否正确实现了 Web 标准。
  • 更容易扩展:如果一个系统被分解成一些清晰的组件,则无需了解任何其他组件的细节即可在当前组件上做修改。这使得每个人都可以更轻松地为渲染代码添加价值,而无需成为资深专家。也可以更轻松地理解整个系统的行为。
  • 性能:优化意大利面条一样的代码已经够难了,但如果没有这样定义良好的流水线,几乎不可能实现更大的事情。例如通用的滚动线程化和动画线程化,或用进程和线程来实现站点隔离。并行可以帮助我们极大地提高性能,但也极其复杂。
  • 产出:BlinkNG 也让一些新功能的实现成为可能。比如,以新的方式运行流水线:如果我们只想在时间预算内运行渲染流水线怎么办?或者跳过已知与用户无关的子树的渲染?这就是 content-visibility 这个 CSS 属性的作用。如何让组件的样式取决于它的布局呢?那就是容器查询。

案例研究:容器查询

容器查询是一项备受期待的 Web 平台功能(多年来,它一直是 CSS 开发人员最想要的功能)。如果它那么好,为什么它还不存在?因为容器查询的实现需要非常仔细地理解以及控制样式和布局代码之间的关系。让我们来仔细看看。

容器查询允许元素的样式取决于它祖先的布局大小。而布局大小是在布局期间计算的,这意味着我们需要在布局之后运行样式计算;但是样式的计算是在布局之前运行的!这种先有鸡还是先有蛋的悖论是我们无法在 BlinkNG 之前实现容器查询的全部原因。

我们如何解决这个问题?这是一个流水线的后向依赖,不就是 Composite After Paint 之类的项目解决的同样的问题吗?如果新样式改变了祖先的大小怎么办?这会不会导致无限循环?

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

但实际上,这还不够,有必要引入一种比 size containment 更弱的 containment。这是因为通常我们希望容器查询的容器能够根据其内联尺寸仅在一个方向(通常是 block)上调整大小。因此我们添加了 inline-size containment 的概念。但是正如你从该部分的注释中看到的那样,很长一段时间内都不清楚这种 containment 是否可能。

用抽象的语言描述规范是一回事,正确实现它又是另一回事。回想一下,BlinkNG 的目标之一就是将 containment 原则带到构成渲染主要逻辑的树遍历中:当遍历子树时,不应该需要来自子树外部的信息。如果渲染代码能够遵守 containment 原则,那么实现 CSS containment 会更加清晰和容易。

未来:非主线程合成……甚至更远!

下图中显示的渲染管道实际上比当前 RenderingNG 的实现还提前了一点。它将分层显示为脱离主线程,而目前它仍在主线程上。然而,这只是时间问题,现在 Composite After Paint 已经发布,分层将紧随其后。

为了理解为什么这很重要,以及它可以带我们去向何处,我们需要从更高的角度考虑渲染引擎的架构。提高 Chromium 性能最大的障碍之一就是渲染的主线程需要同时处理应用程序逻辑(即运行脚本)和大量的渲染工作。结果,主线程频繁地发生拥塞,而主线程的拥塞频繁已经成为整个浏览器的性能瓶颈。

好消息是,它并不是必须这样!Chromium 架构的这一方面可以追溯到 KHTML 时代,当时单线程执行还是主流的编程模型。当多核处理器在消费级设备中变得普遍时,单线程假设已经彻底融入 Blink(以前是 WebKit)。长期以来,我们一直想在渲染引擎中引入更多线程,但在之前的系统中这根本是不可能的。RenderingNG 的主要目标之一是把我们自己从这个坑里面解救出来,让部分的渲染工作甚至全部工作都可以转移到另一个线程(或多个线程)。

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

非阻塞提交将消除这一点,主线程不再需要停止并等待提交阶段结束 —— 主线程将继续工作,提交阶段在合成器线程上并发执行。Non-Blocking Commit 的最终效果是减少在主线程上执行渲染工作的时间,这将减少主线程上的拥塞并提高性能。在撰写本文时(2022 年 3 月),我们有一个可以工作的非阻塞提交原型,我们正准备对性能进行详细分析,搞清楚它对性能的影响。

还在等待列表中的是 Off-main-thread Compositing,其目标是通过将分层从主线程移到工作线程上,使渲染引擎与上面那副流水线图相匹配。与非阻塞提交一样,这将减少主线程的渲染工作量,从而减少主线程的拥塞。如果没有 Composite After Paint 这样的架构改进,这种项目是不可能实现的。

还有更多项目正在进行中!我们终于有了一个强大的底座,让重新分配渲染工作成为可能,我们很高兴看看接下来还有什么可能!

原文链接:developer.chrome.com/articles/bl…

猜你喜欢

转载自juejin.im/post/7110877194968104974