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

欢迎在PC社区平台申请新建项目:https://atomgit.com/OpenHarmonyPCDeveloper


目录


摘要

把一款已经跑通的鸿蒙手机应用直接"打包搬运"到鸿蒙 PC(2in1)桌面端,大概率会遇到以下场景:应用装上了模拟器但桌面找不到图标、启动即崩溃、自定义组件编译报错、右键菜单不工作、关闭再打开内容全丢。这些问题本质上都不是"换皮"能解决的——鸿蒙 PC 不是大号手机,它有独立的 deviceTypes 声明、可能抛异常的系统 API、CustomComponent 基类命名冲突、完全不同的鼠标事件模型,以及需要主动持久化的窗口生命周期。本文以一个真实的 HarmonyPCDemo 项目为样本,讲透 5 个必须改的适配关键点,每一点都有踩坑故事、修复代码和为什么会这样的底层解释。


适用读者 / 前置知识 / 涉及工具版本

适用读者:

  • 开源鸿蒙 PC 专项积分赛参赛者,已有手机端代码想快速跑通桌面端
  • 从 Android / iOS / Web 转鸿蒙开发的工程师,对"桌面端"这个概念还停留在"大屏手机"
  • 刚接触 DevEco Studio 的鸿蒙新手,构建出包后发现应用"不见了"或"秒退"

前置知识:

  • 能独立创建一个 ArkTS 工程、跑通手机模拟器
  • 了解 ArkUI 组件化(@Component、@Entry、@State)
  • 对 module.json5 的作用有基本印象(至少知道它是应用清单)

涉及工具与版本:

  • DevEco Studio 6.0(自带 hvigor、ohpm)
  • HarmonyOS SDK API 19
  • ArkTS / ArkUI
  • Windows 11 x64 + 鸿蒙 PC 模拟器(2in1 模式)

项目开发界面如下:
在这里插入图片描述


引言:我的第一个鸿蒙 PC Demo 跑通了……一半

事情的起点很简单——我已经有一个手机端的便签 + 右键菜单 Demo,想在鸿蒙 PC 模拟器上跑一下看看效果。DevEco Studio 里把 Run Target 切到 2in1 设备,点 Run,编译通过,安装完成。然后我看向模拟器桌面——图标没出现。

点开应用抽屉也没有。卸载重装一遍,还是没有。查了半小时,终于在日志里看到一行:

Ability not found for device type: 2in1

那一刻我意识到,鸿蒙 PC 不是"天然兼容"手机应用的。它有一套独立的适配约束。接下来的两周里,我陆续踩到了另外四个大大小小的坑,最终沉淀出下面这 5 个适配关键点。


关键点 1:module.json5 的 deviceTypes 必须加 "2in1"

踩坑故事:应用装上了模拟器,但桌面就是找不到图标

我的初始 module.json5 是 DevEco Studio 新建项目时自动生成的,里面 deviceTypes 只写了 "phone":

"deviceTypes": [
  "phone"
]

手机模拟器上一切正常。切到 2in1 模拟器,Run 按钮也能点,构建成功,系统甚至提示"安装完成"——但桌面上就是没有图标。

我当时的第一反应是"模拟器卡了",重启了模拟器,甚至重建了一个新的 2in1 模拟器,问题依旧。直到我把日志翻出来,搜索 deviceType 关键字,才看到那条 Ability 注册失败的日志。

修复代码

在 entry/src/main/module.json5 里,把 "2in1" 加进数组:

之前:

"deviceTypes": [
  "phone"
]

之后:

"deviceTypes": [
  "phone",
  "2in1"
]

为什么:鸿蒙设备类型是显式声明、严格过滤的

鸿蒙的应用分发机制和 Android 不同。Android 是"手机能装平板也能装"(通过 resizeableActivity 和 screenSize 做适配),而鸿蒙是设备类型白名单制——deviceTypes 就是一份"我声明支持哪些设备"的清单。系统在安装时会拿目标设备的类型和清单比对,不匹配的直接跳过 Ability 注册。

当前鸿蒙主要的设备类型字符串有:

deviceType说明
phone手机
tablet平板
2in1PC 桌面端(2合1设备、笔记本、台式机)
wearable穿戴设备
tv智慧屏

