跳转到主要内容

技术谈话:改进窗口调整大小行为

·阅读时长 16 分钟

我们正在启动一个新的博客文章系列,分享我们在 Electron 上的工作进展。如果您对这项工作感兴趣,请考虑 贡献


最近,我致力于改进 Electron 和 Chromium 的窗口调整大小行为。

这个错误

我们在 Windows 上遇到一个问题,即在调整窗口大小时,旧的框架会变得可见。

Animated GIF showing the issue where old frames would be shown while resizing windows

是什么让这个错误特别有趣?

  1. 它极具挑战性。
  2. 它隐藏在一个庞大的代码库中。
  3. 如您稍后将看到,底层存在两个不同的错误。

修复这个错误

对于这类错误,首要挑战是确定从哪里开始查找。

Electron 构建于 Chromium 之上,它是 Google Chrome 的开源版本。在编译 Electron 时,Electron 的源代码会作为子目录添加到 Chromium 源代码树中。然后,Electron 依赖 Chromium 的代码来提供现代浏览器的绝大部分功能。

Chromium 拥有大约 3600 万行代码。Electron 本身也是一个大型项目。这其中可能隐藏着导致此问题的代码。

缩小范围以确定根本原因

我做了大量的实验。

首先,我注意到这个问题也发生在 Google Chrome 中

Screenshot of Google Chrome also showing the resize issue

这表明问题可能出在 Chromium 中,而不是 Electron 中。

此外,该问题在 macOS 上不可见。这表明问题出在 Windows 特有的源代码中。

关键线索

我尝试了许多不同的命令行标志和配置选项。

我注意到 app.disableHardwareAcceleration() 修复了该问题。禁用硬件加速后,问题就消失了。

以下是一些背景信息:Chromium 支持各种不同的图形 API,用于在屏幕上显示像素(OpenGL、Vulkan、Metal 等)。在 Windows 上,它使用与 macOS 或 Linux 不同的图形 API。即使在 Windows 上,Chromium 也可以使用多种不同的图形后端。

Chromium 使用哪个图形后端取决于用户的硬件。例如,某些图形后端要求计算机必须具有 GPU。

我尝试了各种图形后端,并注意到以下标志修复了该问题

  • --use-angle=warp
  • --use-angle=vulkan
  • --use-gl=desktop
  • --use-gl=egl
  • --use-gl=osmesa
  • --use-gl=swiftshader

