浏览器内核渲染:重建引擎

8,386 阅读17分钟

cover

本文系翻译整理的 BlinkOn9 会议演讲内容

演讲资料 视频/ PPT

BlinkOn9 会议中,Google Blink 团队开发者 Philip Rogers 与 Stefan Zager 进行了《Blink Rendering - Rebuilding the Engine Mid-Flight》分享,旨在介绍 Blink 渲染的基本原理与开发团队近期对滚动性能、绘制合成与排版的改进。

第一部分:渲染是什么?

简单来说,渲染是浏览器的某种基础功能,它将你的 HTML 和 CSS 解析成 DOM 树,并将其转换成屏幕上的像素点。

图中显示了 document 生命周期的主要阶段,中间四个黑色框是渲染流水线(render pipeline)。

我一直认为研究 Chrome 的追踪器有助于理解 document 生命周期。因此,下图是一个渲染进程的 Chrome 追踪器面板,图中的高亮区域是渲染主线程,底部的一小部分属于合成器线程(compositor thread)。在渲染的开始,我们可能会处理资源加载,运行 JavaScript,修改 DOM 树等等,其间会有一段空闲阶段,用于处理一般任务。

接下来,就会发生 VSync(垂直同期,Vertical Synchronization)。vsync 是浏览器刚刚将一个满满的像素窗口推到显示器上,并且开始生成下一个像素窗口了。因此对于渲染进程来说,这意味着全员都已做好准备生成新的像素点。

vsync 触发了 BeginMainFrame,这是一个重要方法,它驱动了渲染流水线BeginMainFrame 首先会处理输入事件,如滚动、触屏、手势、鼠标等,然后会运行 requestAnimationFrame 回调。

接下来便是开始执行渲染流水线了,如下图,共有四个步骤:

  • style: 将 DOM 树转化为 layout 树,遍历 layout 树为每一个节点标注其样式信息,然后将带有样式信息的 layout 树传递到下一阶段

  • layout: 我们将再次遍历 layout 树,为节点标注其尺寸、位置信息,至此我们已两次对 layout 树进行标注,然后将它传递给合成阶段

  • composition setup: 在合成设置阶段我们会确定需要绘制多少个合成层(compositing layers),以及它们的尺寸、位置、层叠顺序等

  • paint: 绘制阶段会获取 layout 树的标注以及在合成设置阶段所记录信息,然后创建一个由原始绘图命令组成的“显示列表”,它会指示合成器如何进行像素绘制。

在绘制阶段的结尾,会由主线程切换到合成线程(即下图追踪器中的绿色区域),将光栅化工作切分成几个“瓦片”,分配给几个工作线程来进行。待光栅化完成,我们将进入 Chrome 合成器。这一过程会循环往复地执行下去。

以上便是关于渲染的简单介绍,值得注意的一点是,主线程非常繁忙,所有动作都发生在主线程,脚本在主线程运行,还负责了渲染和许多其它功能,因此主线程是非常拥挤的。经过多年的优化工作,我们发现一个非常有效的优化方式,就是把主线程的工作切分,交给其它线程处理。

第二部分:渲染的重要性与时下的难题

对于 Web 平台来说,渲染是非常重要的。

一是因为,动态网页的本质是接受用户或脚本生成的输入,并将其转化为视觉结果。渲染是这个过程的核心,因此无论你的页面做的有多么酷炫,如果渲染出了问题,用户就不会有任何好的体验。

其二,渲染是网页性能的主要决定因素(感知的和实际的),渲染是无法中断的,如果 JavaScript 运行太久页面就会变得笨重,这当然会引起用户注意。

其三,现代网页是动态的——会不断地修改内容,加载内容,进行动画。为了跟上步伐,保证交互流畅,渲染代码必须是一等公民

下面开始介绍我们在渲染代码中遇到的挑战,以及为了解决这些问题我们正在着手进行的改进。

1. 滚动

正如前文所说,渲染是网页性能的主要决定因素,而滚动体验则是其重中之重。用户对于滚动体验是非常敏感的,滚动的体验决定了其对页面整体性能的感知,如果滚动体验很糟糕,页面再酷炫也拯救不了。Blink 中涉及到滚动的代码巧妙地隐藏在各处,跨越了渲染器中的主线程与合成线程,甚至包括浏览器进程。

