前言

在将 Electron 应用适配到鸿蒙 PC 平台时,状态栏(StatusBar)菜单遇到了嵌套层级限制问题:鸿蒙系统的状态栏菜单不支持深层嵌套,导致多级菜单无法正常显示。为了解决这个问题,我们实现了一套菜单嵌套扁平化方案,通过递归处理将多级菜单转换为扁平结构,同时通过 menuCoderightMenuClick 事件实现点击处理。

本文将详细记录这个解决方案的实现原理、代码细节和最佳实践,帮助开发者理解并应用这一方案。

关键词:鸿蒙PC、Electron适配、状态栏菜单、菜单嵌套、扁平化、menuCode、click事件

在这里插入图片描述

目录

  1. 问题现象与限制分析
  2. 菜单扁平化方案设计
  3. 完整实现方案
  4. Click事件处理机制
  5. 最佳实践与注意事项
  6. 常见问题解答
  7. 总结与展望

欢迎加入开源鸿蒙PC社区:https://harmonypc.csdn.net/

问题现象与限制分析

1.1 问题现象

在鸿蒙 PC 上使用状态栏菜单时,遇到以下问题:

错误现象

  • 多级嵌套菜单无法正常显示
  • 子菜单的子菜单(三级及以上)显示异常
  • 菜单结构复杂时,部分菜单项丢失

表现

原始菜单结构:
文件 (File)
  ├─ 新建 (New)
  │   ├─ 文档 (Document)  ❌ 无法显示
  │   └─ 文件夹 (Folder)  ❌ 无法显示
  └─ 打开 (Open)
      └─ 最近文件 (Recent)  ❌ 无法显示
          ├─ 文件1  ❌ 无法显示
          └─ 文件2  ❌ 无法显示

1.2 鸿蒙系统限制

根据 HarmonyOS 状态栏扩展能力文档,状态栏菜单的限制:

限制说明

  • ✅ 支持一级菜单(主菜单项)
  • ✅ 支持二级菜单(子菜单)
  • 不支持三级及以上菜单嵌套

技术原因

  • 状态栏菜单的 UI 组件限制
  • 系统级别的菜单层级限制
  • 无法通过配置绕过

菜单扁平化方案设计

2.1 核心思路

扁平化策略

  • 将多级嵌套菜单转换为二级菜单结构
  • 子菜单的 title 移到 subTitle
  • 通过 menuCode 标识每个菜单项的唯一ID
  • 点击时通过 menuCode 识别具体菜单项

转换示例

转换前(三级嵌套)

{
  "label": "文件",
  "submenu": [
    {
      "label": "新建",
      "submenu": [
        {"label": "文档", "commandId": 1},
        {"label": "文件夹", "commandId": 2}
      ]
    }
  ]
}

转换后(扁平化)

{
  "title": "文件",
  "subMenu": [
    {
      "title": "新建",
      "subMenu": [
        {
          "subTitle": "文档",  // title 移到 subTitle
          "menuCode": "1"
        },
        {
          "subTitle": "文件夹",
          "menuCode": "2"
        }
      ]
    }
  ]
}

2.2 方案架构

原始菜单数据(JSON)
    ↓
transformJsonToMenus()  // 转换为 StatusBarMenuItem
    ↓
processData()  // 扁平化处理
    ↓
recurse()  // 递归处理嵌套菜单
    ↓
扁平化后的菜单数据
    ↓
updateStatusBarMenu()  // 更新状态栏菜单
    ↓
用户点击菜单
    ↓
rightMenuClick 事件
    ↓
通过 menuCode 识别菜单项
    ↓
执行对应操作

完整实现方案

3.1 菜单数据转换

transformMenuItem 函数:将 Electron 菜单数据转换为鸿蒙状态栏菜单格式

// StatusBarManager.ets

private transformMenuItem(jsonItem: MenuRawData): statusBarManager.StatusBarMenuItem | statusBarManager.StatusBarSubMenuItem {
  // 创建菜单动作,使用 commandId 作为 menuCode
  const menuAction: statusBarManager.StatusBarMenuAction = {
    abilityName: 'StatusBarEntryAbility',
    moduleName: 'entry',
    notifyOnly: true,
    menuCode: jsonItem.commandId?.toString()  // ⚠️ 关键:使用 commandId 作为 menuCode
  };

  const menuItem: statusBarManager.StatusBarMenuItem = {
    title: jsonItem.label as string,
    menuAction: menuAction
  };

  // 递归处理子菜单
  if (jsonItem.submenu && jsonItem.submenu.length > 0) {
    menuItem.subMenu = jsonItem.submenu.map((subItem) => 
      this.transformMenuItem(subItem) as statusBarManager.StatusBarSubMenuItem
    );
  }

  return menuItem;
}

