Electron 鸿蒙 PC 上点关闭就真退出了?我花了两天才把“最小化到托盘“做稳

上周把雷达鸭桌面版迁到鸿蒙 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 协议,转载请注明出处。

相关推荐
在书中成长2 小时前
HarmonyOS 小游戏《对战五子棋》开发第2篇-AppScope与entry模块
华为·harmonyos
●VON3 小时前
HarmonyKit | 鸿蒙新特性对比:Tabs vs HdsTabs 选型深度解析
华为·harmonyos·鸿蒙·新特性
binbin_523 小时前
React Native 鸿蒙环境搭建完整实战:从 RN 工程到 RNOH 运行
深度学习·react native·react.js·harmonyos
最爱老式锅包肉3 小时前
《HarmonyOS技术精讲-Core Speech Kit(基础语音服务)》第1篇:音频管理基础与语音服务入门
华为·音视频·harmonyos
最爱老式锅包肉3 小时前
HarmonyOS技术精讲-Speech Kit(场景化语音服务):AI字幕控件赋能实时语音识别与显示
语音识别·harmonyos
千逐684 小时前
Uniapp 鸿蒙实战之 AtomGit APP - 首页设计与仓库列表实现
华为·uni-app·harmonyos
binbin_524 小时前
Flutter 调用鸿蒙原生组件:MethodChannel 与 PlatformView 的选择和落地
开发语言·深度学习·flutter·harmonyos
●VON4 小时前
HarmonyKit | 鸿蒙新特性规范:10 个工具页 UI 一致性设计系统
ui·华为·harmonyos·鸿蒙·新特性
木木子2215 小时前
# 待办事项应用深度解析:ForEach 列表渲染与 CRUD 操作实战
windows·华为·harmonyos