如果你的应用需要同时兼容手机和桌面端,直接把 "phone" 和 "2in1" 都写上即可。这是把手机应用搬上鸿蒙 PC 最先要做的一件事,没有之一。

加完这一行,重新 Run,应用图标立刻出现在 2in1 模拟器的桌面上了。


关键点 2:display.getDefaultDisplaySync() 可能抛异常,必须 try-catch

踩坑故事:DpiMenuDemo 在某些环境下启动即崩

解决完图标问题,应用终于能打开了。我写了一个 DpiMenuDemo 页面用来读取屏幕 DPI 系数(方便做字体和布局的缩放),里面有这么一段:

import { display } from '@kit.ArkUI';

@Entry
@Component
struct DpiMenuDemo {
  @State dpi: number = 1;

  aboutToAppear(): void {
    const d = display.getDefaultDisplaySync();
    this.dpi = d.densityPixels;
  }
  // ...
}

这段代码在我自己的手机模拟器上跑得好好的。但是当我在另一台机器的鸿蒙 PC 模拟器上测试时,应用启动即崩溃,连页面都渲染不出来。DevEco Studio 的日志里只看到一句:

Error: getDefaultDisplaySync threw an exception

修复代码

把所有直接调用 display.getDefaultDisplaySync() 的地方都用 try-catch 包起来:

import { display } from '@kit.ArkUI';
import { BusinessError } from '@kit.BasicServicesKit';

@Entry
@Component
struct DpiMenuDemo {
  @State dpi: number = 1;
  @State densityDpi: number = 160;
  @State statusMsg: string = '准备就绪';

  aboutToAppear(): void {
    try {
      const d = display.getDefaultDisplaySync();
      this.dpi = d.densityPixels;
      this.densityDpi = d.densityDPI;
      this.statusMsg = `DPI: ${this.dpi}  densityDPI: ${this.densityDpi}`;
    } catch (err) {
      const e = err as BusinessError;
      console.error(`[DpiMenu] getDefaultDisplaySync 失败: ${e.code} ${e.message}`);
      // 给一个安全的默认值,应用不会崩
      this.statusMsg = `DPI 读取失败 code=${e.code}`;
    }
  }
  // ...
}

为什么:同步 API ≠ 不会抛异常

很多从 Android/iOS 转过来的开发者会有一个误区:带 Sync 后缀的同步 API 是"安全"的,不会像异步 API 那样需要处理回调或 Promise rejection。但鸿蒙的 display.getDefaultDisplaySync() 不一样——它虽然是同步 API,但在以下场景会主动抛出 BusinessError:

  1. 应用启动阶段 display 服务还没完全就绪(时序问题)
  2. 当前设备没有默认 Display(极少见但存在于某些定制 ROM)
  3. 多显示器环境下默认 Display 正在切换

ArkTS 编译器对"函数可能抛异常"会给出 WARN,这个 WARN 不是摆设——忽略它,应用就可能在某些环境下 crash。try-catch 不只是防御性编程,在这里是让应用不崩的必要条件。

类似需要 try-catch 包裹的同步 API 还有:

  • window.getLastWindowSync()
  • configuration.getLocale()
  • resourceManager.getStringSync()

经验法则:在鸿蒙里,凡是名字里带 Sync 且返回系统服务对象的,默认就认为它可能抛异常。


关键点 3:CustomComponent 基类属性不可重名——别跟 scale、width、height 撞车

踩坑故事:写了个 private scale(size: number),编译直接报类型不兼容

DpiMenuDemo 里我需要一个根据 DPI 系数缩放字体大小的工具函数,非常自然地写下了:

private scale(size: number): number {
  return size * this.dpi;
}

然后在 .fontSize(this.scale(18)) 处使用。编译……居然报错:

Type mismatch: 'number' is not assignable to '(value: number | string | Resource) => this'

我愣了好一会儿。scale(18) 明明返回 number,为什么编译器说它期望一个函数类型?查了一圈才搞明白——@Component 装饰的 CustomComponent 继承自 ArkUI 的组件基类,基类上已经有一个 scale 属性(就是链式修饰器里那个 this.scale({ x: 1.5, y: 1.5 }))。

