技术谈话:改进窗口调整大小行为
我们正在启动一个新的博客文章系列,分享我们在 Electron 上的工作进展。如果您对这项工作感兴趣,请考虑 贡献!
最近,我致力于改进 Electron 和 Chromium 的窗口调整大小行为。
这个错误
我们在 Windows 上遇到一个问题,即在调整窗口大小时,旧的框架会变得可见。

是什么让这个错误特别有趣?
- 它极具挑战性。
- 它隐藏在一个庞大的代码库中。
- 如您稍后将看到,底层存在两个不同的错误。
修复这个错误
对于这类错误,首要挑战是确定从哪里开始查找。
Electron 构建于 Chromium 之上,它是 Google Chrome 的开源版本。在编译 Electron 时,Electron 的源代码会作为子目录添加到 Chromium 源代码树中。然后,Electron 依赖 Chromium 的代码来提供现代浏览器的绝大部分功能。
Chromium 拥有大约 3600 万行代码。Electron 本身也是一个大型项目。这其中可能隐藏着导致此问题的代码。
缩小范围以确定根本原因
我做了大量的实验。
首先,我注意到这个问题也发生在 Google Chrome 中

这表明问题可能出在 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 的组合非常有用。前者为合成器的输出添加色调。后者每帧更改色调颜色。

在截图中,青色色调的帧是最后绘制的帧。
该帧右侧的卡顿没有青色色调。它被来自先前帧的不同颜色色调着色。这表明卡顿并非来自合成器。合成器正在发送正确的输出。
合成器是 Chromium 图形堆栈的一部分。以下非常简化,但为了这篇博文的目的,您可以将其想象成这样
- 合成器
cc生成一个CompositorFrame,其中包含绘制指令。 cc将该CompositorFrame发送到显示合成器viz。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 中。
这看起来像这样

让我解释一下
- 蓝色矩形是我们的表面。
- 绿色区域是我们的视口,即表面上应该可见并且我们主动绘制的区域。
- 红色矩形是我们的裁剪矩形,即实际显示在屏幕上的表面的部分。
作为性能优化,只有视口(绿色区域)在新帧时才会被重新绘制。其余部分保持不变。这很重要。我们只重新绘制绿色视口。我们不会更新视口之外的区域。
调整窗口大小时,应该发生的情况是,在一个原子事务中(= 在完全相同的时间)我们重新绘制视口(= 应该在屏幕上可见的区域),然后更新裁剪矩形以将表面裁剪到新的视口大小。
调整大小后,它应该看起来像这样

这就是我们遇到的第一个错误的开始。
第一个错误
有时这些操作可能会不同步。例如,裁剪矩形可能会在重新绘制视口之前更新。然后我们得到如下结果

我们仍然在绿色视口中显示旧帧。但是裁剪矩形已变大,并且我们显示了尚未重新绘制的表面的区域。
在第一次调整窗口大小时,这些区域将是黑色的。在第二次调整大小时,这些区域将填充以前绘制的旧像素值。它们将显示我们之前在该区域绘制的任何内容。
同样,在缩小窗口的特定边缘情况下,我们有时会先重新绘制视口,然后再更新裁剪矩形。

然后裁剪矩形的一部分仍然会显示以前的帧,因为新帧更小,并且我们没有重新绘制超出新视口的任何区域。
现在为什么这些操作不能同步发生?
我们在这里使用两个不同的 Windows API
IDXGISwapChain1::Present1— 这会在屏幕上显示新像素 / 更新新的视口。IDCompositionDevice::Commit— 这会更新裁剪矩形。
重要的是要理解:这两个函数都在 CPU 上同步返回。但是,它们安排稍后在 GPU 上异步运行的任务。Windows 及其服务(例如 DWM)决定何时以及以何种顺序运行这些任务。因此,它们以异步方式生效,并且不总是在同一帧内生效。
不幸的是,Windows 没有提供任何方法来同步这些操作。所以我不得不找到其他方法来解决这个问题。
我与 Chromium 维护者评估了两个选项
- 在调整大小期间,将先前绘制的视口之外的所有区域绘制为透明。这会使这些区域不可见。它修复了伪影。
- 在调整大小期间,从
IDXGISwapChain1切换到与IDCompositionDevice::Commit同步更新的 DirectComposition 表面。这也可以修复伪影。
我们选择了第一个选项,因为它比第二个选项导致更快的调整大小。
我提交了一个 补丁 到 Chromium,实现了第一个解决方案。
我还提交了另外两个补丁,为主要补丁做准备
- 第一个 补丁 修复了现有代码中的一个错误,该错误会使 CI 与主要补丁结合时失败。它还使启动 Electron 应用程序和 Chrome 稍微快一些。
- 第二个 补丁 被拆分出来,以便更容易地审查主要补丁的代码。
第二个错误
除了第一个错误之外,还有一个第二个错误也导致了陈旧的像素。
以下是发生了什么
当用户调整窗口大小时,Chromium 需要重新绘制窗口的内容以适应新的窗口大小。这需要一些时间。新帧不是立即准备好的。
以下是一系列帧,演示了这一点