回首历史,在 1998 年 KHTML 的原始版本中首次赋予了 document 滚动能力。其后,2003 年 WebKit 中 div 也可以进行滚动了,然而这两种滚动都需要重新触发渲染流水线来进行。起初,这两种滚动的代码是分开编写的,这也没什么大不了的。

然而几年之后,随着对滚动添加了很多功能,做了很多优化,这些关于滚动的代码直接变成了 Blink 中最复杂也最难懂的部分。我们依然维护着这两套滚动代码,所有的功能都要写两遍。不仅如此,由于滚动属于核心代码,实现其它功能也难免要去修改它,复杂度直线上升,越来越难以维护了。

由于目前滚动代码的现状,以及任何功能改动都要写两遍,我们所有开发者的工作都变得很困难,因此,在 2014 年 Steve Kobus 与 Elliott 想到了一个绝妙的主意:通过根层滚动(Root Layer Scrolling)来解决这个问题。

他们决定取消 document 文档级滚动,只使用 overflow 实现所有的滚动功能,这一决定主要是为了降低代码的复杂度,改善代码质量。除此之外还有别的好处,比如,由于两套代码已经分别维护了很长时间,他们的行为表现也并不一致。实际上,文档级滚动行为有明显差异,这是因为文档级滚动与 div 滚动会有一些完全不相关的 Bug,一种滚动有 Bug,另一张滚动可能没有,真是一团糟。

实现根层滚动也是一个漫长艰辛的过程,历经 4 年,终于完成,在 M66 版本交付。

想要大规模改动修改渲染代码的布局部分,第一件事是要通过大约四万五千个布局测试,上图中测试失败次数是由 1500 开始的,事实上,我们刚开始进行修改时,大约有 6000 个测试都失败了。这些测试都需要分门别类,挨个解决,因此在这个过程中我们又顺便解决了很多历史遗留 Bug。

在我们的性能基准测试图中可以发现,在我们刚开展工作时,性能有了一次明显退化,大概退化了 40% 到 50%,随着深入研究这些性能 Bug,我们发现这些是深递归到 CPU 路径的代码,因此我们必须做 CPU 相关优化与 Chrome chromium 部分的代码修改。这是一个非常艰难的过程,要各种不同的代码修复才能让我们真正回到基线性能。

所以我也不得不重申,这块代码真的很难处理,如果我们犯了任何错误,用户都会立即发现,这些错误也会影响所有页面。

接下来我们来了解一下关于绘制与合成我们所做的改进。

2. 绘制与合成

同滚动代码一样,绘制与合成部分的代码也相当古老,大概已经有 16 年了,在当前的代码架构中开发新功能实属不易。现在有机会对这一部分代码进行性能优化,降低内存占用,使得代码易于扩展,便于开发新功能。因此我们开展了一个综合工程项目:绘制代码瘦身。

有必要先从技术方面概述绘制是什么,为什么它如此酷炫,以及我们在整体项目中所处的位置。因此,我们先从前文所提到的滚动是如何工作的开始吧。

在过去,如果我们想进行 div 滚动,我们需要重绘出每一帧。这意味着如果用户一直拖动滚轮,我们就需要生成所有的像素点,用户需要等待我们运行整个渲染流水线后才可以继续移动。

这里有一个惊人的创新叫做合成线程滚动(composited threaded scrolling),其中有两个部分,一个是合成,这很像从电子游戏中获得的灵感,其思想是将整个可滚动区域绘制到一个图像图形缓冲区中,然后并不是每一帧重绘移动区域,而是将一个子纹理复制到不同的纹理中。第二个创新是将滚动操作脱离出主线程,还记得前文提到过的吧,主线程的资源是多么宝贵,此处的基本思想是我们可以在 JavaScript 运行的同时进行滚动。这两件事结合在一起,是一项非常惊人的创新,这种合成线程渲染的思想可以推广到任何需要对纹理进行修改的地方。

比如说,transform,opacity,filter,clip 等等这些都可以通过合成线程思想来实现。当你在软件上运行,用 CPU 绘制像素时,速度很快,但是如果在 GPU 上运行,它的速度更会快成一道闪电。

