Electron鸿蒙PC上恢复窗口状态,多显示器拔插后我的窗口飞不见了
上周用户提了个需求:希望应用重启后能记住上次关闭时的窗口大小和位置。听起来 trivial 对吧?我在 Mac 和 Windows 上做过不下十次,闭着眼睛都能写。结果在鸿蒙 PC 上搞了整整两个晚上,窗口不是飞到屏幕外面,就是缩成了指甲盖大小。下面把趟过的坑全记下来。
第一坑:screen.getAllDisplays() 返回的数据不能信
Electron 的 screen 模块大家都熟,拿到所有显示器信息然后算坐标,常规操作:
const { screen } = require('electron');function getBestDisplay(winBounds) { const displays = screen.getAllDisplays(); // 找包含窗口中心点的显示器 const center = { x: winBounds.x + winBounds.width / 2, y: winBounds.y + winBounds.height / 2 }; return displays.find(d => { const b = d.bounds; return center.x >= b.x && center.x <= b.x + b.width && center.y >= b.y && center.y <= b.y + b.height; }) || displays[0];}
这段代码在 Windows 上稳如老狗,在鸿蒙 PC 上直接翻车。我发现 getAllDisplays() 返回的 bounds 和 workArea 有时候是空的,有时候宽高超离谱——比如一个 1920×1080 的屏幕,bounds 竟然返回 { x: 0, y: 0, width: 3840, height: 2160 },正好是实际分辨率的两倍。我一开始以为是高分屏缩放的问题,查了两天才发现,鸿蒙 PC 的显示服务在某些场景下会把物理像素和逻辑像素混着返回,而且不带 scaleFactor 修正。
更离谱的是,如果你外接了一台显示器,然后拔掉,再调用 getAllDisplays(),它有时候还会返回那台已经不存在的显示器,持续好几秒。我猜测是鸿蒙的显示子系统缓存没及时刷新。
兜底方案:别全信 screen API,恢复窗口状态前必须做合法性校验:
function sanitizeBounds(saved, displays) { const MIN_VISIBLE = 100; // 至少露 100px 在屏幕里 const MAX_W = 3840, MAX_H = 2160; // 过滤异常值 let { x, y, width, height } = saved; width = Math.min(Math.max(width, 400), MAX_W); height = Math.min(Math.max(height, 300), MAX_H); // 检查是否至少有一部分落在有效显示器内 const visible = displays.some(d => { const b = d.workArea || d.bounds; const intersectX = Math.max(0, Math.min(x + width, b.x + b.width) - Math.max(x, b.x)); const intersectY = Math.max(0, Math.min(y + height, b.y + b.height) - Math.max(y, b.y)); return intersectX >= MIN_VISIBLE && intersectY >= MIN_VISIBLE; }); if (!visible) { // 回退到主显示器中央 const primary = displays.find(d => d.id === screen.getPrimaryDisplay().id) || displays[0]; const b = primary.workArea || primary.bounds; x = b.x + (b.width - width) / 2; y = b.y + (b.height - height) / 2; } return { x: Math.round(x), y: Math.round(y), width, height };}
这段代码我现在每个 Electron 项目都复制一份,已经成了标配。
第二坑:maximized 状态在鸿蒙上是个伪命题
Electron 的 BrowserWindow 有 isMaximized() 方法,保存状态时通常这么写:
const state = { width: win.getSize()[0], height: win.getSize()[1], x: win.getPosition()[0], y: win.getPosition()[1], maximized: win.isMaximized()};
恢复时如果 maximized 为 true,就先 win.show() 再 win.maximize()。这在其他平台没问题,在鸿蒙 PC 上我发现了两个怪事。
怪事一:如果窗口在关闭前是最大化状态,下次启动时调用 maximize(),窗口有时候会卡在全屏和正常尺寸之间——标题栏消失了,但内容区域又没有真正铺满屏幕。看起来像鸿蒙的窗口管理器和 Electron 的 Chromium 框架在边框计算上产生了分歧。我试了 setTimeout(() => win.maximize(), 100) 都不行,最后发现必须先等 ready-to-show 事件触发后,再延迟一帧:
win.once('ready-to-show', () => { if (savedState.maximized) { // 鸿蒙 PC 需要多一帧让窗口管理器就绪 setImmediate(() => win.maximize()); } win.show();});
setImmediate 比 setTimeout(..., 0) 更早执行,刚好卡在 Chromium 渲染进程和鸿蒙窗口服务之间那个微妙的时机。
怪事二:如果用户上次是在扩展屏上最大化的,然后拔掉扩展屏重启应用,直接 maximize() 会导致窗口尺寸变成扩展屏的分辨率,但显示在主屏上,底部直接溢出任务栏。我的做法是:最大化前永远先校验当前显示器尺寸,如果和保存时不一致,就放弃最大化状态,改为手动计算一个合理的居中尺寸。
说实话,isMaximized() 这个 API 在鸿蒙上不太靠谱。我现在改成双保险:既存 maximized 标志位,也存正常化后的 normalBounds,恢复时根据显示器环境决定走 maximize 还是手动铺满。
// 保存时const state = win.isMaximized() ? { maximized: true, normalBounds: win.getNormalBounds() } : { maximized: false, bounds: win.getBounds() };// 恢复时if (state.maximized) { win.setBounds(state.normalBounds); // 先放到合理位置 setImmediate(() => win.maximize());}
第三坑:DPI 变化后窗口变成显微镜
鸿蒙 PC 的显示器 DPI 切换策略和 Windows 不太一样。我在一台 2K 屏(缩放 150%)上运行应用,窗口尺寸设为 1200×800,一切正常。然后把窗口拖到一台 1080p 普通屏上关闭,下次在这台 1080p 屏上打开,窗口直接变成了 1800×1200——因为 Electron 把之前那套逻辑像素当成物理像素用了。
根本原因是 Electron 在鸿蒙上获取 display.scaleFactor 的时机有问题。窗口创建时如果还没有正确关联到目标显示器,webPreferences 里的 devicePixelRatio 就可能是错的。我查了很久,没找到在创建窗口前强制指定 DPI 的方法。
我的 workaround 很粗暴但有效:恢复窗口后,立刻根据当前显示器的实际 DPI 重新矫正一次尺寸。
function correctDpiMismatch(win) { const display = screen.getDisplayMatching(win.getBounds()); const scale = display.scaleFactor || 1; if (scale !== 1) { const [w, h] = win.getSize(); // 如果保存的尺寸明显大于显示器工作区,按 DPI 反推 const work = display.workArea; if (w > work.width || h > work.height) { win.setSize(Math.round(w / scale), Math.round(h / scale)); } }}
这法子不优雅,但确实把问题按住了。我怀疑是 Chromium 在 OpenHarmony 显示后端上的某个 bug,等官方修估计得半年。
第四坑:多显示器拔插后,窗口坐标变成负数的鬼故事
最诡异的一次,用户反馈说他笔记本外接了一个显示器,把应用拖到扩展屏右上角,关闭。第二天到公司没接显示器,打开应用——窗口没了。任务栏上有图标,Alt+Tab 也看得到,但屏幕上没有任何东西。
我远程一看,窗口坐标变成了 x: 3120, y: -20。他笔记本屏幕只有 1920×1080,窗口飞到了完全不存在的坐标上。更糟的是,鸿蒙 PC 上 win.center() 有时候也不管用,如果窗口的 x 是负数或者超出范围,center() 计算出来的新坐标还是错的。
这问题的根源是:鸿蒙桌面在显示器热插拔时,不会自动把游离窗口拉回可见区域。Windows 和 macOS 都有这个保护机制,鸿蒙目前(至少我测的 5.0.1 版本)没有。
我的解决方案是在应用启动和显示器变化两个时机都做矫正:
function rescueOffscreenWindow(win) { const bounds = win.getBounds(); const displays = screen.getAllDisplays(); const valid = sanitizeBounds(bounds, displays); if (valid.x !== bounds.x || valid.y !== bounds.y) { // 如果修正了位置,先取消最大化状态再移动 if (win.isMaximized()) win.unmaximize(); win.setPosition(valid.x, valid.y); }}// 启动时app.whenReady().then(() => { const win = createWindow(); rescueOffscreenWindow(win);});// 显示器配置变化时screen.on('display-added', () => rescueOffscreenWindow(win));screen.on('display-removed', () => setTimeout(() => rescueOffscreenWindow(win), 500));
注意 display-removed 我用了 setTimeout(..., 500),因为前面提过,鸿蒙的显示子系统缓存刷新有延迟,立刻矫正可能还是用的旧显示器数据。
第五坑:JSON 文件保存路径在应用更新后变了
这个坑跟窗口状态本身无关,但直接影响功能。我把窗口状态存在 app.getPath('userData') 下的 window-state.json 里。鸿蒙 PC 上 Electron 应用的 userData 路径大概是 /data/app/el2/100/base/{bundleName}/database/{appName}。问题出在应用更新后,鸿蒙有时候会分配一个新的沙箱路径,老路径下的配置文件直接作废,窗口状态全部重置。
更坑的是,app.getPath('userData') 在更新前后返回的路径确实不一样。我不是鸿蒙系统专家,猜测和它的分布式应用管理策略有关。反正用户视角就是:更新了个版本,窗口又变回默认大小了。
最终我把配置文件移到了鸿蒙提供的 filesDir 下,通过 os.homedir() 拼接一个相对固定的路径:
const path = require('path');const os = require('os');function getStableConfigPath() { // 鸿蒙 PC 上优先用 home 下的固定目录,避免应用更新导致路径漂移 const home = os.homedir(); return path.join(home, '.config', 'my-electron-app', 'window-state.json');}
当然,这样脱离了鸿蒙推荐的沙箱目录,理论上不太合规。但至少功能稳了,后面再研究怎么在沙箱内做配置迁移。
完整封装
把上面这些兜底逻辑打包成一个 WindowStateManager,项目中直接复用:
// window-state-manager.jsconst { screen, BrowserWindow } = require('electron');const fs = require('fs');const path = require('path');class WindowStateManager { constructor(options = {}) { this.file = options.path || this._defaultPath(); this.state = this._read(); this.win = null; } _defaultPath() { const os = require('os'); return path.join(os.homedir(), '.config', 'my-app', 'window-state.json'); } _read() { try { return JSON.parse(fs.readFileSync(this.file, 'utf8')); } catch { return { width: 1200, height: 800, maximized: false }; } } _write() { const dir = path.dirname(this.file); if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); fs.writeFileSync(this.file, JSON.stringify(this.state, null, 2)); } apply(win) { this.win = win; const displays = screen.getAllDisplays(); const bounds = sanitizeBounds(this.state, displays); win.setBounds(bounds); if (this.state.maximized) { setImmediate(() => win.maximize()); } // 监听变化 const save = () => { this.state = { ...win.getNormalBounds(), maximized: win.isMaximized() }; this._write(); }; win.on('resize', save); win.on('move', save); win.on('closed', save); // 显示器变化时救援 screen.on('display-removed', () => { setTimeout(() => rescueOffscreenWindow(win), 500); }); }}module.exports = { WindowStateManager };
主进程里三行代码搞定:
const { WindowStateManager } = require('./window-state-manager');const stateManager = new WindowStateManager();const win = new BrowserWindow({ width: 1200, height: 800, show: false });stateManager.apply(win);
写在最后
说实话,窗口状态保存这个需求,我之前从来没把它当回事。Windows 上 electron-window-state 一把梭,Mac 上系统自己帮你管得好好的。鸿蒙 PC 作为新平台,很多细节确实还没打磨到位,尤其是多显示器和 DPI 这一块。现在我的每个鸿蒙项目都会把这个 WindowStateManager 塞进去,宁可过度设计也不想再被用户骂"窗口又不见了"。
如果你也在做 Electron 鸿蒙桌面应用,建议尽早把窗口状态管理加上,而且要带够兜底。别像我一样,等用户报 bug 了再连夜填坑。
本文代码遵循 MIT 协议。转载请注明出处,商业用途请联系作者获取授权。