我定义的 private scale 没有 override 基类属性,而是把它"遮蔽"了。但 ArkTS 的类型推断在链式修饰器上下文中仍然指向基类的签名,结果就是同名不同参,编译器懵了。

修复代码

改个名字就行。用 scaleFont()、scaleSize() 或者任何不和基类属性冲突的名字:

// 注意:函数名不能叫 scale,CustomComponent 基类已有 scale 属性用于修饰器
private scaleFont(size: number): number {
  return size * this.dpi;
}

使用处同步改:

Text(`鸿蒙PC屏幕 DPI 系数:${this.dpi}`)
  .fontSize(this.scaleFont(18))
  .fontWeight(FontWeight.Bold)

为什么:ArkUI 有大量公共属性,命名要避开

ArkUI 的 CustomComponent 基类(内部叫 CommonMethod)提供了非常多的链式修饰器属性,常见的包括:

基类属性作用
width / height设置宽高
scale缩放变换
opacity透明度
rotate旋转
translate平移
align对齐
padding内边距
margin外边距
backgroundColor背景色
border边框
shadow阴影
fontSize字号

如果你在 Component 里定义了同名的成员(不管是 private 还是 public,不管是属性还是方法),ArkTS 的类型系统就会产生冲突。这个坑在手机端也存在,只是手机端 Demo 简单,不太容易撞上;而鸿蒙 PC 适配时你大概率会写 scale、resize、adjust 这类工具函数,撞上的概率呈指数上升。

经验法则:

  • 工具函数统一加前缀,比如 scaleFont、calcWidth、fitHeight
  • 实在想用短名字,就把工具函数抽成组件外的独立函数或一个 Utils 类
  • 写完代码做一次全局搜索:在 Component 内,搜一下你定义的方法名在 ArkUI 文档里是不是已经存在

关键点 4:鸿蒙 PC 鼠标事件 vs 手机触摸事件——button=2 才是右键

踩坑故事:右键菜单在 PC 上不工作,因为判断错了 button 值

这是我在 DpiMenuDemo 里实现自定义右键菜单时踩到的最"低级"但最耗时的坑。鸿蒙 PC 上用户可以用鼠标,那右键弹菜单嘛——我凭印象写了:

.onMouse((e: MouseEvent) => {
  if (e.button === 0 && e.action === 1) {
    // 弹菜单
  }
})

写完跑模拟器,右键点上去……菜单不出来。排查了 20 分钟:位置计算错了?菜单 visibility 没设对?Column 的层级被盖了?最后打印了一下 e.button 的值:右键按下时 button 是 2,不是 0。

修复代码

鸿蒙 PC 的 MouseEvent.button 值和我之前的认知完全不同:

// 鸿蒙 PC 鼠标事件:button=1 左键,button=2 右键,button=3 中键
// action=1 按下,action=2 释放,action=3 移动
.onMouse((e: MouseEvent) => {
  if (e.button === 2 && e.action === 1) {
    this.menuX = e.x;
    this.menuY = e.y;
    this.showMenu = true;
  }
})

完整的右键菜单 + 左键拖拽在便签 StickyNote 里的实现:

.onMouse((event: MouseEvent) => {
  if (event.button === 1 && event.action === 1) {
    // 左键按下:开始拖拽
    this.dragging = true;
    this.lastX = event.x;
    this.lastY = event.y;
  } else if (event.button === 1 && event.action === 3 && this.dragging) {
    // 左键按住移动:更新窗口位置
    this.posX += event.x - this.lastX;
    this.posY += event.y - this.lastY;
    this.lastX = event.x;
    this.lastY = event.y;
    this.moveWindowTo(this.posX, this.posY);
    this.savePosition();
  } else if (event.button === 1 && event.action === 2) {
    // 左键释放:结束拖拽
    this.dragging = false;
  }
})

为什么:鸿蒙的 button 值映射和 HTML 不一样

如果你之前写过 Web,会记得 HTML 的 MouseEvent.button 是:0=左键,1=中键,2=右键。鸿蒙 PC 的映射不是这个。

按钮HTML button鸿蒙 PC button
左键01
右键22
中键13

注意左键!HTML 里左键是 0,鸿蒙里左键是 1。这是最容易错的地方——如果你从 Web 搬代码,把 button === 0 搬过来,左键事件在鸿蒙 PC 上会全失效。