但是这里有一个叫“老巢爆炸(lair explosion)”的问题。如下图,如果我们将绿盒子使用合成线程进行旋转,它会贯穿蓝盒子。问题是我们需要确认蓝盒子会被绘制在绿盒子之上,因此蓝盒子也会被合成。这种情况会占用相当多的内存。你作为一名前端工程师,在页面上设置了透明度,有可能你就突然发现内存爆炸了,因为页面上其它部分也都被合成了。

下面来介绍一下当下合成器架构体系来阐述合成器是如何工作的,绘制代码瘦身又有什么样的成效。

我们有一个简单的 DOM 树结构,有 emoji 笑脸表情的 div 是可以滚动的。它的生命周期与前文所述的并无二致,因此在排版环节我们将标注 layout 树的尺寸与位置信息,然后便是合成设置环节了,我们重点讲一下。

a、b、d 都不可滚动,所以它们仨可以一起绘制到同一个图形缓冲区中(graphics buffer)。而 emoji 笑脸表情是可以滚动的,我们不想为它的滚动重绘每一帧,因此把它单独放到一个图形缓冲区中。现在我们有了两个图形缓冲区,是时候进行绘制了。

在绘制过程中,我们实际上是遍历 layout 树,记录绘图命令。然后是进行光栅化。

此时我们将执行绘制步骤中所记录的绘图命令,生成真正的像素点。

最终我们将在页面上它们安放到一起,上下滚动 emoji 表情时也不会触发重绘步骤了。

在目前的架构体系下,有两个问题,一是合成仅限于特定子树。layout 树有一个属性,决定我们能否进行合成。并非所有子树都有这个属性,因此我们不能随意将页面上的 div 转换成图形缓冲区,这导致了一个基本性合成 Bug,在 2014 年首次发现。

当时我们试图让 iframe 在任意地方合成,以提高滚动性能,结果发现页面上的内容瞬间都消失了,原因是如果制作了一个合成的 iframe,你还需要确保任何绘制在它上方的内容也是合成的。这是一个在 2014 年发现的毁灭性错误,因为你已经建立了这些特殊的逻辑来不创建过多的图形缓冲区处理诸如此类的事情,结果在游戏的后期发现了一种基本的缺陷,这种缺陷束缚了你的手,这并不是是把你的手绑在一个边缘案例中,这一个可能遇到的情况(Gmail 在进行滚动优化时就遇到了这个问题,优化无法生效),这阻止了我们继续在当前架构中构建。

我们当前合成体系结构的第二个问题是合成设置是在绘制之前完成的。我们在系统早期就创建了图像缓冲区,你需要在绘制步骤中重新计算,所以我们有重复的逻辑,很难描述这个逻辑有多复杂,但是我可以说大约一半的绘制代码是用于这种大小和效果,比如 clip。

除了在绘制之前进行这种合成设置之外,还有一个问题,因为它在主线程上,这意味着任何可能改变绘制对象大小的效果都需要回到主线程。例如,如果你有两个可以合成的盒子,其中一个是可以滚动的,那么在很多情况下你必须假设最坏的情况。你必须假设合成器可以在页面上的任何地方进行,所以你必须为页面上的许多东西创建图像缓冲区,这是我们之前讨论过的老巢爆炸问题,导致了真正的性能问题。

绘制代码瘦身项目改变了我们整个架构中的这两个问题。它改变了我们如何选择合成事物的粒度,这样你就可以合成,将任何效果转换成图像缓冲区,第二是我们将合成设置移动到绘制后。这不仅可以解决基础性合成 Bug,也避免了逻辑重复。

因此,新的合成架构可以在任何边界进行合成,我们已经移动了合成设置应用程序,以释放主线程的压力。这使我们能够对重叠的事物做出精确的合成决定,可以做一些改变主线程外绘制对象大小的事情。

在这个项目的里程碑中,我们已经完成了关于绘制缓存的功能,目前处于 M67,刚刚发布了绘制代码瘦身的 V1.75 版本。在今年(2018)年底,我们将发布 V2 版本,将合成设置移动到绘制后进行。

3. 布局排版