在调整大小期间的某个时间点,Windows 会告诉我们:“窗口宽度为 1,000 像素。”但是浏览器合成器生成的帧滞后了。我们最后绘制的帧的宽度可能是 600 像素。
从历史上看,Chromium 会跳过窗口宽度与上次绘制的帧的宽度不匹配的帧。它会决定只是不更新窗口。
然而,这通常会导致窗口内容在调整大小操作完成之前完全不更新。
因此 在 2015 年,有人决定:“为什么不显示这些帧呢?它们可能与窗口大小并不完全匹配,但至少我们可以显示一些东西。”
这会导致边框,但当时边框是黑色的。所以这比之前的实现要好。
现在,10 年后,随着 DirectComposition 的出现,这个边框经常被过时的像素填充。
让我们看看当时发生了什么
每一帧由多个渲染通道组成。这些渲染通道代表了应该在屏幕上绘制的各种内容。从复杂的位图到填充纯色的矩形。
每一帧都有一个根渲染通道,它包含所有其他渲染通道并将它们粘合在一起。(渲染通道以树形结构排列,根渲染通道是该树的根。)
所以现在在调整大小期间,我们会到达一个知道窗口宽度为 1000 像素的点。相应地,我们将输出表面的视口调整为也为 1000 像素宽。但我们刚刚收到的帧只有 600 像素宽。
2015 年的优化 然后会更改根渲染通道的宽度,使其也为 1000 像素。但它不会更改渲染通道实际在屏幕上绘制的内容。它们仍然只包含绘制 600 像素宽图像的指令。
这样看起来会是这样

黄色区域是帧的渲染通道实际绘制内容的区域。它的宽度为 600 像素。
然而,我们的绿色视口和红色裁剪矩形宽度为 1000 像素。这是我们在屏幕上显示的内容。(毕竟,根渲染通道的宽度属性声称它会重新绘制 1000 像素的整个区域。)
但是,由于我们没有绘制右侧 400 像素的指令,这些区域没有得到更新。
在第一次调整大小的时候,我们会显示黑色的像素。(这是我们用初始化表面的颜色。)
在后续的调整大小中,这些区域会显示之前绘制的内容。我们会看到过时的像素。
我将此问题的修复提交到 crrev.com/c/7156576。
该修复更改了我们在收到大小与窗口不同的帧时所做的事情。与其调整帧的大小并添加包含过时像素的边框,我们 调整我们的视口 和我们的裁剪矩形。

我们将表面裁剪到我们收到的帧的大小。我们不显示超出我们有绘制指令的 600 像素之外的任何内容。
Voilà,没有边框,没有过时的像素!
如果没有 supports_viewporter,这将是一项代价高昂的操作,因为它会分配一个新的输出表面。但是,有了 DirectComposition,我们使用“视口”功能。因此,当我们更改视口大小时,我们不会重新分配表面。我们只是让它的不同部分可见。因此,这是一项廉价的操作。
将补丁移植到 Electron
一旦修复进入 Chromium,我们还必须将其拉入 Electron。
在 main 分支上,Electron 会不断更新其 Chromium 版本。因此,这些补丁通过 Chromium roll PR 合并到 main 中。
但是,现在合并到 main 中的提交只有在大约三个月后才会包含在 Electron 发布版中。我们现有的发布和预发布 分支 运行在较旧的 Chromium 版本上。
因此,下一步是将补丁移植到 Electron 39 和 Electron 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 贡献代码!我们喜欢看到新面孔。
