上周把雷达鸭桌面版迁到鸿蒙 PC 上跑,同事试了一下,当头一棒:"点右上角关闭,应用直接没了?"
我愣了一下。在 Windows 和 macOS 上,这套"关闭到托盘"的逻辑跑了小半年,从来没出过事。用户点关闭,窗口隐藏,托盘图标还在,右键能退出,左键能唤回。结果到鸿蒙 PC 上,这一套跟纸糊的一样,一戳就破。
鸿蒙 PC 的桌面环境对 Electron 来说,底层走的是类 Linux 那套。但"类 Linux"不是"就是 Linux",很多你以为理所应当的行为,它偏偏给你整点惊喜。
第一个惊喜是 window.on('close', ...)。
我原先的代码大概长这样,应该很多人都写过:
typescript
// main.ts
import { BrowserWindow, app } from 'electron';
let mainWindow: BrowserWindow | null = null;
let isQuitting = false;
function createWindow() {
mainWindow = new BrowserWindow({
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 事件。得做两层兜底。
我换了个思路,把"关闭"重新定义为"最小化",而不是去拦截关闭。听起来绕,但反而稳了:
typescript
// 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 的奇怪预处理,否则显示出来是一团黑。
typescript
// main.ts ------ 托盘图标创建(兼容鸿蒙 PC)
import { Tray, nativeImage, Menu } from 'electron';
import path from 'path';
let tray: Tray | null = null;
function createTray() {
const iconPath = path.join(
process.env.NODE_ENV === 'development'
? __dirname
: process.resourcesPath,
'assets',
'tray-icon@2x.png'
);
const icon = nativeImage.createFromPath(iconPath).resize({
width: 32,
height: 32,
});
tray = new Tray(icon);
const contextMenu = 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:
typescript
// main.ts ------ 托盘点击兼容层
function bindTrayEvents() {
if (!tray) return;
// 鸿蒙 PC 上 click 不保证触发,尽量别只依赖它
tray.on('click', () => {
toggleWindow();
});
tray.on('double-click', () => {
toggleWindow();
});
// 右键菜单永远稳,作为保底
tray.on('right-click', () => {
tray?.popUpContextMenu();
});
}
function toggleWindow() {
if (!mainWindow) {
createWindow();
return;
}
if (mainWindow.isVisible() && mainWindow.isFocused()) {
mainWindow.hide();
} else {
mainWindow.show();
mainWindow.focus();
}
}
这一套组合拳上去,鸿蒙 PC 上终于能用了。单击不响应的用户,至少右键菜单能唤出窗口;单击响应的,体验跟 Windows 一样。
测这个的时候我建议用真机,别只在 DevEco Studio 的远程模拟器上跑。模拟器里的托盘行为和真机差异很大,我在模拟器上 click 事件正常,到真机上就不行,白高兴一场。后来凡是涉及系统托盘、窗口生命周期、全局快捷键这类跟桌面环境强相关的功能,我都直接上真机验证。
说实话,这三个坑单独看都不难,但堆在一起就特别容易让人怀疑人生。尤其是你明明在 Windows 上验证过一百遍的代码,换到鸿蒙 PC 就跟没写一样,这种落差很消磨人。
我把整个流程串起来,写成一个 bootstrapMainWindow 函数,方便以后复制:
typescript
// main.ts ------ 完整主进程初始化(Electron 鸿蒙 PC 兼容版)
import { app, BrowserWindow, Tray, Menu, nativeImage } from 'electron';
import path from 'path';
let mainWindow: BrowserWindow | null = null;
let tray: Tray | null = null;
let isQuitting = false;
function getAssetPath(...parts: string[]) {
const base =
process.env.NODE_ENV === 'development' ? __dirname : process.resourcesPath;
return path.join(base, 'assets', ...parts);
}
function createWindow() {
mainWindow = new BrowserWindow({
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();
}
});
}
function createTray() {
const icon = nativeImage
.createFromPath(getAssetPath('tray-icon@2x.png'))
.resize({ width: 32, height: 32 });
tray = new Tray(icon);
tray.setToolTip('雷达鸭');
const menu = 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 读资源,才算把这个问题压住。
typescript
// 配置文件存到用户数据目录,别跟安装包绑死
import { app } from 'electron';
import path from 'path';
import fs from 'fs';
const userDataPath = app.getPath('userData');
const configPath = path.join(userDataPath, 'app-config.json');
function readConfig() {
if (!fs.existsSync(configPath)) return {};
return JSON.parse(fs.readFileSync(configPath, 'utf-8'));
}
这么做还有一个好处:卸载应用时不会把用户配置一起清掉,重新安装后偏好设置还在。对桌面应用来说,这比什么花里胡哨的功能都实在。
雷达鸭桌面版现在就是这么跑的。点关闭,窗口缩到托盘;右键托盘,能退出也能显示;升级后图标和配置都不丢。看起来是桌面应用的基本功,但在鸿蒙 PC 上确实让我掉了不少头发。
你最近在鸿蒙 PC 上跑 Electron 时,有没有遇到过什么"明明代码没变,行为却变了"的诡异问题?
关于作者:老三,10 年以上软件开发经验,软件设计师、人工智能应用工程师。目前主要做鸿蒙应用(ArkTS)北向开发和 Web 前端,也在折腾 AI 自动化。偶尔在 CSDN 写写鸿蒙和 AI 方向的技术笔记。
本文遵循 MIT 协议,转载请注明出处。