布局有两个主要问题,第一个是 web 平台问题,我们称之为组合问题(The Combinatorial Problem)。我们有大量的 web 标准,并且还在不断添加更多新的标准,同时旧的标准也依然存在,每次我们定义新的 CSS 标准时,它都会创建一组带有与所有现有 CSS 标准的新交互。它们结合的方式有一点奇怪,随之而来有很多的边界 case,让我们以 flexbox 为例看一看:

很简单的三个 flex item 盒子,我们添加几个属性看看布局会发生什么变化。

设置 direction: rtl 会使得布局方向变为从右往左。

在此基础上,添加一个 flex-direction: row-reverse,布局方向又恢复为从左往右了。

direction 属性去掉,从右往左排布。

flex-direction 设置为 columb-reverse,布局改为按列排布。

设置 writing-mode 同时 flex-direction 改为行排布,使得文字方向也发生了改变。

flex-direction 改为反向,依然复合预期。

flex-direction 改为列,也是一样。举例到这里就足够了,以上之所以表现复合预期,是因为我花了三周的时间解决各种 Bug。

在其它内核的浏览器中可就不一定了,如上图,第一个图是以上 flexbox 示例在 chromium 中的表现,第一排第二个浏览器表现也几乎相同,然而第三个第四个可就相去甚远。

我无意 diss 其它浏览器,换个功能示例,可能 chromium 就是表现最差的那一个。我是想强调这个兼容性问题确实存在,复杂的 CSS 特性也在持续堆积。

第二个问题是 Blink 中布局相关的代码是非常远古的,里面充斥着无封装,不可重入,非线程安全的面条式巨石代码

先解释一下巨石代码,这里有一个 layout 树,节点是 layout 对象,假设我们在树下面的一个元素上改变 CSS。元素现在变脏了,需要转发出去。接下来我们要做的是标记整个祖先链,当我们想执行 layout 阶段时,我们总是从树顶开始,一直往下走,现在我们进行了一系列优化,但是优化后的也没有跳过很多步骤。

我们仍然要进行完整的树遍历,这也是耗费资源的,每次我们执行 layout 都会进行遍历。底部节点可能位于一个尺寸固定的盒子里,它甚至可以使用 CSS containment,这是一个新特性,有点类似于浏览器的契约,意味着这个子树不会影响它自身以外的任何东西,子树以外的任何东西也不会影响它。

如果布局这棵子树时我们已经有了所有我们所需要的信息,无需在这个子树之外寻找任何额外的信息来确定大小和位置就好了。然而事实上,我们一直在运行布局代码来获取其他信息。

处于图中这个节点中,如果出于某种原因我们可以跳到树的另一部分吗?不可以,这是一个毁灭性操作。

至于线程安全,还记得最开始我们了解的渲染流水线吧?我们遍历 layout 树,还对它进行标注,然后传递给绘制阶段。当我们完成所有任务准备生成下一帧内容时,会从上次使用的 layout 树开始,根据已改变的内容来更新它。这里是没有什么是线程安全的,可能有多个线程修改它。

对于以上两个问题,相应有两个解决方案。针对组合问题,解决方案是 CSS 定制布局即 Houdini,这意味着可以在元素上设置特定的 CSS 属性,然后定义一个 JavaScript 函数,该函数负责布局该元素及子树。在常规布局过程中,我们会暂停然后去调用 JavaScript 函数,传给它一组布局元素所需要的信息,函数将消费它。这里不会讲太多 Houdini 的细节,大家有兴趣可以自行研究。

针对第二个问题的解决方案是 Layout NG,这实际上是对如何完成布局的全盘反思。Layout NG 有两个特性,一是它使用约束驱动的布局,输入一个子树来进行布局,我们传递给它所有它所需要的在子树中进行布局的信息,而且它根本不看子树的外面。实现这一点也并不容易,通过在中强制封装,我们让底层布局代码更容易实现刚才提到的 CSS 定制布局。第二个特性是,输入(layout 树)与输出(fragment 树)的树都是不可变对象,我们每次都创建一个新的布局树,一旦我们创建了它,该树就不可变了,我们并不是在这个输入树上进行注释,而是复制它,并用新的替换子树来改变子树,我们将拥有布局树的全新副本。

这两个特性的实现将使得布局方面的各种强力优化成为可能。这一项目尚属早期,第一阶段预计在今年年底、明年年初发布。