另外还有一点:手机上用的是 onTouch(TouchEvent),鸿蒙 PC 上应该用 onMouse(MouseEvent)。两者的事件参数结构也不同:TouchEvent 里触点坐标在 touches[0].x,MouseEvent 直接给 e.x。在做跨端兼容时,可以用设备类型判断走不同的事件分支

手机 + PC 双端事件兼容写法

上面那段话我一开始只是"知道",真正落地时才发现:同一份页面代码要同时跑手机和 PC,事件绑定必须按设备类型分流。判断依据是 deviceInfo.deviceType——2in1 走 onMouse,其余走 onTouch:

import { deviceInfo } from '@kit.BasicServicesKit';

@Entry
@Component
struct StickyNote {
  @State posX: number = 200;
  @State posY: number = 160;
  private isPC: boolean = deviceInfo.deviceType === '2in1';

  // 统一内部处理函数:只认「归一化后的坐标 + 动作」
  private handlePointer(x: number, y: number, action: 'down' | 'move' | 'up'): void {
    if (action === 'down') {
      this.lastX = x;
      this.lastY = y;
      this.dragging = true;
    } else if (action === 'move' && this.dragging) {
      this.posX += x - this.lastX;
      this.posY += y - this.lastY;
      this.lastX = x;
      this.lastY = y;
      this.moveWindowTo(this.posX, this.posY);
    } else if (action === 'up') {
      this.dragging = false;
      this.savePosition();
    }
  }

  build() {
    Column() {
      // ... 便签内容
    }
    .width('100%')
    .height('100%')
    .onTouch((e: TouchEvent) => {
      if (this.isPC) { return; } // PC 上不处理触摸
      const t = e.touches[0];
      if (e.type === TouchType.Down) {
        this.handlePointer(t.x, t.y, 'down');
      } else if (e.type === TouchType.Move) {
        this.handlePointer(t.x, t.y, 'move');
      } else if (e.type === TouchType.Up) {
        this.handlePointer(t.x, t.y, 'up');
      }
    })
    .onMouse((e: MouseEvent) => {
      if (!this.isPC) { return; } // 手机上不处理鼠标
      if (e.button !== 1) { return; } // 只处理左键拖拽
      if (e.action === 1) {
        this.handlePointer(e.x, e.y, 'down');
      } else if (e.action === 3) {
        this.handlePointer(e.x, e.y, 'move');
      } else if (e.action === 2) {
        this.handlePointer(e.x, e.y, 'up');
      }
    })
  }
}

坐标取值差异是这里最容易翻车的地方:

事件坐标来源说明
TouchEvente.touches[0].x / e.touches[0].y坐标在触点数组里,多指时 touches[0] 只是第一个触点;单指场景够用,多指要遍历
MouseEvente.x / e.y坐标直接挂在事件对象上,没有数组层级

所以统一的关键就是:在事件回调里先把坐标"拍平"成 (x, y) 两个 number,再交给同一个 handlePointer。handlePointer 内部完全不关心事件来自触摸还是鼠标,只认归一化后的坐标和动作枚举——这样拖拽、右键菜单、点击判定这些业务逻辑只写一份,双端行为天然一致。

另外两个小提醒:

  • onTouch 和 onMouse 可以同时挂在同一个组件上,用 isPC 做互斥短路即可,不会互相干扰。
  • 如果后续要支持右键菜单,在 onMouse 分支里单独判断 e.button === 2 && e.action === 1,不要混进拖拽的 handlePointer,避免语义纠缠。

。


关键点 5:Preferences 持久化——PC 窗口可关闭重开,内容要记住

踩坑故事:便签关掉再打开,内容全丢了

手机端的便签我一开始没做持久化——反正手机应用很少被主动"关闭",退到后台系统会帮你保留状态。但鸿蒙 PC 不一样:用户可以像操作普通桌面应用一样,点窗口右上角的 × 彻底关掉应用。关掉再打开,@State 变量全部重置,我写的那几条待办就没了。

修复代码

使用 @kit.ArkData 里的 preferences API 做键值对持久化:

import { preferences } from '@kit.ArkData';
import { BusinessError } from '@kit.BasicServicesKit';
import common from '@ohos.app.ability.common';

