Electron 多显示器测试完全指南:基于 `virtualDisplay` 原生存根模块的无硬件多屏方案
本文围绕 Electron 官方多显示器测试文档展开,深度剖析
@electron-ci/virtual-display原生模块的设计、三种核心 API、推荐用法与 macOS CoreGraphics 底层约束。读完你可以在没有物理显示器的情况下编写并运行多屏场景测试——例如跨屏窗口定位、窗口状态持久化与显示器热插拔行为,并理解为什么每个用例前都必须调用forceCleanup()。
在开发桌面应用时,screen 检测、多屏窗口定位、全屏/最大化状态恢复等能力都高度依赖真实的物理多显示器环境,而这在普通开发者机器和 CI 上往往难以获得。Electron 仓库给出的解决方案是:利用 macOS CoreGraphics 的 CGVirtualDisplay 私有框架,在软件层面创建出操作系统认可的"虚拟显示器",从而把多显示器测试变成一段普通 JavaScript 代码就能完成的事情。整套方案封装在 spec 目录下的 virtual-display 原生存根模块中(见 addon.mm 与 VirtualDisplayBridge.m),并通过 @electron-ci/virtual-display 包名对外提供。
平台限制(务必先读): 该方案只支持 macOS。模块的 JS 入口在非 Darwin 平台上直接抛错(见 lib/virtual-display.js);且
CGVirtualDisplay属于未公开的私有 API,需要运行环境具备相应权限与授权。官方在 testing.md 中明确标注"Platform support: macOS only"。此外,由于 CoreGraphics 行为存在不少"坑",建议在编写测试前完整通读本指南一次。
一、virtualDisplay 是什么:从原生桥接到 JS 的最小实现
在进入用法之前,先厘清模块的构成,这有助于理解为什么文档反复强调"清理"。@electron-ci/virtual-display 是标准的 Node 原生模块,结构如下:
- binding.gyp:gyp 构建描述,仅在
OS=="mac"时编译,启用了 ARC、libc++,目标系统 macOS 11.0+; - src/addon.mm:Node-API(napi)层,把 JS 调用翻译成 Objective-C 方法;
- src/VirtualDisplayBridge.m:真正调用 CoreGraphics
CGVirtualDisplay私有框架的桥接实现; - include/VirtualDisplayBridge.h:声明了
CGVirtualDisplay、CGVirtualDisplayDescriptor、CGVirtualDisplayMode、CGVirtualDisplaySettings等私有类的接口; - lib/virtual-display.js:平台分发的 JS 入口。
桥接层用一张静态字典 sDisplays(key 为自增计数器)维护所有已创建显示器,并通过 CGGetActiveDisplayList 轮询等待显示器注册、用 CGConfigureDisplayOrigin 把虚拟显示器摆放到指定坐标。从源码可以清楚看到:模块暴露的返回 ID 其实不是 CG 显示器 ID,而是内部计数器,真正的 CGDirectDisplayID 通过 display.displayID 取得并用于注册等待。
二、三种 API 逐一拆解
2.1 virtualDisplay.create([options]):创建虚拟显示器
创建一个虚拟显示器,返回唯一的显示器 ID(number)。创建失败时返回 0(注意:addon 层会先抛 "Failed to create virtual display" 异常,见 addon.mm,文档所说返回 0 对应未抛异常时的兜底语义)。
options 中所有参数均可选,均有默认值。JS 层默认值与 addon.mm 中 PropertySpec 数组完全一致(见 addon.mm):
const virtualDisplay = require('@electron-ci/virtual-display')
// 默认:1920×1080,原点位于 (0, 0)
const displayId = virtualDisplay.create()
| 参数 | 含义 | 默认值 | 说明 |
|---|---|---|---|
width |
显示器宽度(像素) | 1920 |
必须为数值,否则抛出 <prop> must be a number |
height |
显示器高度(像素) | 1080 |
必须为数值 |
x |
显示器左上角 X 坐标 | 0 |
相对于 macOS 全局桌面坐标空间 |
y |
显示器左上角 Y 坐标 | 0 |
相对主显示器(通常主屏在 (0,0)) |
自定义参数示例:
const virtualDisplay = require('@electron-ci/virtual-display')
const displayId = virtualDisplay.create({
width: 2560, // 显示宽度(像素)
height: 1440, // 显示高度(像素)
x: 1920, // X 位置(左上角)
y: 0 // Y 位置(左上角)
})
参数校验细节:addon 层逐属性读取整数,并额外检查传入对象中不存在未知属性(Object contains unknown properties)。也就是说 options 里只能出现 width/height/x/y 这四个键,传多余字段会直接抛错。
[!NOTE] 建议在每一个测试前调用
virtualDisplay.forceCleanup(),否则该测试的创建可能失败。原因:macOS CoreGraphics 内部维护一个显示器 ID 分配池,当虚拟显示器在测试中被快速创建/销毁时该池会损坏,后续创建可能拿到不一致的显示器 ID,导致测试不稳定(flaky)。
2.2 virtualDisplay.forceCleanup():彻底清理
对所有虚拟显示器做完整清理,并重置 macOS CoreGraphics 显示系统。其原生实现(见 VirtualDisplayBridge.m)会做三件事:
- 清空
sDisplays字典并把计数器归零; - 通过
CGBeginDisplayConfiguration+CGCompleteDisplayConfiguration(kCGConfigurePermanently)提交一次永久配置变更; usleep(2000000)等待 2 秒后,再用kCGConfigureForSession提交一次会话级配置,完成对显示配置系统的"复位"。
beforeEach(() => {
virtualDisplay.forceCleanup()
})
2.3 virtualDisplay.destroy(displayId):销毁单个虚拟显示器
移除指定虚拟显示器。从源码看,其作用是从 sDisplays 字典中移除对应条目(VirtualDisplayBridge.m),找不到该 ID 时返回 false。
virtualDisplay.destroy(displayId)
[!NOTE] 使用完毕后务必销毁虚拟显示器,防止污染 macOS CoreGraphics 显示器池并影响后续测试。
三、推荐用法:一套防抖的测试骨架
把三种 API 组合成标准模板,是最稳妥的写法:
describe('multi-monitor tests', () => {
const virtualDisplay = require('@electron-ci/virtual-display')
beforeEach(() => {
virtualDisplay.forceCleanup()
})
it('should handle multiple displays', () => {
const display1 = virtualDisplay.create({ width: 1920, height: 1080, x: 0, y: 0 })
const display2 = virtualDisplay.create({ width: 2560, height: 1440, x: 1920, y: 0 })
// 你的测试逻辑
virtualDisplay.destroy(display1)
virtualDisplay.destroy(display2)
})
})
这一模板与 Electron 官方 spec 中的实际用法完全吻合。在 api-browser-window-spec.ts 的 multi-monitor tests 套件里,官方还加了更多抗抖动处理,可视为生产级参考:
beforeEach里先forceCleanup(),随后轮询等待直到screen.getAllDisplays().length === 1,确认只剩主屏再开始(最多等 20 秒);- 封装
createDisplay()辅助函数:创建失败时最多重试 3 次,重试仍失败才抛出最后一次错误; - 封装
waitForDisplayPositioned():创建后用screen.getDisplayNearestPoint({ x, y })持续探测虚拟屏是否真的被放到了期望坐标(最多轮询 30 次、每次间隔 500ms)。
// 官方 spec 中的核心辅助函数(节选自 spec/api-browser-window-spec.ts)
function createDisplay(opts: { width: number; height: number; x: number; y: number }): number {
let lastError: Error = new Error('Failed to create virtual display');
for (let attempt = 0; attempt < 3; attempt++) {
try {
return virtualDisplay.create(opts);
} catch (e) {
lastError = e as Error;
}
}
throw lastError;
}
async function waitForDisplayPositioned(expectedX: number, expectedY: number, expectedCount?: number) {
// ...轮询 screen.getAllDisplays() / screen.getDisplayNearestPoint(...),
// 直到虚拟显示器出现在期望坐标,否则最终断言失败。
}
这种"清理 + 等待收敛 + 创建重试 + 坐标确认"的四段式流程,正是文档那句"macOS CoreGraphics 特性多、务必整体通读后再写测试"背后的真实工程折衷。
四、尺寸与坐标约束:显示器不是想设多大就设多大
4.1 尺寸上下限
虚拟显示器被限制为最小 720×720,最大 8192×8192 像素。实际极限取决于 Mac 的图形能力,超出该范围(如 9000×6000)的系统上可能创建失败。
// 适合测试的安全尺寸
virtualDisplay.create({ width: 1920, height: 1080 }) // 全高清
virtualDisplay.create({ width: 3840, height: 2160 }) // 4K
源码印证:在 VirtualDisplayBridge.m 中,minX/minY = 720、maxX/maxY = 8192,桥接层会据此计算缩放倍率:
minMultiplier = max(ceil(720 / width), ceil(720 / height))
maxMultiplier = min(floor(8192 / width), floor(8192 / height))
随后按 60Hz 刷新率、从 minMultiplier 到 maxMultiplier 生成一组分辨率模式(modes)填入 CGVirtualDisplaySettings。这意味着你传入的 width/height 是"基准分辨率",实际可用的模式集合是基准分辨率的整数倍、且必须落在 720~8192 区间内。显示器描述(descriptor)中还会根据宽高推算物理尺寸(sizeInMillimeters),并附上自定义的 vendorID = 0xF0F0 与 productID,让系统把它识别为一块真实外接屏。
4.2 坐标自动校正行为(Overlap 与 Gap)
macOS 会维护连续的桌面空间:如果显示器之间存在重叠或空隙,系统会自动调整显示器位置,使新显示器的原点尽量接近请求位置,同时避免重叠或留缝。
重叠(Overlap)会被自动拉开:
// 请求的位置
const display1 = virtualDisplay.create({ x: 0, y: 0, width: 1920, height: 1080 })
const display2 = virtualDisplay.create({ x: 500, y: 0, width: 1920, height: 1080 })
// macOS 自动把 display2 重定位到 x: 1920,避免与 display1 重叠
const actualBounds = screen.getAllDisplays().map(d => d.bounds)
// 结果:[{ x: 0, y: 0, width: 1920, height: 1080 }, { x: 1920, y: 0, width: 1920, height: 1080 }]
空隙(Gap)会被吸附消除:
// 请求的位置:两块屏之间留了缝
const display1 = virtualDisplay.create({ width: 1920, height: 1080, x: 0, y: 0 })
const display2 = virtualDisplay.create({ width: 1920, height: 1080, x: 2000, y: 0 })
// macOS 把 display2 吸附到 x: 1920(消除 80px 空隙)
[!NOTE] 创建后务必用
screen.getAllDisplays()校验实际坐标,因为 macOS 可能把数值调整到与你设定的不一致的位置。
五、用 Electron screen API 验证结果
验证坐标用的是 Electron 内置的 screen 模块(在 screen 文档 有完整 API 列表,其底层实现在 electron_api_screen.cc):
screen.getAllDisplays():返回当前所有显示器(含虚拟屏)的数组,每项含bounds(整块屏区域)与workArea(可用工作区);screen.getPrimaryDisplay():返回主显示器,测试中常作为坐标系原点参考;screen.getDisplayNearestPoint(point):返回包含或最接近某点的显示器,官方 spec 用它来探测虚拟屏是否已就位。
一个常见的验证写法是:断言虚拟屏的 bounds.width/height 与创建参数一致,再用目标屏的 workArea 计算窗口期望坐标,之后把窗口放到虚拟屏上验证行为。官方在 api-browser-window-spec.ts 的 'should restore window bounds correctly on a secondary display' 用例展示了完整链路:把主屏右缘坐标 primaryDisplay.bounds.x + primaryDisplay.bounds.width 作为虚拟屏 x → 创建虚拟屏并等待就位 → 把窗口状态持久化保存到该屏 → 用相同 name 重建窗口 → 断言恢复的 bounds 与保存值相等 → 销毁窗口与虚拟屏。
六、真实世界的运行门槛:何时能跑这些测试
官方 spec 对多显示器测试设置了严格的前置条件(见 api-browser-window-spec.ts),这直接决定了你在自己机器上复现时的预期:
const testMultiMonitor =
process.platform === 'darwin' && // 仅 macOS
process.arch === 'arm64' && // 仅 Apple Silicon(x64 存在双显示器 bug)
screen.getAllDisplays().length === 1 && // 必须从单主屏开始
!process.env.CI; // CI 运行器缺权限/授权
代码注释明确指出:GitHub Actions 的 macOS runner 缺少 CGVirtualDisplay 注册与定位虚拟显示器所需的权限/授权(entitlements),因此这些用例只能在开发者本机运行;同时在 macOS-x64 上 virtualDisplay.create() 存在"创建出双显示器"的已知问题(FIXME 待调查)。Electron 仓库主要用这套方案测试**窗口状态持久化(windowStatePersistence)**在跨屏、拔屏、分辨率变化下的行为——这也提示我们:若要在 CI 使用该方案,需要自行配置带授权的 runner,而不是直接套用官方 GitHub Actions 环境。
七、写给自己的 8 条避坑清单
综合官方文档、桥接源码与 spec 实践,编写多屏测试时请守住以下纪律:
- 只在 macOS 使用;非 macOS 平台模块直接抛错。
- 每个测试(
beforeEach)前都forceCleanup(),并把"等待显示器数量收敛到 1"纳入清理流程。 - 创建后校验真实坐标:macOS 会修正重叠与缝隙,
getAllDisplays()返回的 bounds 才是最终真相。 - 创建失败要重试:CoreGraphics 显示器池在快速创建/销毁后可能短暂失效,官方实践是重试 3 次。
- 用完即
destroy,并在断言前等待目标屏真正消失。 - 尺寸保持在 720×720 ~ 8192×8192;传 9000×6000 这类越界尺寸在部分机器上会失败。
- options 只接受
width/height/x/y四个键,其余字段会触发Object contains unknown properties异常。 - 以主屏为坐标基准计算虚拟屏位置,例如放在主屏右侧
x = primary.bounds.x + primary.bounds.width。
延伸阅读
- 多显示器测试原始文档(本指南所依据的权威出处)
- 测试开发总览,其中"Multi-Monitor Tests"一节给出了该模块的定位与用途
- 原生模块实现:addon.mm、VirtualDisplayBridge.m、binding.gyp
- 官方多屏测试用例:api-browser-window-spec.ts(窗口状态持久化 × 虚拟显示器)
- Electron 屏幕 API:screen 文档 与底层实现 electron_api_screen.cc
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0624
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00