鸿蒙PC Dock 栏隐藏"打开新窗口"功能实现详解
问题背景
在鸿蒙 PC 平台上开发 Electron 应用时,dock 栏右键菜单默认会显示"打开新窗口"选项。对于单实例应用,需要完全隐藏这些选项,确保用户无法通过 dock 栏创建多个应用实例。
问题现象
在 dock 栏右键菜单中,通常会看到两个"打开新窗口"相关的选项:
- 系统默认的"打开新窗口" :由系统根据应用的
launchType自动生成 - 快捷方式定义的"打开新窗口" :由应用在
shortcuts_config.json中定义的快捷方式生成
这两个选项都会导致创建新的应用实例,对于单实例应用来说是不期望的行为。
解决方案
通过多层配置 + 代码拦截的方式,实现完全隐藏 dock 栏的"打开新窗口"功能。
完整实现步骤
步骤 1:应用级别配置(AppScope/app.json5)
设置应用为单实例模式:
json
{
"app": {
"bundleName": "com.nutpi.min",
"vendor": "example",
"versionCode": 1000000,
"versionName": "1.0.0",
"icon": "$media:app_icon",
"label": "$string:app_name",
"configuration": "$profile:configuration",
"multiAppMode": {
"multiAppModeType": "appClone",
"maxCount": 1
}
}
}
关键配置说明:
multiAppModeType: "appClone":应用克隆模式,但限制最大数量为 1maxCount: 1:最多只允许 1 个实例
注意 :也可以使用 "multiAppModeType": "singleInstance",但某些版本可能不支持,使用 appClone + maxCount: 1 更兼容。
步骤 2:Ability 级别配置(electron/src/main/module.json5)
设置 EntryAbility 为单例模式:
json
{
"module": {
"abilities": [
{
"name": "EntryAbility",
"srcEntry": "./ets/entryability/EntryAbility.ets",
"launchType": "singleton", // ✅ 关键:单例模式
"removeMissionAfterTerminate": true,
"exported": true,
"skills": [
{
"entities": [
"entity.system.home",
"entity.system.browsable"
],
"actions": [
"action.system.home",
"ohos.want.action.viewData"
],
"uris": []
}
]
// ✅ 移除了 shortcuts metadata 引用
}
]
}
}
关键配置说明:
launchType: "singleton":Ability 单例模式,系统不会创建新实例- 移除了
metadata中的ohos.ability.shortcuts引用:避免显示快捷方式菜单
步骤 3:移除快捷方式配置(shortcuts_config.json)
清空或删除快捷方式定义:
修改前:
json
{
"shortcuts": [
{
"shortcutId": "OpenNewWindow",
"label": "$string:OpenNewWindow_label",
"wants": [
{
"bundleName": "com.huawei.ohos_electron",
"moduleName": "electron",
"abilityName": "EntryAbility",
"parameters": {
"openInNewWindow": "true"
}
}
]
}
]
}
修改后:
json
{
"shortcuts": []
}
关键说明:
- 快捷方式配置会在 dock 栏右键菜单中生成额外的"打开新窗口"选项
- 清空
shortcuts数组后,自定义的快捷方式菜单项将不再显示
步骤 4:AbilityStage 层面拦截(WebAbilityStage.ets)
在 onAcceptWant 方法中拦截 openInNewWindow 参数:
typescript
onAcceptWant(want: Want): string {
LogUtil.info(TAG, "ability stage on accept want :" + JSON.stringify(want));
let instanceKey = want.parameters?.instanceKey;
if (instanceKey) {
return instanceKey.toString();
}
// ✅ 单实例模式:拦截 openInNewWindow,不创建新窗口,返回现有窗口 ID
if (want.parameters?.openInNewWindow) {
LogUtil.warn(TAG, "openInNewWindow detected, but single instance mode is enabled. Returning existing window ID.");
// 不创建新窗口,返回现有窗口的 ID
if (GlobalThisHelper.isLaunched()) {
let lastActiveBrowserId = GlobalThisHelper.getLastActiveBrowserId();
if (lastActiveBrowserId !== undefined) {
LogUtil.info(TAG, "Returning last active browser ID: " + lastActiveBrowserId);
return lastActiveBrowserId;
}
let result = this.nativeContext?.ExecuteCommand(
CommandType.kGetLastActiveWidget, { is_sync: true });
let widgetId = result?.last_widget_Id;
if (widgetId) {
LogUtil.info(TAG, "Returning last active widget ID: " + ConfigData.WINDOW_PREFIX + widgetId);
return ConfigData.WINDOW_PREFIX + widgetId;
}
}
// 如果没有现有窗口,返回默认窗口 ID(不会创建新窗口)
LogUtil.warn(TAG, "No existing window found, returning default window ID");
return ConfigData.DEFAULT_WINDOW_ID;
}
// ... 其他逻辑
}
工作原理:
- 当系统尝试创建新窗口时,
onAcceptWant会被调用 - 检测到
openInNewWindow参数后,不创建新窗口 - 返回现有窗口的 ID,系统会复用现有窗口
步骤 5:Ability 层面拦截(WebAbility.ets)
在 onNewWant 方法中拦截并激活现有窗口:
typescript
onNewWant(want: Want, launchParam: AbilityConstant.LaunchParam): void {
const params = want?.parameters;
// ✅ 单实例模式:如果检测到 openInNewWindow,激活现有窗口而不是创建新窗口
if (params?.openInNewWindow) {
LogUtil.warn(TAG, 'openInNewWindow detected in onNewWant, but single instance mode is enabled. Activating existing window.');
let windowClass = this.abilityManager?.getProxy(this.xcomponentId)?.getWindow();
if (windowClass) {
// 显示窗口并激活(showWindow 会自动将窗口带到前台)
windowClass.showWindow()
.then(() => {
LogUtil.info(TAG, 'Window activated successfully');
})
.catch((err: BusinessError) => {
LogUtil.error(TAG, 'Failed to show window. Cause: ' + JSON.stringify(err));
});
}
return;
}
// ... 其他逻辑
}
工作原理:
onNewWant在 Ability 接收到新的启动请求时调用- 检测到
openInNewWindow后,激活现有窗口而不是创建新窗口 - 使用
showWindow()将窗口带到前台
步骤 6:BaseAbility 层面拦截(WebBaseAbility.ets)
在 onCreate 方法中拦截:
typescript
onCreate(want: Want, launchParam: AbilityConstant.LaunchParam) {
this.initParameters(want);
if (CheckEmptyUtils.isEmpty(this.xcomponentId)) {
LogUtil.info(TAG, 'onCreate, Allocate xcomponent id');
// ✅ 单实例模式:即使检测到 openInNewWindow,也不创建新窗口
if (want.parameters?.openInNewWindow) {
LogUtil.warn(TAG, 'openInNewWindow detected in onCreate, but single instance mode is enabled.');
// 使用现有窗口 ID
if (GlobalThisHelper.isLaunched()) {
let lastActiveBrowserId = GlobalThisHelper.getLastActiveBrowserId();
if (lastActiveBrowserId !== undefined) {
this.xcomponentId = lastActiveBrowserId;
LogUtil.info(TAG, 'Using existing window ID: ' + this.xcomponentId);
return;
}
}
// 如果没有现有窗口,使用默认 ID(不会创建新窗口)
this.xcomponentId = ConfigData.DEFAULT_WINDOW_ID;
LogUtil.warn(TAG, 'No existing window found, using default window ID');
return;
}
// ... 正常创建逻辑
}
}
工作原理:
onCreate在 Ability 创建时调用- 检测到
openInNewWindow后,使用现有窗口 ID 而不是创建新窗口
步骤 7:窗口层面配置(WebAbility.ets)
禁用 dock 栏悬停显示:
typescript
// 在 onWindowStageCreate 中
if (this.deviceInfoAdapter?.isNormalWindowMode() === false) {
window.setWindowTitleMoveEnabled(!this.hideTitleBar);
windowStage.setWindowModal(this.isModal);
// ✅ 禁用 dock 栏的"打开新窗口"功能
// setTitleAndDockHoverShown(标题栏悬停显示, dock栏悬停显示)
// false 表示禁用,true 表示启用
window.setTitleAndDockHoverShown(false, false);
} else {
LogUtil.info(TAG, 'tablet free multi-window model is disabled');
// ✅ PC 模式下也禁用 dock 栏的"打开新窗口"功能
try {
window.setTitleAndDockHoverShown(false, false);
} catch (exception) {
LogUtil.warn(TAG, 'Failed to set dock hover shown: ' + JSON.stringify(exception));
}
}
工作原理:
setTitleAndDockHoverShown(false, false)禁用标题栏和 dock 栏的悬停菜单- 虽然可能无法完全隐藏菜单项,但可以禁用悬停显示
步骤 8:Electron 层面防护(main.js)
添加单实例锁机制:
javascript
const { app, BrowserWindow } = require('electron');
let mainWindow;
// ✅ 单实例锁,防止从 dock 栏打开新窗口
const gotTheLock = app.requestSingleInstanceLock();
if (!gotTheLock) {
// 如果已经有实例在运行,则退出
app.quit();
} else {
// 监听第二个实例启动事件(从 dock 栏右键菜单"打开新窗口"触发)
app.on('second-instance', (event, commandLine, workingDirectory) => {
// 如果主窗口存在,则激活并显示它
if (mainWindow) {
if (mainWindow.isMinimized()) {
mainWindow.restore();
}
mainWindow.focus();
mainWindow.show();
}
});
}
function createWindow() {
mainWindow = new BrowserWindow({
width: 800,
height: 600,
webPreferences: {
nodeIntegration: false,
contextIsolation: true,
},
});
mainWindow.loadFile('index.html');
// 主窗口关闭时清理
mainWindow.on('closed', () => {
mainWindow = null;
});
return mainWindow;
}
// ... 其他代码
工作原理:
app.requestSingleInstanceLock()确保只有一个应用实例运行- 如果用户尝试启动第二个实例(如通过 dock 栏"打开新窗口"),会触发
second-instance事件 - 新实例立即退出,并激活现有窗口
完整配置架构图
┌─────────────────────────────────────────────────────────┐
│ 应用级别配置 (AppScope/app.json5) │
│ multiAppMode: { │
│ multiAppModeType: "appClone", │
│ maxCount: 1 │
│ } │
└─────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────┐
│ Ability级别配置 (electron/src/main/module.json5) │
│ launchType: "singleton" │
│ 移除 shortcuts metadata 引用 │
└─────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────┐
│ 快捷方式配置 (shortcuts_config.json) │
│ shortcuts: [] // 清空快捷方式 │
└─────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────┐
│ AbilityStage层面 (WebAbilityStage.ets) │
│ onAcceptWant() 拦截 openInNewWindow │
│ 返回现有窗口 ID,不创建新窗口 │
└─────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────┐
│ Ability层面 (WebAbility.ets) │
│ onNewWant() 拦截并激活现有窗口 │
└─────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────┐
│ BaseAbility层面 (WebBaseAbility.ets) │
│ onCreate() 使用现有窗口 ID │
└─────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────┐
│ 窗口层面 (WebAbility.ets) │
│ setTitleAndDockHoverShown(false, false) │
└─────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────┐
│ Electron层面 (main.js) │
│ requestSingleInstanceLock() │
│ second-instance 事件处理 │
└─────────────────────────────────────────────────────────┘
关键配置对比
修改前
app.json5:
json
{
"multiAppMode": {
"multiAppModeType": "multiInstance",
"maxCount": 1
}
}
module.json5:
json
{
"launchType": "specified", // ❌ 允许创建多个实例
"metadata": [
{
"name": "ohos.ability.shortcuts",
"resource": "$profile:shortcuts_config" // ❌ 引用快捷方式
}
]
}
shortcuts_config.json:
json
{
"shortcuts": [
{
"shortcutId": "OpenNewWindow", // ❌ 定义快捷方式
"label": "$string:OpenNewWindow_label",
"wants": [
{
"parameters": {
"openInNewWindow": "true"
}
}
]
}
]
}
结果:dock 栏右键菜单显示两个"打开新窗口"选项
修改后
app.json5:
json
{
"multiAppMode": {
"multiAppModeType": "appClone", // ✅ 应用克隆模式
"maxCount": 1 // ✅ 限制为 1 个实例
}
}
module.json5:
json
{
"launchType": "singleton", // ✅ 单例模式
// ✅ 移除了 shortcuts metadata
}
shortcuts_config.json:
json
{
"shortcuts": [] // ✅ 清空快捷方式
}
结果:dock 栏右键菜单不再显示"打开新窗口"选项
实现效果
✅ 已实现的功能
-
完全隐藏快捷方式菜单项 :通过清空
shortcuts_config.json,移除了自定义的"打开新窗口"选项 -
系统菜单项被拦截:虽然系统可能仍显示"打开新窗口",但点击后不会创建新窗口
-
多层防护机制:从配置到代码,多个层面都进行了拦截
-
单实例运行:应用确保只有一个主窗口实例
测试验证
-
编译应用:
bashhvigor assembleHap --mode module -p module=electron@default -
验证步骤:
- 右键点击 dock 栏中的应用图标
- 验证:菜单中不再显示"打开新窗口"选项
- 如果系统仍显示(系统限制),点击后验证不会创建新窗口
-
多次测试:
- 尝试通过不同方式启动应用
- 验证:始终只有一个窗口实例
技术要点总结
1. 配置层级
- 应用级别 :
multiAppMode控制应用实例数量 - Ability级别 :
launchType控制 Ability 实例模式 - 快捷方式 :
shortcuts数组控制右键菜单项
2. 代码拦截点
- AbilityStage.onAcceptWant:拦截窗口创建请求
- Ability.onNewWant:拦截新启动请求
- BaseAbility.onCreate:拦截创建请求
3. 窗口激活机制
当检测到 openInNewWindow 时:
- 不创建新窗口
- 获取现有窗口引用
- 调用
showWindow()激活窗口 - 窗口自动带到前台
4. Electron 单实例锁
javascript
const gotTheLock = app.requestSingleInstanceLock();
if (!gotTheLock) {
app.quit(); // 第二个实例立即退出
} else {
app.on('second-instance', () => {
// 激活现有窗口
});
}
常见问题
Q1: 为什么需要多层拦截?
A: 鸿蒙系统在不同阶段可能会触发窗口创建,多层拦截确保所有路径都被覆盖:
onAcceptWant:系统级别拦截onNewWant:Ability 级别拦截onCreate:实例创建级别拦截
Q2: appClone 和 singleInstance 的区别?
A:
singleInstance:严格单实例,系统完全不显示"打开新窗口"appClone+maxCount: 1:应用克隆模式但限制为 1 个实例,兼容性更好
Q3: 为什么清空 shortcuts 数组而不是删除文件?
A:
- 保留文件结构,避免配置解析错误
- 如果完全删除文件,需要同时移除
module.json5中的 metadata 引用 - 清空数组更安全,不会影响其他配置
Q4: 如果系统仍显示"打开新窗口"怎么办?
A:
- 这是系统级别的限制,无法完全移除
- 但通过代码拦截,点击后不会创建新窗口
- 系统会激活现有窗口,实现单实例效果
最佳实践
-
配置一致性 :确保
app.json5和module.json5中的配置一致 -
代码拦截:即使配置正确,也建议添加代码拦截作为双重保障
-
日志记录:在拦截点添加日志,便于调试和排查问题
-
测试验证:在不同版本的鸿蒙系统上测试,确保兼容性
总结
通过配置 + 代码拦截的组合方案,成功实现了隐藏 dock 栏"打开新窗口"功能:
✅ 配置层面:
- 应用级别:
multiAppModeType: "appClone",maxCount: 1 - Ability级别:
launchType: "singleton" - 快捷方式:清空
shortcuts数组
✅ 代码层面:
- AbilityStage 拦截:
onAcceptWant - Ability 拦截:
onNewWant - BaseAbility 拦截:
onCreate - Electron 防护:单实例锁
✅ 效果:
- dock 栏右键菜单不再显示"打开新窗口"选项
- 应用以单实例模式运行
- 多层防护确保万无一失