以下标志重现了该问题

  • --use-angle=d3d11 (这是 Windows 上的当前默认设置)
  • --use-angle=gl (在 Windows 上回退到 Direct3D 11,请参阅 chrome://gpu/)

没有一个可用的标志足以用作 Windows 上 Electron 应用程序的默认设置。它们要么太慢,要么缺乏广泛的驱动程序支持。

然而,这些解决方法指向了正确的方向。它们表明问题出在仅与 ANGLE Direct3D 11 后端一起使用的代码路径中。

Direct3D 是 Windows API,用于硬件加速图形。

ANGLE 是一个库,它将 OpenGL 调用转换为本机图形 API 的调用,该 API 用于给定的操作系统,这里是 Direct3D。ANGLE 允许 Chromium 开发人员在所有平台上编写 OpenGL 调用。然后,ANGLE 将它们转换为 Direct3D、Vulkan 或 Metal API 调用,具体取决于使用的图形 API。

定位相关的 Chromium 组件

Chromium 在数万个地方引用了 Direct3D。遍历所有这些地方是不现实的。

偶然间,我在 Chromium 源代码中发现了一些有用的调试标志

  • --ui-show-paint-rects
  • --ui-show-property-changed-rects
  • --ui-show-surface-damage-rects
  • --ui-show-composited-layer-borders
  • --tint-composited-content
  • --tint-composited-content-modulate
  • (还有更多)

它们突出显示了浏览器窗口中由 Chromium 图形堆栈的不同部分重新绘制或更新的区域。

这让我能够看到图形堆栈的哪个部分生成了哪个输出。

特别是,--tint-composited-content--tint-composited-content-modulate 的组合非常有用。前者为合成器的输出添加色调。后者每帧更改色调颜色。

Screenshot of Chromium with the --tint-composited-content flag

在截图中,青色色调的帧是最后绘制的帧。

该帧右侧的卡顿没有青色色调。它被来自先前帧的不同颜色色调着色。这表明卡顿并非来自合成器。合成器正在发送正确的输出。

合成器是 Chromium 图形堆栈的一部分。以下非常简化,但为了这篇博文的目的,您可以将其想象成这样

  1. 合成器 cc 生成一个 CompositorFrame,其中包含绘制指令。
  2. cc 将该 CompositorFrame 发送到显示合成器 viz
  3. viz 然后绘制帧并在屏幕上显示它。

为每个 CompositorFrame 添加色调表明合成器生成了正确的输出。因此,问题必须出在显示合成器 viz 中。

定位相关的 viz 代码

从那里,我开始在 viz 源代码中搜索 Direct3D 的提及。

注意:从这里开始,这篇文章将变得更加技术性,并引用源代码符号。

我发现在使用 ANGLE Direct3D 11 后端时,Chromium 使用 Windows DirectComposition API 来绘制窗口内容。

Chromium 的 DirectComposition OutputSurface 与 Chromium 中的其他输出表面不同。它具有 supports_viewporter 功能 (源链接 1, 源链接 2)。

输出表面是可以绘制到的位图,通常由 GPU 纹理支持。

如果没有 supports_viewporter,每当窗口大小更改时,Chromium 都会创建一个与新窗口大小匹配的新输出表面。然后它会在该表面上绘制并显示它。

supports_viewporter 试图减少这些代价高昂的表面分配。使用 supports_viewporter,Chromium 不会在每次调整大小时分配新表面。而是会分配一个大于我们需要绘制的表面的表面。然后它只会绘制并显示该表面的某个子矩形(“视口”)。表面的其他部分不应该显示在屏幕上。

这应该使调整大小更有效,因为 Chromium 所需做的就是填充表面到正确的宽度和高度,而不是每次调整大小时都分配新表面。此表面调整大小逻辑位于 direct_renderer.cc 中。

这看起来像这样

Visualization showing the surface, viewport, and clip rect

让我解释一下

  • 蓝色矩形是我们的表面。
  • 绿色区域是我们的视口,即表面上应该可见并且我们主动绘制的区域。
  • 红色矩形是我们的裁剪矩形,即实际显示在屏幕上的表面的部分。

作为性能优化,只有视口(绿色区域)在新帧时才会被重新绘制。其余部分保持不变。这很重要。我们只重新绘制绿色视口。我们不会更新视口之外的区域。

调整窗口大小时,应该发生的情况是,在一个原子事务中(= 在完全相同的时间)我们重新绘制视口(= 应该在屏幕上可见的区域),然后更新裁剪矩形以将表面裁剪到新的视口大小。

调整大小后,它应该看起来像这样

Visualization with updated viewport and clip rect

这就是我们遇到的第一个错误的开始。

第一个错误

有时这些操作可能会不同步。例如,裁剪矩形可能会在重新绘制视口之前更新。然后我们得到如下结果

Visualization where the clip rect was updated before the viewport

我们仍然在绿色视口中显示旧帧。但是裁剪矩形已变大,并且我们显示了尚未重新绘制的表面的区域。

在第一次调整窗口大小时,这些区域将是黑色的。在第二次调整大小时,这些区域将填充以前绘制的旧像素值。它们将显示我们之前在该区域绘制的任何内容。

同样,在缩小窗口的特定边缘情况下,我们有时会先重新绘制视口,然后再更新裁剪矩形。

Visualization where the viewport was repainted before the clip rect was updated

然后裁剪矩形的一部分仍然会显示以前的帧,因为新帧更小,并且我们没有重新绘制超出新视口的任何区域。

现在为什么这些操作不能同步发生?

我们在这里使用两个不同的 Windows API

重要的是要理解:这两个函数都在 CPU 上同步返回。但是,它们安排稍后在 GPU 上异步运行的任务。Windows 及其服务(例如 DWM)决定何时以及以何种顺序运行这些任务。因此,它们以异步方式生效,并且不总是在同一帧内生效。

不幸的是,Windows 没有提供任何方法来同步这些操作。所以我不得不找到其他方法来解决这个问题。

我与 Chromium 维护者评估了两个选项

  1. 在调整大小期间,将先前绘制的视口之外的所有区域绘制为透明。这会使这些区域不可见。它修复了伪影。
  2. 在调整大小期间,从 IDXGISwapChain1 切换到与 IDCompositionDevice::Commit 同步更新的 DirectComposition 表面。这也可以修复伪影。

我们选择了第一个选项,因为它比第二个选项导致更快的调整大小。

我提交了一个 补丁 到 Chromium,实现了第一个解决方案。

我还提交了另外两个补丁,为主要补丁做准备

  1. 第一个 补丁 修复了现有代码中的一个错误,该错误会使 CI 与主要补丁结合时失败。它还使启动 Electron 应用程序和 Chrome 稍微快一些。
  2. 第二个 补丁 被拆分出来,以便更容易地审查主要补丁的代码。

第二个错误

除了第一个错误之外,还有一个第二个错误也导致了陈旧的像素。

以下是发生了什么

当用户调整窗口大小时,Chromium 需要重新绘制窗口的内容以适应新的窗口大小。这需要一些时间。新帧不是立即准备好的。

以下是一系列帧,演示了这一点

First frame of the Chromium resize sequence Second frame of the Chromium resize sequence Third frame of the Chromium resize sequence Fourth frame of the Chromium resize sequence Fifth frame of the Chromium resize sequence

在调整大小期间的某个时间点,Windows 会告诉我们:“窗口宽度为 1,000 像素。”但是浏览器合成器生成的帧滞后了。我们最后绘制的帧的宽度可能是 600 像素。

从历史上看,Chromium 会跳过窗口宽度与上次绘制的帧的宽度不匹配的帧。它会决定只是不更新窗口。

然而,这通常会导致窗口内容在调整大小操作完成之前完全不更新。

因此 在 2015 年,有人决定:“为什么不显示这些帧呢?它们可能与窗口大小并不完全匹配,但至少我们可以显示一些东西。”

这会导致边框,但当时边框是黑色的。所以这比之前的实现要好。

现在,10 年后,随着 DirectComposition 的出现,这个边框经常被过时的像素填充。

让我们看看当时发生了什么

每一帧由多个渲染通道组成。这些渲染通道代表了应该在屏幕上绘制的各种内容。从复杂的位图到填充纯色的矩形。

每一帧都有一个根渲染通道,它包含所有其他渲染通道并将它们粘合在一起。(渲染通道以树形结构排列,根渲染通道是该树的根。)

所以现在在调整大小期间,我们会到达一个知道窗口宽度为 1000 像素的点。相应地,我们将输出表面的视口调整为也为 1000 像素宽。但我们刚刚收到的帧只有 600 像素宽。

2015 年的优化 然后会更改根渲染通道的宽度,使其也为 1000 像素。但它不会更改渲染通道实际在屏幕上绘制的内容。它们仍然只包含绘制 600 像素宽图像的指令。

这样看起来会是这样

Visualization where the frame is smaller than the viewport

黄色区域是帧的渲染通道实际绘制内容的区域。它的宽度为 600 像素。

然而,我们的绿色视口和红色裁剪矩形宽度为 1000 像素。这是我们在屏幕上显示的内容。(毕竟,根渲染通道的宽度属性声称它会重新绘制 1000 像素的整个区域。)

但是,由于我们没有绘制右侧 400 像素的指令,这些区域没有得到更新。

在第一次调整大小的时候,我们会显示黑色的像素。(这是我们用初始化表面的颜色。)

在后续的调整大小中,这些区域会显示之前绘制的内容。我们会看到过时的像素。

我将此问题的修复提交到 crrev.com/c/7156576

该修复更改了我们在收到大小与窗口不同的帧时所做的事情。与其调整帧的大小并添加包含过时像素的边框,我们 调整我们的视口 和我们的裁剪矩形。

Visualization where the clip rect and viewport size are adjusted to the frame size

我们将表面裁剪到我们收到的帧的大小。我们不显示超出我们有绘制指令的 600 像素之外的任何内容。

Voilà,没有边框,没有过时的像素!

注意

如果没有 supports_viewporter,这将是一项代价高昂的操作,因为它会分配一个新的输出表面。但是,有了 DirectComposition,我们使用“视口”功能。因此,当我们更改视口大小时,我们不会重新分配表面。我们只是让它的不同部分可见。因此,这是一项廉价的操作。

将补丁移植到 Electron

一旦修复进入 Chromium,我们还必须将其拉入 Electron。

main 分支上,Electron 会不断更新其 Chromium 版本。因此,这些补丁通过 Chromium roll PR 合并到 main 中。

但是,现在合并到 main 中的提交只有在大约三个月后才会包含在 Electron 发布版中。我们现有的发布和预发布 分支 运行在较旧的 Chromium 版本上。

因此,下一步是将补丁移植到 Electron 39Electron 40

Electron 在 patches/chromium 目录 中保留了 Chromium 补丁的列表。当我们移植 Chromium 补丁时,我们会将其添加到那里。构建 Electron 时,这些补丁会应用于 Chromium 源代码。

(通常,我们试图 保持补丁数量较少。每个补丁都可能导致在 Chromium 更新期间发生合并冲突。补丁的维护负担是真实的。)

Electron 39 backport PR 很快被合并。该修复成为 Electron 39.2.6 的一部分。 🎉

如果您调整 Electron 39.2.6 或更高版本的窗口大小,您将不会再看到过时的像素。

(这些补丁也包含在 Google Chrome Canary 中。它们应该包含在 2026 年 2 月的稳定 Google Chrome 发布版中。)

感谢

非常感谢 Plasticity 为这项工作提供资金!

感谢 Chromium 团队的 Michael Tang 和 Vasiliy Telezhnikov 提供的帮助。

总结

这是我迄今为止遇到的最难的 bug(而且我已经在 18 年的软件编写生涯中处理过许多难的 bug)。

但它也是我迄今为止最有趣的项目之一。

如果您觉得这很有趣,请考虑 为 Electron 贡献代码!我们喜欢看到新面孔。