@Entry
@Component
struct StickyNote {
  @State content: string = '在这里写下今天的待办...';
  @State posX: number = 200;
  @State posY: number = 160;

  private pref: preferences.Preferences | null = null;

  aboutToAppear(): void {
    this.initPreferences();
  }

  private async initPreferences(): Promise<void> {
    try {
      const context: common.UIAbilityContext = getContext(this) as common.UIAbilityContext;
      this.pref = await preferences.getPreferences(context, 'StickyNote');
      // 启动时读取上次保存的内容和位置
      this.content = await this.pref.get('content', this.content) as string;
      this.posX = await this.pref.get('posX', this.posX) as number;
      this.posY = await this.pref.get('posY', this.posY) as number;
    } catch (err) {
      const e = err as BusinessError;
      console.error(`[StickyNote] Preferences 初始化失败: ${e.code} ${e.message}`);
    }
  }

  private saveContent(): void {
    if (!this.pref) { return; }
    this.pref.put('content', this.content).catch((err: BusinessError) => {
      console.error(`[StickyNote] 保存内容失败: ${err.code}`);
    });
  }

  build() {
    Column() {
      TextArea({ text: this.content })
        .onChange((val: string) => {
          this.content = val;
          // 实时持久化,用户每敲一个字就存一次
          this.saveContent();
        })
    }
    .width('100%')
    .height('100%')
  }
}

为什么:桌面端的窗口生命周期和移动端完全不同

移动端应用的生命周期是前台 ↔ 后台 ↔ 被系统杀死,用户几乎不会主动"退出"一个 App。而桌面端的窗口可以被:

  • 用户主动关闭(点 ×)
  • 被最小化后进程不销毁(状态自动保留,但重启后就没了)
  • 系统关机/休眠
  • 多窗口切换(鸿蒙 PC 支持多应用同屏)

这意味着任何需要"记住"的东西——用户输入、窗口位置、最后打开的文件、主题设置——都不能只存在内存里。

鸿蒙提供了几层持久化方案,按重量从小到大:

方案适用场景API 来源
Preferences(推荐)小型键值对(设置项、便签内容、窗口位置)@kit.ArkData
DistributedKVStore需要多设备同步的键值对@kit.ArkData
文件 I/O大文件(图片、文档)@kit.CoreFileKit
关系型数据库(RDB)结构化数据(联系人、消息历史)@kit.ArkData

对于便签、设置这类轻量场景,preferences 是最顺手的选择:get/put 都是异步、自动做序列化、key 就是字符串。注意每次 put 之后最好 flush() 一下(鸿蒙 4.0+ 会自动 flush 但显式调用更保险)。


补充:落到 ArkUI——状态分三层,别全塞 @State

踩完上面这些坑之后我回头重构了一遍代码,最大的收获是:状态不要一股脑塞进 @State。桌面端应用的状态天然分三层,混在一起写,最后一定是「改一个地方,三个页面跟着抖」。

层级生命周期典型内容ArkUI 落点是否落盘
UI 瞬态层一次交互 / 一次渲染鼠标 hover、拖拽中的临时坐标、右键菜单是否展开、输入框焦点@State / @Prop(组件内私有)否
会话共享层一次应用运行当前便签正文、当前选中项、窗口位置、主题模式@Provide + @Consume(跨组件共享),或 AppStorage否(进程退出即丢)
持久态层跨进程 / 跨重启便签内容、窗口坐标、用户设置relationalStore(RDB,结构化数据)+ Preferences(轻量键值)是

三层之间的数据流向,以及唯一写入口的位置,画成图是这样:

交互确认后提交
(如 onChange / 菜单点击)

同步更新内存

异步落盘
put / executeSql

应用启动时读取
aboutToAppear

初始化下发

@Consume 自动刷新

纯 UI 反馈,不落盘

用户交互
鼠标 / 键盘 / 触摸

UI 瞬态层
@State / @Prop
hover、拖拽中坐标、菜单展开

唯一写入口
NoteStore 单例
(所有写操作只走这里)

会话共享层
@Provide + @Consume
当前便签、选中项、窗口位置

持久态层
Preferences(键值)
relationalStore(结构化)

