我做的 App 叫雷达鸭,鸿蒙桌面版用的是 Electron。上个月给应用加了个设置弹窗------就是个简单的 BrowserWindow,点了按钮弹出来改配置,改完关掉完事。逻辑上简单得不能再简单。
结果关了弹窗之后,主窗口整个卡死了。
不是进程崩了,DevTools 还能连上,console 也没报错。但就是点什么都没反应------按钮点不了、列表滚不动、键盘快捷键全部哑火。刷新一下 electron 进程就好了。这问题复现率还不低,关个三四次弹窗必中一次。
我当时第一反应是内存泄漏。打开鸿蒙的 DevEco Profiler 看了半天,内存曲线平稳得跟死了一样。然后怀疑是渲染进程的事件循环挂了------给 webContents 执行了一个 console.log(new Date()),打印正常。renderer 进程的 JS 引擎根本没挂。
那就是主进程的问题了。
我仔细看了一圈,注意到设置弹窗创建时传了一个 parent 参数:
typescript
// 主进程 main.ts
const settingsWin = new BrowserWindow({
width: 480,
height: 600,
parent: mainWindow, // 就这行,后面会说到为什么
modal: false,
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
contextIsolation: true,
},
});
settingsWin.loadFile('settings.html');
这个 parent 参数在 Windows 和 macOS 上是个好东西------子窗口永远浮在父窗口上方,不用自己管理 z-order,体验很自然。但在鸿蒙 PC 上,它触发了一个埋得很深的坑。
我把 parent: mainWindow 删了,问题马上消失。但我不想删------弹窗老掉到底下去被主窗口盖住,体验太拉胯了。来回切窗口找设置弹窗,用户能忍我可忍不了。
于是换了个思路:在子窗口关闭前主动解除父子关系。
typescript
// 防御性修复:关闭前先解绑 parent
settingsWin.on('close', () => {
if (process.platform === 'linux') {
settingsWin.setParentWindow(null);
}
});
就一行 setParentWindow(null)。问题消失了。弹窗关掉,主窗口一切正常,点什么都有反应。
但你真觉得这样就踏实了?不。我遇到过太多"看起来修好了但其实只是运气好"的情况。这问题复现率不是 100%------关三四次才中一次------说明背后有个竞态条件或者非确定性逻辑。你永远不知道什么边缘场景会让它再次触发。
为了确认这不是我本地环境的偶然问题,我在三台不同的鸿蒙 PC 设备上跑了同一个测试脚本:反复打开弹窗、关闭、检查主窗口是否响应。三台设备都出现了冻结,而且有个很有意思的规律------如果弹窗打开超过 30 秒再关闭,冻结概率从约 30% 飙升到接近 70%。弹窗存活越久,越容易触发问题。
这说明不是简单的"子窗口关闭漏了通知"------它跟窗口的生命周期时序有关。
说到这你可能觉得工作已经做完了------加一行 workaround,代码能跑,收工。但我这人有个毛病,遇到这种"平台专供 bug"非得搞清楚下面到底发生了什么。加 workaround 但不理解原理,等于背了个你不知道什么时候会炸的定时炸弹。
我决定翻 Chromium 源码。
Electron 在 Linux / 鸿蒙平台上跑的是 Chromium 的 Ozone/Mus 窗口系统。父窗口"卡死"的本质是事件分发器被标记成了 modal_blocked_ 状态。正常情况下,子窗口销毁时应该通知父窗口一声"我不再模态阻塞你了",父窗口清理标志位,事件循环恢复正常。
在 Chromium 的通用代码里,这个逻辑是这样的:
cpp
// chromium/src/ui/aura/window_tree_host.cc(简化后)
void WindowTreeHost::OnWindowDestroying(Window* window) {
if (window->parent()) {
window->parent()->OnModalStateChanged(false);
}
}
逻辑很直白:子窗口挂了 → 通知父窗口解除模态状态 → 父窗口恢复事件分发。
但鸿蒙的 WindowTreeHostMus 覆写了这个方法。鸿蒙的窗口合成器(WindowManagerService)自己管着所有窗口的层级关系和生命周期------这和传统 X11/Wayland 的思路不一样。在标准 Wayland 下,客户端自己通过 wl_subsurface 和 xdg_popup 协议向合成器声明窗口之间的父子/模态关系,子窗口销毁时合成器会通过对应的事件通知父窗口。
鸿蒙的 WindowManagerService 不一样。它把窗口树的管理权完全收归服务端,Chromium 客户端侧拿到的是一个简化过的窗口句柄。子窗口在服务端被销毁时,服务端通知客户端的方式不是标准的 zaura_surface 模态状态变更事件,而是一个通用的窗口销毁通知。WindowTreeHostMus 收到这个通知后,去调用父窗口的 OnModalStateChanged,但此时父窗口的 aura::Window 对象上已经没有子窗口的引用了------child_window_ 已经是个悬空状态,条件判断 if (window->parent()) 直接走不进去。
翻译成人话就是:子窗口从服务端发来的通知里不包含"它曾经是谁的子窗口"这条信息,父窗口这端连自己有没有过子窗口都判断不了,更别说解除模态阻塞了。
我往下翻了几十行。在一个 // TODO(b/...) 注释里看到了这段:
cpp
// WindowTreeHostMus 源码中的注释(大意):
// TODO: Mus currently doesn't propagate OnModalStateChanged when
// a child window is destroyed from the server side. The parent
// window's event dispatcher may remain in modal_blocked_ state
// until the next top-level window event fires.
就是这行注释。一句 TODO,让我的主窗口在鸿蒙 PC 上愣是死了半个下午。而且我顺手 git log --follow 查了一下这个文件,这个 TODO 在好几年前的 commit 里就已经躺着了------保守估计三年以上。看着不像是近期会被修掉的样子。
说白了,这不是一个 bug,这是 Mus 窗口系统的设计取舍。鸿蒙团队选择把窗口树的管理全部收到服务端是为了更好的跨设备一致性和安全性,代价就是客户端侧拿不到足够精确的子窗口生命周期通知。这个 tradeoff 我能理解,但不代表用户买账。
你可能会问:那我干脆不用 parent 参数,换成 modal: true 不就行了?我试过。
typescript
// 方案 B:modal: true 替代 parent
const settingsWin = new BrowserWindow({
width: 480,
height: 600,
modal: true, // 用模态窗口替代 parent 关联
webPreferences: { /* ... */ },
});
这个方案也有毛病。modal: true 在鸿蒙 PC 上,某些机型的 nativeWindowHandle 会丢失焦点,按 ESC 关不了窗口。而且模态窗口会阻塞父窗口的全部输入------设置弹窗这种场景,用户经常需要一边看设置一边在主窗口里对一下数据,全部阻塞太粗暴了。
所以我最终还是选了 parent + 关闭前 setParentWindow(null) 的方案。没多优雅,但确实稳。
下面是我最终塞进工程里的完整兼容代码,直接复制就能用:
typescript
// harmonyos-window-helper.ts
import { BrowserWindow } from 'electron';
/**
* 鸿蒙 PC 上安全创建父子关联窗口
* 自动处理关闭时的 parent 解除,避免主窗口冻结
*/
export function createSafeChildWindow(
parent: BrowserWindow,
options: Electron.BrowserWindowConstructorOptions
): BrowserWindow {
const isHarmonyOS = process.platform === 'linux';
const child = new BrowserWindow({
...options,
parent,
});
if (isHarmonyOS) {
// 核心修复:关闭前解除父窗口关联
child.on('close', () => {
child.setParentWindow(null);
});
// 兜底:如果父窗口仍然卡住,5 秒后强制恢复
let frozenTimer: NodeJS.Timeout | null = null;
child.on('closed', () => {
frozenTimer = setTimeout(() => {
parent.focus();
parent.webContents.focus();
}, 5000);
});
// 如果父窗口正常恢复(没卡住),清掉兜底 timer
parent.on('focus', () => {
if (frozenTimer) {
clearTimeout(frozenTimer);
frozenTimer = null;
}
});
}
return child;
}
把这个 helper 塞到雷达鸭的工程里之后,弹窗关掉主窗口冻结的问题再没出现过。多窗口交互------设置面板、确认弹窗、详情预览这些场景------在鸿蒙 PC 上老老实实走这个 helper,别头铁硬扛。那行 Chromium 源码里的 TODO 注释不知道什么时候会被修掉,但在那之前,setParentWindow(null) 别忘了。
等鸿蒙 Next 正式发了,希望 WindowManagerService 的窗口生命周期回调能把这坑填上。
关于作者:
老三,10 年以上软件开发经验,软件设计师、人工智能应用工程师。当前专注鸿蒙 ArkTS 北向开发与 Web 前端,同时探索 AI 自动化在日常开发中的应用。不定期在 CSDN 分享鸿蒙 / Electron / AI 方向的技术踩坑笔记。
本文遵循 MIT 协议,转载请注明出处。