关键点

  • ✅ 使用 commandId 作为 menuCode,用于点击识别
  • ✅ 递归处理子菜单,支持任意层级
  • ✅ 保持菜单结构,为后续扁平化做准备

3.2 菜单扁平化处理

recurse 函数:递归处理菜单嵌套,将子菜单的 title 移到 subTitle

// StatusBarManager.ets

private recurse(items: MenuItem[] | undefined, isTopLevel: boolean = true): undefined | MenuItem[] {
  if (items === undefined) {
    return items;
  }
  
  items.map((item: MenuItem) => {
    // ⚠️ 关键:如果不是顶级菜单,将 title 移到 subTitle
    if (!isTopLevel) {
      item.subTitle = item.title;  // 保存原始标题到 subTitle
      item.title = undefined;      // 清空 title
    }
  
    // 递归处理子菜单
    this.recurse(item.subMenu, false);
  });
  
  return items;
}

处理逻辑

  • ✅ 顶级菜单(isTopLevel = true):保持 title 不变
  • ✅ 子菜单(isTopLevel = false):将 title 移到 subTitle,清空 title
  • ✅ 递归处理所有层级的子菜单

processData 函数:调用递归函数处理菜单数据

// StatusBarManager.ets

private processData(rawData: statusBarManager.StatusBarMenuItem[]): MenuItem[] {
  // 深拷贝数据,避免修改原始数据
  let modifiedData: MenuItem[] = JSON.parse(JSON.stringify(rawData)) as MenuItem[];
  
  // 调用递归函数扁平化处理
  return this.recurse(modifiedData) as MenuItem[];
}

3.3 菜单设置流程

SetContextMenu 函数:设置状态栏菜单的完整流程

// StatusBarManager.ets

@LogMethod
SetContextMenu(menu_model: string, onCompleted: (ret: boolean) => void, callback: (id: Number) => void) {
  LogUtil.debug(TAG, `JS SetContextMenu:: ${menu_model}`);
  
  try {
    // 1. 解析 JSON 菜单数据
    let menu_json: MenuRawData[] = JSON.parse(menu_model) as MenuRawData[];
  
    // 2. 转换为鸿蒙菜单格式
    const items: statusBarManager.StatusBarMenuItem[] = this.transformJsonToMenus(menu_json);
  
    // 3. 扁平化处理
    const modified_items: statusBarManager.StatusBarMenuItem[] = this.processData(items) as statusBarManager.StatusBarMenuItem[];
  
    // 4. 包装为菜单组
    const menus: statusBarManager.StatusBarGroupMenu[] = [modified_items];
  
    // 5. 更新状态栏菜单
    try {
      statusBarManager.updateStatusBarMenu(
        this.ctxAdapter.getActiveContext(), 
        menus, 
        () => {
          onCompleted && onCompleted(true);
        }
      );
    } catch (err) {
      LogUtil.info(TAG, `updateStatusBarMenu failed. err: ${JSON.stringify(err)}`);
      onCompleted && onCompleted(false);
    }
  
    // 6. 保存点击回调函数
    this.rightMenuCallback = callback;
  } catch (err) {
    LogUtil.error(TAG, 'SetContextMenu Error: ' + err);
    onCompleted && onCompleted(false);
  }
}

Click事件处理机制

4.1 事件监听注册

在 SetImage 函数中注册事件监听

// StatusBarManager.ets

statusBarManager.addToStatusBar(this.ctxAdapter.getActiveContext(), {
  icons: { /* ... */ },
  quickOperation: { /* ... */ },
  statusBarGroupMenu: [],
}, () => {
  // 注册左键点击事件
  this.leftClickCallback = callback;
  statusBarManager.on('statusBarIconClick', () => {
    this.leftClickCallback?.();
  });
  
  // ⚠️ 关键:注册右键菜单点击事件
  let rightMenuClickCallback = (eventData: emitter.EventData) => {
    LogUtil.info(TAG, `rightMenuClickCallback is called. menuCode: ${eventData?.data?.menuCode}`);
  
    // 通过 menuCode 调用回调函数
    this.rightMenuCallback?.(Number(eventData?.data?.menuCode));
  }
  
  statusBarManager.on('rightMenuClick', rightMenuClickCallback);
  
  this.isCreated = true;
  onCompleted && onCompleted(true);
});

关键点

  • ✅ 监听 rightMenuClick 事件
  • ✅ 从 eventData.data.menuCode 获取菜单项ID
  • ✅ 调用保存的回调函数,传递 menuCode

4.2 menuCode 传递流程

完整流程

Electron 菜单数据
    ↓
commandId: 123  // Electron 菜单项的 commandId
    ↓
transformMenuItem()
    ↓
menuCode: "123"  // 转换为字符串作为 menuCode
    ↓