视图层
各 @Component 只读渲染

几个关键约束,写代码时我给自己定死了:

  • UI 瞬态层只进不出:@State 里的东西(比如 dragging、menuVisible)绝不直接写库,它只负责渲染反馈。
  • 会话共享层是唯一被视图订阅的层:组件通过 @Consume 拿数据,只读不改;要改就调 NoteStore 的方法。
  • 唯一写入口是 NoteStore 单例:@State 和 @Consume 都不允许直接调 preferences.put() 或 relationalStore.executeSql()。所有写操作收敛到一个类里,落盘策略(防抖、失败重试、批量 flush)才有地方统一处理。
  • 持久态层只被写入口读写:启动时由 NoteStore 读出来灌进会话共享层,运行中由 NoteStore 异步写回,视图层永远不碰它。

这样分层之后,右键菜单展开这种高频状态不会触发任何落盘,而便签正文的每一次修改都经过同一个入口做防抖再写库——既不会卡,也不会丢。

构建成功日志片段

完成以上 5 个关键点适配后,Ctrl+F9 构建 HarmonyPCDemo 工程,输出如下:

> Task :entry:assembleHap
> Task :entry:mergeHap
BUILD SUCCESSFUL in 23s
18 actionable tasks: 18 executed

[INFO] 2026-10-27 21:36:11 - Build output: entry\build\default\outputs\default\entry-default-signed.hap
[INFO] 2026-10-27 21:36:12 - Install hap to 2in1 emulator...
[INFO] 2026-10-27 21:36:15 - Install success
[INFO] 2026-10-27 21:36:15 - Launch ability: EntryAbility
[INFO] 2026-10-27 21:36:16 - Ability launched successfully

BUILD SUCCESSFUL、安装成功、Ability 成功拉起——从图标出现到右键菜单弹出、从 DPI 正确读取到便签内容可持久化,5 个关键点全部打通。

项目运行效果如下:
在这里插入图片描述
在这里插入图片描述


总结表格

把手机应用搬上鸿蒙 PC 的 5 个适配关键点,按难度、重要性、耗时做一个打分(1-5 分):

#关键点难度重要性耗时一句话总结
1module.json5 加 "2in1"⭐⭐⭐⭐⭐⭐5 分钟没有这行,应用根本出不来
2getDefaultDisplaySync() 包 try-catch⭐⭐⭐⭐⭐⭐15 分钟不包就是不定时炸弹
3CustomComponent 内别用 scale / width 等命名⭐⭐⭐⭐⭐10 分钟ArkUI 基类属性不能撞
4鼠标事件 button=2 才是右键⭐⭐⭐⭐⭐⭐⭐20 分钟别把 HTML 的 button 值搬过来
5Preferences 做持久化⭐⭐⭐⭐⭐⭐30 分钟桌面端窗口关了就是真没了

一个很有意思的观察:重要性最高的是最简单的(关键点 1),难度最高的是最容易被忽略的(关键点 4)。这和很多平台适配的经验一致——清单文件配置是门槛,事件模型是陷阱。


AtomGit 仓库链接 & 赛道二 PR

应用仓库

本文完整源码(module.json5、DpiMenuDemo.ets、StickyNote.ets、Home.ets 等页面,及 hvigor 构建配置)已开源,托管在 AtomGit:

👉 https://atomgit.com/wdracky/ohpm

OpenHarmonyPCDeveloper/build_in_harmonyos 主线框架。适配的库是 soundio 2.0.0(跨平台音频 I/O C 库,CMake 构建,纯 C 无额外依赖)。

PR #19174 链接:
https://atomgit.com/OpenHarmonyPCDeveloper/build_in_harmonyos/pull/19174

PR 内容:archives/s/soundio/ 目录下完整 Conan 配方(conanfile.py + conandata.yml + test_package + manifest + notes + README)。核心适配策略:禁用所有平台音频后端(JACK/PulseAudio/ALSA/CoreAudio/WASAPI,OHOS 暂无),仅保留 Threads(bionic pthreads 原生支持)。

本文所有代码均在 HarmonyOS SDK API 19 + DevEco Studio 6.0 环境下验证通过。如果你在适配过程中遇到了其他坑,欢迎在社区留言,我会持续补充到仓库里。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