首页
/ Electron 多显示器测试完全指南:基于 `virtualDisplay` 原生存根模块的无硬件多屏方案

Electron 多显示器测试完全指南:基于 `virtualDisplay` 原生存根模块的无硬件多屏方案

2026-09-06 18:42:20作者:董斯意

本文围绕 Electron 官方多显示器测试文档展开,深度剖析 @electron-ci/virtual-display 原生模块的设计、三种核心 API、推荐用法与 macOS CoreGraphics 底层约束。读完你可以在没有物理显示器的情况下编写并运行多屏场景测试——例如跨屏窗口定位、窗口状态持久化与显示器热插拔行为,并理解为什么每个用例前都必须调用 forceCleanup()

在开发桌面应用时,screen 检测、多屏窗口定位、全屏/最大化状态恢复等能力都高度依赖真实的物理多显示器环境,而这在普通开发者机器和 CI 上往往难以获得。Electron 仓库给出的解决方案是:利用 macOS CoreGraphics 的 CGVirtualDisplay 私有框架,在软件层面创建出操作系统认可的"虚拟显示器",从而把多显示器测试变成一段普通 JavaScript 代码就能完成的事情。整套方案封装在 spec 目录下的 virtual-display 原生存根模块中(见 addon.mmVirtualDisplayBridge.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:声明了 CGVirtualDisplayCGVirtualDisplayDescriptorCGVirtualDisplayModeCGVirtualDisplaySettings 等私有类的接口;
  • 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)会做三件事:

  1. 清空 sDisplays 字典并把计数器归零;
  2. 通过 CGBeginDisplayConfiguration + CGCompleteDisplayConfiguration(kCGConfigurePermanently) 提交一次永久配置变更;
  3. 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.tsmulti-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 = 720maxX/maxY = 8192,桥接层会据此计算缩放倍率

minMultiplier = max(ceil(720 / width), ceil(720 / height))
maxMultiplier = min(floor(8192 / width), floor(8192 / height))

随后按 60Hz 刷新率、从 minMultipliermaxMultiplier 生成一组分辨率模式(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 实践,编写多屏测试时请守住以下纪律:

  1. 只在 macOS 使用;非 macOS 平台模块直接抛错。
  2. 每个测试(beforeEach)前都 forceCleanup(),并把"等待显示器数量收敛到 1"纳入清理流程。
  3. 创建后校验真实坐标:macOS 会修正重叠与缝隙,getAllDisplays() 返回的 bounds 才是最终真相。
  4. 创建失败要重试:CoreGraphics 显示器池在快速创建/销毁后可能短暂失效,官方实践是重试 3 次。
  5. 用完即 destroy,并在断言前等待目标屏真正消失。
  6. 尺寸保持在 720×720 ~ 8192×8192;传 9000×6000 这类越界尺寸在部分机器上会失败。
  7. options 只接受 width/height/x/y 四个键,其余字段会触发 Object contains unknown properties 异常。
  8. 以主屏为坐标基准计算虚拟屏位置,例如放在主屏右侧 x = primary.bounds.x + primary.bounds.width

延伸阅读

登录后查看全文
热门项目推荐
相关项目推荐