updateStatusBarMenu()  // 设置到状态栏
    ↓
用户点击菜单项
    ↓
rightMenuClick 事件触发
    ↓
eventData.data.menuCode = "123"
    ↓
rightMenuCallback(123)  // 调用回调,传递 menuCode
    ↓
Electron 主进程处理菜单命令

4.3 事件清理

RemoveFromStatusBar 函数:移除事件监听

// StatusBarManager.ets

@LogMethod
async RemoveFromStatusBar(onCompleted: (ret: boolean) => void) {
  try {
    statusBarManager.removeFromStatusBar(this.ctxAdapter.getActiveContext(), () => {
      this.isCreated = false;
      onCompleted && onCompleted(true);
    });
  } catch (err) {
    LogUtil.info(TAG, `removeFromStatusBar failed. err: ${JSON.stringify(err)}`);
    onCompleted && onCompleted(false);
  } finally {
    // ⚠️ 关键:清理事件监听
    statusBarManager.off('statusBarIconClick');
    statusBarManager.off('rightMenuClick');
  }
}

最佳实践与注意事项

5.1 menuCode 设计原则

推荐做法

// ✅ 好:使用唯一的 commandId 作为 menuCode
menuCode: jsonItem.commandId?.toString()

// ❌ 不好:使用重复的 menuCode
menuCode: "1"  // 多个菜单项使用相同的 menuCode

注意事项

  • ✅ 确保每个菜单项有唯一的 commandId
  • menuCode 必须是字符串类型
  • ✅ 如果没有 commandId,需要生成唯一ID

5.2 菜单扁平化最佳实践

推荐做法

// ✅ 好:深拷贝数据后再处理
let modifiedData: MenuItem[] = JSON.parse(JSON.stringify(rawData)) as MenuItem[];

// ❌ 不好:直接修改原始数据
let modifiedData = rawData;  // 会修改原始数据

注意事项

  • ✅ 使用深拷贝避免修改原始数据
  • ✅ 递归处理所有层级的子菜单
  • ✅ 保持菜单结构,只调整 title/subTitle

5.3 事件处理最佳实践

推荐做法

// ✅ 好:保存回调函数,在事件触发时调用
this.rightMenuCallback = callback;
statusBarManager.on('rightMenuClick', (eventData) => {
  this.rightMenuCallback?.(Number(eventData?.data?.menuCode));
});

// ❌ 不好:直接在事件中处理业务逻辑
statusBarManager.on('rightMenuClick', (eventData) => {
  // 直接处理业务逻辑,耦合度高
});

注意事项

  • ✅ 使用回调函数模式,解耦事件处理和业务逻辑
  • ✅ 检查回调函数是否存在再调用
  • ✅ 正确转换 menuCode 类型(字符串转数字)

常见问题解答

Q1: 为什么需要扁平化菜单?

A: 鸿蒙系统的状态栏菜单不支持三级及以上嵌套,必须将多级菜单扁平化为二级结构才能正常显示。

Q2: menuCode 的作用是什么?

A: menuCode 用于标识每个菜单项的唯一ID,当用户点击菜单时,通过 menuCode 识别具体是哪个菜单项被点击,然后执行对应的操作。

Q3: 扁平化后菜单结构会丢失吗?

A: 不会。扁平化只是将子菜单的 title 移到 subTitle,菜单的层级结构仍然保留,只是显示方式改变。

Q4: 如何处理没有 commandId 的菜单项?

A: 需要为每个菜单项生成唯一的ID,可以使用自增ID或者基于菜单路径生成唯一标识。


总结与展望

7.1 核心要点总结

通过本文的深入分析,我们了解到:

  1. 菜单扁平化的必要性:鸿蒙系统限制导致必须扁平化多级菜单
  2. 递归处理机制:通过 recurse() 函数递归处理所有层级的菜单
  3. menuCode 标识机制:使用 commandId 作为 menuCode 标识菜单项
  4. Click事件处理:通过 rightMenuClick 事件和 menuCode 实现点击处理

7.2 技术价值

这个解决方案不仅解决了菜单嵌套显示问题,还带来了以下好处:

  • 兼容鸿蒙系统限制:通过扁平化适配系统限制
  • 保持菜单功能:所有菜单项都能正常显示和点击
  • 代码可维护性:清晰的递归处理逻辑,易于理解和维护

7.3 适用场景

这套方案适用于:

  • ✅ 所有使用状态栏菜单的 Electron 应用
  • ✅ 菜单结构复杂的应用
  • ✅ 在鸿蒙 PC 上运行的 Electron 应用
  • ✅ 需要多级菜单嵌套的应用

相关资源

HarmonyOS 官方文档

Electron 官方文档

Logo

赋能鸿蒙PC开发者,共建全场景原生生态,共享一次开发多端部署创新价值。

更多推荐