Electron 鸿蒙 PC 上点关闭就真退出了?我花了两天才把“最小化到托盘“做稳
上周把雷达鸭桌面版迁到鸿蒙 PC 上跑,同事试了一下,当头一棒:“点右上角关闭,应用直接没了?”
我愣了一下。在 Windows 和 macOS 上,这套"关闭到托盘"的逻辑跑了小半年,从来没出过事。用户点关闭,窗口隐藏,托盘图标还在,右键能退出,左键能唤回。结果到鸿蒙 PC 上,这一套跟纸糊的一样,一戳就破。
鸿蒙 PC 的桌面环境对 Electron 来说,底层走的是类 Linux 那套。但"类 Linux"不是"就是 Linux",很多你以为理所应当的行为,它偏偏给你整点惊喜。
第一个惊喜是window.on('close', ...)。
我原先的代码大概长这样,应该很多人都写过:
// main.tsimport{BrowserWindow,app}from'electron';letmainWindow:BrowserWindow|null=null;letisQuitting=false;functioncreateWindow(){mainWindow=newBrowserWindow({width:1200,height:800,webPreferences:{preload:'./preload.js',},});mainWindow.on('close',(event)=>{if(!isQuitting){event.preventDefault();mainWindow?.hide();}});}app.on('before-quit',()=>{isQuitting=true;});这段代码在 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 上如果不拦这一层,用户从任务栏右键"关闭窗口"或者按 Alt+F4,照样能把进程带走。拦了之后,想真正退出,只能走托盘菜单里的"退出"。
接下来是托盘图标。这个坑更隐蔽。
我原先用的图标路径是./assets/tray-icon.png。在 Windows 开发环境打包后没问题,在鸿蒙 PC 上运行,托盘区域一片空白,连个占位符都没有。
Electron 的Tray在 Linux 系列桌面上对图标格式和路径很敏感。我踩了一圈,发现两个点:
第一,最好把图标资源放到nativeImage里显式加载,不要直接传字符串路径。第二,鸿蒙 PC 对 32×32 的 PNG 支持较好,但颜色通道最好不带 Alpha 的奇怪预处理,否则显示出来是一团黑。
// main.ts —— 托盘图标创建(兼容鸿蒙 PC)import{Tray,nativeImage,Menu}from'electron';importpathfrom'path';lettray:Tray|null=null;functioncreateTray(){consticonPath=path.join(process.env.NODE_ENV==='development'?__dirname:process.resourcesPath,'assets','tray-icon@2x.png');consticon=nativeImage.createFromPath(iconPath).resize({width:32,height:32,});tray=newTray(icon);constcontextMenu=Menu.buildFromTemplate([{label:'显示主窗口',click:()=>{mainWindow?.show();mainWindow?.focus();},},{type:'separator'},{label:'退出',click:()=>{isQuitting=true;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}from'electron';importpathfrom'path';letmainWindow:BrowserWindow|null=null;lettray:Tray|null=null;letisQuitting=false;functiongetAssetPath(...parts:string[]){constbase=process.env.NODE_ENV==='development'?__dirname:process.resourcesPath;returnpath.join(base,'assets',...parts);}functioncreateWindow(){mainWindow=newBrowserWindow({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(){consticon=nativeImage.createFromPath(getAssetPath('tray-icon@2x.png')).resize({width:32,height:32});tray=newTray(icon);tray.setToolTip('雷达鸭');constmenu=Menu.buildFromTemplate([{label:'显示主窗口',click:()=>{mainWindow?.show();mainWindow?.focus();},},{type:'separator'},{label:'退出',click:()=>{isQuitting=true;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}from'electron';importpathfrom'path';importfsfrom'fs';constuserDataPath=app.getPath('userData');constconfigPath=path.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 协议,转载请注明出处。