Electron 鸿蒙 PC 上点关闭就真退出了?我花了两天才把“最小化到托盘“做稳 上周把雷达鸭桌面版迁到鸿蒙 PC 上跑同事试了一下当头一棒“点右上角关闭应用直接没了”我愣了一下。在 Windows 和 macOS 上这套关闭到托盘的逻辑跑了小半年从来没出过事。用户点关闭窗口隐藏托盘图标还在右键能退出左键能唤回。结果到鸿蒙 PC 上这一套跟纸糊的一样一戳就破。鸿蒙 PC 的桌面环境对 Electron 来说底层走的是类 Linux 那套。但类 Linux不是就是 Linux很多你以为理所应当的行为它偏偏给你整点惊喜。第一个惊喜是window.on(close, ...)。我原先的代码大概长这样应该很多人都写过// main.tsimport{BrowserWindow,app}fromelectron;letmainWindow:BrowserWindow|nullnull;letisQuittingfalse;functioncreateWindow(){mainWindownewBrowserWindow({width:1200,height:800,webPreferences:{preload:./preload.js,},});mainWindow.on(close,(event){if(!isQuitting){event.preventDefault();mainWindow?.hide();}});}app.on(before-quit,(){isQuittingtrue;});这段代码在 Windows 上没问题。在鸿蒙 PC 上关闭窗口那一瞬间进程直接没了。event.preventDefault()像是被无视了一样。我一开始怀疑是isQuitting状态没传对加了日志发现before-quit根本来不及触发应用就已经在退出了。也就是说鸿蒙 PC 的窗口管理器在某些情况下直接把窗口进程一起带走了不管你 Electron 主进程同不同意。这是第一个坑你不能只依赖close事件。得做两层兜底。我换了个思路把关闭重新定义为最小化而不是去拦截关闭。听起来绕但反而稳了// main.ts —— 重新定义关闭为最小化mainWindow.on(close,(event){if(!isQuitting){event.preventDefault();mainWindow?.hide();}});// 再加一层拦截所有退出意图app.on(before-quit,(event){if(!isQuitting){event.preventDefault();mainWindow?.hide();}});是的我把before-quit也拦截了。有人会觉得这太极端但我实测下来在鸿蒙 PC 上如果不拦这一层用户从任务栏右键关闭窗口或者按 AltF4照样能把进程带走。拦了之后想真正退出只能走托盘菜单里的退出。接下来是托盘图标。这个坑更隐蔽。我原先用的图标路径是./assets/tray-icon.png。在 Windows 开发环境打包后没问题在鸿蒙 PC 上运行托盘区域一片空白连个占位符都没有。Electron 的Tray在 Linux 系列桌面上对图标格式和路径很敏感。我踩了一圈发现两个点第一最好把图标资源放到nativeImage里显式加载不要直接传字符串路径。第二鸿蒙 PC 对 32×32 的 PNG 支持较好但颜色通道最好不带 Alpha 的奇怪预处理否则显示出来是一团黑。// main.ts —— 托盘图标创建兼容鸿蒙 PCimport{Tray,nativeImage,Menu}fromelectron;importpathfrompath;lettray:Tray|nullnull;functioncreateTray(){consticonPathpath.join(process.env.NODE_ENVdevelopment?__dirname:process.resourcesPath,assets,tray-icon2x.png);consticonnativeImage.createFromPath(iconPath).resize({width:32,height:32,});traynewTray(icon);constcontextMenuMenu.buildFromTemplate([{label:显示主窗口,click:(){mainWindow?.show();mainWindow?.focus();},},{type:separator},{label:退出,click:(){isQuittingtrue;app.quit();},},]);tray.setToolTip(雷达鸭);tray.setContextMenu(contextMenu);}注意process.resourcesPath和__dirname的分支。在鸿蒙 PC 打包后的环境里__dirname指向的是应用内部的一个临时挂载点开发和生产的路径规则不一样。我第一次没做这个分支生产包死活找不到图标。还有一个细节nativeImage.createFromPath().resize()这一步最好别省。Electron 在 Linux 托盘里传一个过大或者尺寸不标准的图标有些桌面环境会直接拒绝渲染而不是缩放。我先前传了一个 512×512 的图标进去结果托盘区域一片空白调了半天才发现是尺寸问题。resize 到 32×32 之后至少能保证托盘宿主愿意显示它。然后是最折磨人的第三个坑托盘点击事件。在 Windows 上左键单击托盘图标窗口就弹出来。在 macOS 上习惯是双击。在鸿蒙 PC 上我绑了tray.on(click, ...)结果点烂了图标都没反应。查了半天 Electron 文档看到一段话差点没背过气去“StatusNotifierItem 规范没有规定哪个操作会触发 activation某些桌面环境下 click 事件不会触发。”翻译成人话在类 Linux 桌面上你的左键单击不保证能收到。我最终的兜底方案是左键单击做尝试右键菜单做保底同时监听double-click// main.ts —— 托盘点击兼容层functionbindTrayEvents(){if(!tray)return;// 鸿蒙 PC 上 click 不保证触发尽量别只依赖它tray.on(click,(){toggleWindow();});tray.on(double-click,(){toggleWindow();});// 右键菜单永远稳作为保底tray.on(right-click,(){tray?.popUpContextMenu();});}functiontoggleWindow(){if(!mainWindow){createWindow();return;}if(mainWindow.isVisible()mainWindow.isFocused()){mainWindow.hide();}else{mainWindow.show();mainWindow.focus();}}这一套组合拳上去鸿蒙 PC 上终于能用了。单击不响应的用户至少右键菜单能唤出窗口单击响应的体验跟 Windows 一样。测这个的时候我建议用真机别只在 DevEco Studio 的远程模拟器上跑。模拟器里的托盘行为和真机差异很大我在模拟器上 click 事件正常到真机上就不行白高兴一场。后来凡是涉及系统托盘、窗口生命周期、全局快捷键这类跟桌面环境强相关的功能我都直接上真机验证。说实话这三个坑单独看都不难但堆在一起就特别容易让人怀疑人生。尤其是你明明在 Windows 上验证过一百遍的代码换到鸿蒙 PC 就跟没写一样这种落差很消磨人。我把整个流程串起来写成一个bootstrapMainWindow函数方便以后复制// main.ts —— 完整主进程初始化Electron 鸿蒙 PC 兼容版import{app,BrowserWindow,Tray,Menu,nativeImage}fromelectron;importpathfrompath;letmainWindow:BrowserWindow|nullnull;lettray:Tray|nullnull;letisQuittingfalse;functiongetAssetPath(...parts:string[]){constbaseprocess.env.NODE_ENVdevelopment?__dirname:process.resourcesPath;returnpath.join(base,assets,...parts);}functioncreateWindow(){mainWindownewBrowserWindow({width:1200,height:800,show:false,webPreferences:{preload:getAssetPath(..,preload.js),},});mainWindow.loadURL(https://your-app-url.com);mainWindow.on(ready-to-show,(){mainWindow?.show();});mainWindow.on(close,(event){if(!isQuitting){event.preventDefault();mainWindow?.hide();}});}functioncreateTray(){consticonnativeImage.createFromPath(getAssetPath(tray-icon2x.png)).resize({width:32,height:32});traynewTray(icon);tray.setToolTip(雷达鸭);constmenuMenu.buildFromTemplate([{label:显示主窗口,click:(){mainWindow?.show();mainWindow?.focus();},},{type:separator},{label:退出,click:(){isQuittingtrue;app.quit();},},]);tray.setContextMenu(menu);tray.on(click,()mainWindow?.show());tray.on(double-click,()mainWindow?.show());tray.on(right-click,()tray?.popUpContextMenu());}app.on(before-quit,(event){if(!isQuitting){event.preventDefault();mainWindow?.hide();}});app.on(activate,(){if(!mainWindow){createWindow();}else{mainWindow.show();}});app.whenReady().then((){createWindow();createTray();});有一点我要说明这个方案不是优雅的。它同时处理了 Windows、macOS、Linux、鸿蒙 PC 四者的差异代码里带了明显的 platform-aware 味道。有人可能会说应该按平台拆分得更干净。我同意但那是在你确认每个平台行为都稳定之后的事。在适配鸿蒙 PC 的早期先把功能跑起来比代码漂亮重要一百倍。另外鸿蒙 PC 的应用市场分发机制对 Electron 应用还有一个隐藏影响每次更新后应用的资源路径可能会变。如果你的托盘图标、缓存文件、配置文件都写死了绝对路径升级一次就找不到一次。我用app.getPath(userData)存配置用process.resourcesPath读资源才算把这个问题压住。// 配置文件存到用户数据目录别跟安装包绑死import{app}fromelectron;importpathfrompath;importfsfromfs;constuserDataPathapp.getPath(userData);constconfigPathpath.join(userDataPath,app-config.json);functionreadConfig(){if(!fs.existsSync(configPath))return{};returnJSON.parse(fs.readFileSync(configPath,utf-8));}这么做还有一个好处卸载应用时不会把用户配置一起清掉重新安装后偏好设置还在。对桌面应用来说这比什么花里胡哨的功能都实在。雷达鸭桌面版现在就是这么跑的。点关闭窗口缩到托盘右键托盘能退出也能显示升级后图标和配置都不丢。看起来是桌面应用的基本功但在鸿蒙 PC 上确实让我掉了不少头发。你最近在鸿蒙 PC 上跑 Electron 时有没有遇到过什么明明代码没变行为却变了的诡异问题关于作者老三10 年以上软件开发经验软件设计师、人工智能应用工程师。目前主要做鸿蒙应用ArkTS北向开发和 Web 前端也在折腾 AI 自动化。偶尔在 CSDN 写写鸿蒙和 AI 方向的技术笔记。本文遵循 MIT 协议转载请注明出处。