Chromium 浏览器内核中,来自前端的内容如何在最终转换成为屏幕上的各个像素点,也就 是浏览器内核渲染过程,这是一个复杂的工程上的一系列步骤。对于这些复杂的步骤我们需 要把握的方面包括该过程每个阶段的设计思想、数据模型、数据模型的交互。深入而仔细地 理解上述内容,有利与我们阅读庞大源码中始终保持清醒找准定位。下面我将基于我对 Life of a Pixel 的理解仔细来讨论这个过程。
Life of a Pixel 输入与输出
首先需要谈谈输入与输出,这一系列步骤的输入成为 Web Content。它由一系列现有协议所 定义的一套描述 Web 内容的文本主要组成,当然也包括其他引用内容。现有的协议(我们 常称为编程语言),通常是 HTML、CSS 与 JavaScript。这三者分别有定义了 Web 内容的 结构内容、样式、逻辑。但这并非严格分工,在当下流行的前端设计思想(前后端分离)中 JavaScript 正在承担着越来越多的职责。JavaScript 本身也正在不断地变得独立,近年来 逐渐跳出浏览器这个平台来独立发挥其作用(node.js、react-vr)。 另一方面,谈谈输 出。如何绘制屏幕上的像素,涉及到计算机图形学方面的理论与工程。在传统上,我们可以 认为计算机向屏幕输出内容经历了以下步骤。应用软件将其想要表达的图形内容转换成为对 操作系统与图形相关的函数库的调用(OpenGL、Direct X 等),这些函数库通过操作系统 中安装驱动程序及其他操作系统有关服务向硬件(GPU 等)传送数据与指令,并操纵硬件的 计算核心与存储器(GPU 等)完成光栅化等步骤、最终将硬件(GPU 等)存储器中的最终内 容转化为向屏幕输出的信号。在这方面,由函数库(OpenGL)所提供的 API 都是比较低级 的。 举个例子,原来我也做过 OpenGL 相关的编程,虽然现代 OpenGL 提供一些模型与协 议(如管线等)来简化工作,但是其提供的模型以及依照模型设计的 API 带有明显的硬件 气息。对于 OpenGL 来说,调用者要精确输入预设的或者利用 GPU 程序计算出的数据其所 绘制内容的在几何坐标系下的位置、颜色以及绘制顺序。 由输入输出来大致推断,浏览器 的工作是按照先行前端协议(前端编程语言)及其附属多媒体、数据等方面的内容精确理解 前端开发者对于 Web 页面的描述,并通过数据的层层流动与转换计算推断出图形函数库所 需的各类信息。现行前端协议的复杂性与兼容性与鲁棒性要求使得这一步骤的实现十分复杂 与庞大。而对于性能与稳定的要求更提升了设计与实现上的难度。实现这一系列不仅仅是技 术上,而且也是软件工程上的难题。
页面生命周期综述
由上述对于输入与输出的讨论,我们可以理解 Chromium 团队提出以下有关于页面生命周 期。
- 基于 Web Content 经过若干步骤产生相关数据模型。
- 数据模型随时间、交互等因素的不断更新。
其中,对于第二点要求尽可能少地快速地修改由初由第一步骤产生的数据模型,尽可能降低 计算成本。其原因在于,第一步骤产生数据模型所进行的相关计算与数据模型的交互对于现 有平均性能水平来说依然十分昂贵,所以不断重复执行第一步骤在实际环境中不可取。
初步渲染步骤概论
对于上面提及过的在输入与输出之间的相关步骤,我们将在下面按照前后顺序进行有关论 述。
DOM
对于 HTML 来说,其语法规则有着明显的树状特征。这使得利用 HTML 能够方便地描述出文 档结构并附带部分内容。所以我们需要提取出其中地结构信息与内容信息。在这一步 中,HTML 文档解析器将解析 HTML 文档中的文本并转换其为 DOM 树。 有关于 DOM 树,不 得不提的是 JavaScript 对于 DOM 树的操作能力,我认为这也是 JavaScript 的核心内容 之一。JavaScript 借由该能力能够对页面进行控制、更新与内容添加、更改、删除等操 作,我认为这是前后端分离思想的技术基础。上述能力对 DOM 树的实际操作由 V8 引擎具 体完成,该引擎实现了 JavaScript 的操作 DOM 树的 API,使得 JavaScript 具备该能 力。
CSS(style)
CSS 有着两方面的主要作用,筛选或者指定其作用的 HTML 标签,定义其所对应 HTML 标签 的内容。对于我们来说,其本质在于筛选或者查找出 DOM 节点,并将样式信息与 DOM 节点 对应起来。 其中有个问题需要注意,在 CSS 文件对于某一个或者几个 DOM 节点的样式的 描述中,可能存在着未定义、重复定义、冲突、无效的样式定义。针对这个问题,Chromium 团队引入了重计算(recalc)针对 DOM 树每一个节点计算所有对应的 Computed Style。
Layout
有了上面两个步骤提供的信息,我们需要进一步的转换。将 DOM 节点与 Compute Style 一 道转换为视觉几何结构(Layout)。在这一步中我们需要解决的问题包括文字、表格、布局 等等元素的在页面最终位置、排布及大小。为了能够对这些信息进行有效计算并且整 理,Chromium 构造了 Layout Tree。这个数据结构旨在容纳结构有关信息并且进行上述工 作。 在代码中,以下结构描述了有关信息。
// A LayoutRect contains the information needed to generate a CGRect that may or
// may not be flipped if positioned in RTL or LTR contexts. |boundingWidth| is
// the width of the bounding coordinate space in which the resulting rect will
// be used. |position| is used to describe the location of the resulting frame,
// and |size| is the size of resulting frame.
struct LayoutRect {
CGFloat boundingWidth;
LayoutRectPosition position;
CGSize size;
};
Layout 树与 DOM 树的节点关系并非一对一,有一些情况下 DOM 节点并不需要有其对应的 Layout 对象或者其可以放入其他有关的 Layout 对象中(通常为父节点对应的 Layout 对 象)。其中有一点正在变化的内容需要注意,legacy layout object 与 LayoutNG。LayoutNG 的提出,是为了解决当前的 Layout 对象中输入输出及其他中间内容 混杂并且在计算过程中父子节点相互引用的问题。原先的设计首先带来了节点数据有效性的 确定的问题,正在计算的节点需要判断其引用的数据是其需要的最终状态,不然节点当前所 计算出的数据依然可能要在稍后重新计算,这提升了算法设计的复杂度。与此对 应,LayoutNG 的输入与输出分离,而且输出一旦产生其状态就已经确定且不可以修改,结 构清晰,所以在算法设计上相对简单有效率。
Paint
依据 Layout 树提供的信息,我们可以计算出一些更加基本的信息如坐标系位置、大小、颜 色、绘制顺序等。在 Paint 步骤,我们将 Layout 树转换为一组绘制的操作列表。而在更 新过程中,前一步操作会将 Layout 树拆分为一个个独立的图层,而在该步骤中针对每一个 图层都会生成其独立的绘制操作列表。在该过程期间,我们需要将结构信息转换为绘制的堆 栈顺序,堆栈内部的各个节点是有着相对独立的绘制过程的 Paint Phrase。具体地结合代 码来说,Paint 步骤将产生一组 DrawXXXOp 的数据结构。
Raster
光栅化将绘制信息转换为在内存(通常为现存)中的位图。由上面步骤产生的 DrawXXXOp 数据结构通过 raster 步骤转换为 ImageDecodeCache。其中 raster 步骤并不在渲染进程 中进行,而在 GPU 进程(设计上的概念)中进行。其原因在于该步骤需要产生 GL 调用 (一种在运行时动态绑定到 OpenGL API 地址的代理函数),而由于浏览器的沙箱策略禁止 渲染进程直接进行 GL 调用。这能有效避免恶意代码对实际的 OpenGL API 漏洞的利用,并 且防止 GL 或者驱动程序中的一些缺陷带来的不稳定因素使得渲染进程崩溃,进而降低浏览 器整体的稳定性。在这个过程中,DrawXXXOp 数据结构将由渲染进程传递到 GPU 进程。GPU 进程将进行该数据结构的 DrawRectOpResult()的调用。 DrawRectOpResult 结构操作 SKIA 提供的接口产生进而 GL 调用,相较于 GL 函数组提供的功能,SKIA 能够提供进行一些更 加高级的计算机图形学计算。SKIA 也运用到了 Google 的其他项目中,如 Android。 在此 仍然需要提到 GPU 进程的有关一些要点。GPU 进程能够在崩溃后被浏览器重新启动,这一 切不被用户感知,并且一个 GPU 进程能够服务于多个 Web Content 渲染进程,这也包括 UI 的渲染进程。而在 Windows 平台,GL 调用会最终通过 ANGLE 翻译成 Direct X 调用。 其原因在于 OpenGL 的支持在 Windows 下实际效果(个人推断为性能、支持程度等方面的 因素)并不理想,而 Direct X 虽然在精度上略逊于 OpenGL(专业工业设计软件等大多使 用 OpenGL 调用),但是其在显卡驱动的兼容、性能方面等强于 OpenGL(部分 OpenGL API 在普通显卡的驱动上支持不良甚至不被支持,这也是有昂贵专业显卡硬件及其驱动产品的原 因之一)。值得一提的是,Direct X 就是微软自己发起并不断维护的。
结论说明
初步渲染的结果依然保存在内存(内存、显存)中,这也代表了相关数据结构及其需要的数 据以及被计算或者生成完毕。这些数据结构与其中的数据将运用到下面的更新过程中,作为 数据输入与支撑。
更新步骤概论
由于在涉及范围较大时,Paint 与 Raster 这两部操作十分昂贵。针对浏览器应用,要保证 60 FPS 甚至更高,如果低于这个线用户可能会感受到卡顿影响用户体验。所以更新步骤的 设计思想主要是对尽可能少的数据结构进行 Paint 与 Raster 步骤。而该设计思想下,主 要努力的方向我认为分为以下两点:细化操作粒度、为操作设置优先级来确保用户能看到的 体验。还有一个方面是,由于 JavaScript 的单线程性质,如何在 JavaScript 做昂贵操作 阻塞主线程的情况下,尽可能快的处理一些能利用已有信息当下能够处理的相应,这是一个 难题。更新步骤中设计有关机制与设计思想,比较难以理解。我将在下面的内容中,基于我 的理解论述这一系列的步骤。
合成(compositing)
Chromium 针对上述设计思想与难点引入和 compositing 这个解决方案。在该解决方案下, 页面将被分解成各自独立渲染的图层。这些图层由一个叫做合成线程的线程进行处理并绘 制。在主线程上,表现为将 Layer 树经由 Paint Layer 转换为 cc::Layer,而 cc::Layer 就是上面提到的独立额图层。cc::Layer 也是合成器操作的基本单元。Paint Layer 可以理 解成候选的 cc::Layer,它通过上下文及相应机制进行宣选择、合并与转换。cc::Layer 是 一个列表类型的数据结构,由 Graphics Layer 通过 UpdateAssignmentIfNeeded 进行构 建。以上步骤均位于 compositing assignment 步骤中,但是未来将会计划被移到 Paint 之后,具体原因不明。
// Base class for composited layers. Special layer types are derived from
// this class. Each layer is an independent unit in the compositor, be that
// for transforming or for content. If a layer has content it can be
// transformed efficiently without requiring the content to be recreated.
// Layers form a tree, with each layer having 0 or more children, and a single
// parent (or none at the root). Layers within the tree, other than the root
// layer, are kept alive by that tree relationship, with refpointer ownership
// from parents to children.
class CC\_EXPORT Layer : public base::RefCounted<Layer> {
...
};
Prepaint
该步骤会将一些绘制属性引用到某些图层(cc::Layer)中,绘制属性在这时会与图层绑 定。该步骤的材料所提供的信息不多,但是个人认为可以参考 Photoshop 中的图层模型和 对应绘制属性(黑白、亮度、对比度等等)来推断。日后,针对该方便的研究需要通过额外 的文档或者源代码来进行。
Commit&Tilling
在主线程绘制完成后,会将更新后的数据结构 cc::Layer 列表与属性树与合成线程 impl(也就是上面提到的合成线程)同步。impl 在同步操作后,会将图层需要绘制的部分 提取出来,变成更小粒度的 Tile。而 Tile 是栅格化工作的基本单元,记录了需要被栅格 化的部分在页面中的位置以及绘制步骤等相关信息。Tile 生成后会被放入 Tile 池中,由 栅格化线程根据优先级进行栅格化操作。栅格化优先级是有浏览器视窗距离 Tile 所指定位 置的距离推断的。栅格化线程可能有多个,存在于 GPU 进程中。 在栅格化过程后,Tile 会生成 Quad,而 Quad 是在屏幕特定位置绘制 Tile(已经栅格化完成)的命令。其具体路 径是 Tiles->AppendQuads()->CompositorFrame。而 CompositorFrame 中包含了 DrawQuardList。值 得注意的是,CompositorFrame(合成帧)是渲染进程的输出,也是后 面渲染进程与 GPU 进程之间传递的数据结构。而 Tile 的栅格化操作一般在 GPU 进程中进 行,这样可以获得更好的性能。
Activate&Draw
为了进一步优化 Commit&Tilling 效率,缓解渲染进程与 GPU 线程可能出现的不协同 性,Chromium 在渲染进程的 impl 线程引入了 Pending Tree 与 Active Tree 这两种工作 流。Pending Tree 中接收的的是由主线程提交到 impl 线程的图层(包含图层和属性树) 列表,并适时进行栅格化。Active Tree 中接收栅格化后的图层(包含栅格化结果),并进 行绘制操作。该多工作流模型的引入使得一边 raster 一边 draw 成为了可能,这提高了吞 吐量。
Display
这里需要扩展描述一个模型,在 Chromium,渲染进程与 GPU 进程之间并不是一对一的。在 实际模型中,很可能是多个渲染进程对应一个 GPU 进程。在这里,个人推断是每个标签页 对应一个渲染进程。同时需要注意,浏览器进程里的 UI 框架中的合成器也会与 GPU 进程 通信。所以可以这么理解,GPU 进程在运行时负责整个软件的栅格化与绘制操作。 GPU 进 程与其对应通信的模块之间传递的是合成帧,合成帧与 surface(它出现在屏幕中的位置) 有关。这其中有一个表面聚合的概念,现有材料提供的信息不多,个人认为是涉及合成帧之 间的重叠位置的处理与优化。 剩下的操作在 GPU 进程中。它会利用合成帧中的 Quad 提供 的信息生成并执行一组 GL 调用。而 GL 调用会通过命令缓冲区进行序列化并进行代理调 用。上述过程在 via 线程中进行。而真正的 GL 调用将会在 gpu 线程(与 GPU 进程区分 开)中进行,该线程最终将通过 OpenGL 的 API,来进行实际的屏幕绘制操作。 在新的 Display 操作模式下,via 线程将使用 SKIA 进行绘制操作,并将结果(deferred display list 数据结构)传递给 gpu 线程,最终位于 gpu 线程中的 SKIA 后端会根据所获得的信 息做实际的 GL 调用(或者 Valkan 调用)。
最后的一些细节
由于现代显示器一般使用双缓冲区,在特定时刻一个缓冲区的用于绘制,一个缓冲区的用于 显示。在前者完成绘制后,通过 Swap 操作,前者内容(帧)将会被显示到屏幕中而后者会 被用于下一帧的绘制。不断地如此往复。 栅格化操作和显示操作均在 GPU 进程中进行,前 者原因在于利用 GPU 对栅格化操作进行加速。