鸿蒙PC应用开发实战:把手机应用搬上鸿蒙桌面端的 5 个适配关键点
欢迎加入开源鸿蒙PC社区:https://harmonypc.csdn.net/
欢迎在PC社区平台申请新建项目:https://atomgit.com/OpenHarmonyPCDeveloper
目录
- 摘要
- 适用读者 / 前置知识 / 涉及工具版本
- 引言:我的第一个鸿蒙 PC Demo 跑通了……一半
- 关键点 1:
module.json5的deviceTypes必须加"2in1" - 关键点 2:
display.getDefaultDisplaySync()可能抛异常,必须 try-catch - 关键点 3:CustomComponent 基类属性不可重名——别跟
scale、width、height撞车 - 关键点 4:鸿蒙 PC 鼠标事件 vs 手机触摸事件——
button=2才是右键 - 关键点 5:Preferences 持久化——PC 窗口可关闭重开,内容要记住
- 构建成功日志片段
- 总结表格
- AtomGit 仓库链接 & 赛道二 PR
摘要
把一款已经跑通的鸿蒙手机应用直接"打包搬运"到鸿蒙 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 | 平板 |
2in1 | PC 桌面端(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:
- 应用启动阶段 display 服务还没完全就绪(时序问题)
- 当前设备没有默认 Display(极少见但存在于某些定制 ROM)
- 多显示器环境下默认 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 |
|---|---|---|
| 左键 | 0 | 1 |
| 右键 | 2 | 2 |
| 中键 | 1 | 3 |
注意左键!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');
}
})
}
}
坐标取值差异是这里最容易翻车的地方:
| 事件 | 坐标来源 | 说明 |
|---|---|---|
TouchEvent | e.touches[0].x / e.touches[0].y | 坐标在触点数组里,多指时 touches[0] 只是第一个触点;单指场景够用,多指要遍历 |
MouseEvent | e.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(轻量键值) | 是 |
三层之间的数据流向,以及唯一写入口的位置,画成图是这样:
几个关键约束,写代码时我给自己定死了:
- 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 分):
| # | 关键点 | 难度 | 重要性 | 耗时 | 一句话总结 |
|---|---|---|---|---|---|
| 1 | module.json5 加 "2in1" | ⭐ | ⭐⭐⭐⭐⭐ | 5 分钟 | 没有这行,应用根本出不来 |
| 2 | getDefaultDisplaySync() 包 try-catch | ⭐⭐ | ⭐⭐⭐⭐ | 15 分钟 | 不包就是不定时炸弹 |
| 3 | CustomComponent 内别用 scale / width 等命名 | ⭐⭐ | ⭐⭐⭐ | 10 分钟 | ArkUI 基类属性不能撞 |
| 4 | 鼠标事件 button=2 才是右键 | ⭐⭐⭐ | ⭐⭐⭐⭐ | 20 分钟 | 别把 HTML 的 button 值搬过来 |
| 5 | Preferences 做持久化 | ⭐⭐ | ⭐⭐⭐⭐ | 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 环境下验证通过。如果你在适配过程中遇到了其他坑,欢迎在社区留言,我会持续补充到仓库里。
更多推荐


所有评论(0)