简介:面向华为鸿蒙系统开发者的音乐服务卡片示例工程,聚焦手机桌面与万能卡片场景,解决用户不进入应用即可快速查看歌曲信息、完成播放控制的需求。工程提供完整源码与资源配置,围绕HarmonyOS服务卡片开发全流程展开,涵盖遵循Material Design的卡片UI设计、基于JSF/JS的页面逻辑、卡片创建/显示/暂停/恢复/销毁等生命周期管理、播放状态数据同步与事件更新,以及点击播放、滑动切歌等交互动画,并利用分布式软总线能力演示跨设备协同。压缩包共60个文件,以java/js逻辑代码和hml/css界面文件为主,辅以png/jpg图标素材、json配置、gradle构建脚本及wav音频示例,整体约18.24MB,目录结构清晰,按主功能模块、资源配置和构建工具分层组织,便于按需查阅与二次开发。已有415人学习下载,适合初学鸿蒙卡片开发、希望理解分布式能力落地方式的中高级移动开发者参考实践。通过该项目还可掌握DevEco Studio调试、打包发布及跨设备协同调用的完整思路,为后续多端音乐场景开发打下基础。
1. 鸿蒙开发里的音乐服务卡片:不打开App就能切歌,是怎么做到的?
入手鸿蒙开发一段时间的开发者,迟早会撞上这样一张卡片:桌面上一个 2×4 的组件,不带 App 图标,却有专辑封面、歌名、播放/暂停按钮,甚至还能拖动进度条。这就是音乐服务卡片。它解决的问题非常具体——用户想切歌、暂停时,不必先解锁手机、找到应用、再进播放页,在桌面上一键完成。这个交互在车机、平板和手表上价值更明显。这篇文章面向已经会跑 HelloWorld 但还没拆过服务卡片工程的开发者,我会把卡片从配置、宿主到刷新、跳转的完整链路拆清楚,再把高频的坑逐条标出来,让你拿到一张卡片能稳定跑起来。
2. 卡片不是半个页面:音乐服务卡片的运行模型与框架选型
2.1 服务卡片和普通页面的三个本质区别
很多新人习惯把服务卡片理解为“缩小版页面”,于是按写 Page 的思路去写,结果出现一堆诡异问题。卡片和页面的第一个区别是运行进程不同:普通页面运行在你的应用进程里,服务卡片却运行在卡片的宿主进程里,也就是桌面或负一屏的进程。这两个进程之间有通信边界,你不能在卡片里直接调用应用里的全局单例,更不能直接拿播放器实例去查询播放状态。
第二个区别是生命周期不同。应用页面由用户打开或关闭,卡片则由系统根据桌面显示情况创建和销毁。卡片在桌面上看不见的时候可能被系统回收,但应用进程还活着;反过来,应用进程被杀掉,卡片也不一定消失。音乐类卡片最典型的坑就在这里:用户划掉应用后台,卡片上还在显示“正在播放”,但实际已经断播了。
第三个区别是渲染能力受限。卡片不能使用全部 ArkUI 组件和接口,它的 UI 在宿主侧做了一套受限渲染。地图组件、Canvas 高级绘制、Web 组件这一类重组件,在卡片里直接用会白屏或直接编译报错。音乐卡片虽然看起来只是个简单列表,但它要用的封面图加载、进度条、按钮态切换,都有专门的接口约定。
这三点决定了做音乐服务卡片的正确姿势:卡片本质上是一个“远程展示+事件上报”的组件,它不该承载业务逻辑,只负责显示数据、上报用户动作。数据从哪来?从应用进程通过卡片数据通道推过来。用户点了切歌按钮怎么办?卡片通过事件消息把动作发回应用进程,由应用真正执行切歌,再把新状态刷回卡片。
2.2 ArkTS卡片 与 JS卡片:选型为什么直接影响后续开发
如果你翻过早期的鸿蒙开发资料,会看到卡片还分 JS 卡片和 ArkTS 卡片。这是鸿蒙服务卡片的两种 UI 语法实现。JS 卡片用类 HTML/CSS 的语法写界面,早期版本里很常见,渲染方式类似轻量 WebView;ArkTS 卡片则用声明式 ArkUI 语法,直接编译成原生组件树。API 9 之后,新项目基本以 ArkTS 卡片为准,到 API 11、12 阶段,JS 卡片已经退居兼容位置。
做一个新的音乐服务卡片,我一般直接选 ArkTS,不纠结。原因很简单:第一,ArkTS 卡片能复用很多页面级的开发经验,写起来和写一个普通 @Component 没本质差别,团队上手成本低。第二,ArkTS 卡片的渲染性能和交互响应都比 JS 卡片好,尤其是按钮点击、进度条拖动这种高频交互,JS 卡片的触控反馈能明显感觉到慢半拍。第三,卡片要配合新特性时,比如 2×4 尺寸的圆角控制、卡片共享背景,ArkTS 支持得更好。
但 JS 卡片也不是完全没有价值。如果你的应用要兼容很老的 HarmonyOS 设备,且卡片界面只是静态展示,JS 卡片反而轻。只不过现状是连官方模板都默认 ArkTS,新项目没必要逆着方向走。选型上我的建议很简单:做新的,直接 ArkTS;维护老的 JS 卡片,不求有功但求无过,别在老的上面加复杂交互。
2.3 卡片链路全景:ExtensionAbility、卡片管理服务和数据通道
理解完卡片边界,再看整条链路。服务卡片的提供方是一个特殊的 ExtensionAbility,在 Stage 模型里你创建了一个 FormExtensionAbility,它负责告诉系统“我有哪些卡片、卡片尺寸是什么、首帧数据是什么”。这个 ExtensionAbility 并不直接渲染 UI,真正的 UI 是你在卡片源码文件里写的 ArkTS 页面结构。
系统中还有一个卡片管理服务,它是宿主和提供方之间的中间人。桌面启动时去找卡片管理服务说:“我要展示这张卡片”,卡片管理服务再去拉起或者唤醒应用侧的卡片 ExtensionAbility,拿到卡片的数据和 UI 描述,然后把渲染任务交给卡片宿主进程。之后用户点击卡片上的按钮,事件从卡片宿主走到应用进程,应用响应完后调用更新接口把新数据推回卡片。
这条链路里,音乐卡片最在意的其实是“数据怎么推”。卡片有几条数据刷新路径:定时刷新,按小时或按分钟周期性更新,适合展示时间天气这类内容;下一次刷新,指定一个未来时间点更新一次;主动推送,应用进程主动调 updateForm 把最新数据刷过去,音乐卡片几乎全依赖这条路径,因为歌曲状态变化是事件驱动的。还有临时刷新,也就是短时间内的按需刷新,适合播放进度这类持续变化的数据。
所以做音乐卡片,你真正要维护的是两端:卡片侧的 UI 组件和事件上报,应用侧的播放状态监听和数据推送。这两端通过 formId 关联,一个应用可以创建多张卡片,每张卡片都有自己的唯一 formId,操作时必须带对 id,否则会出现“改了 A 卡片,B 卡片纹丝不动”的诡异问题。
3. 把一张音乐服务卡片做出来:从解压工程到跑通首帧
3.1 工程骨架:解压后先别急着打开 IDE,先看目录结构
拿到一个音乐服务卡片的工程压缩包,我的习惯是先看目录再开 DevEco Studio。一个标准 Stage 模型工程的轮廓中,AppScope 下面放着应用级配置,entry/src/main 下面才是模块级内容。你要找的卡片相关代码通常在 entry/src/main/ets/forms 或类似目录下,可能有新项目在 module 里单独建 forms 目录。先看 module.json5 里有没有 extensionAbilities 节点,再看 ets 目录下有没有 form 相关源码,这两个东西决定了工程里到底有没有卡片模块。
很多新手拿到工程第一步就点 Sync,然后看着报错发愁。其实大部分报错在解压那一刻就埋下了:路径带空格、工程目录层级被改动、SDK 版本与本机不一致。我的建议是先确认本机 DevEco Studio 版本和 SDK 版本,再打开工程。如果你拿到的模板包是基于 API 11 的,本机 API 12 通常可以兼容打开,但反过来会报低版本问题。
打开工程后,重点看 entry 模块下的 module.json5,里面 extensionAbilities 节点是这样的结构:
{ "module": { "name": "entry", "type": "entry", "extensionAbilities": [ { "name": "MusicFormExtensionAbility", "srcEntry": "./ets/forms/MusicFormExtAbility.ets", "type": "form", "metadata": [ { "name": "ohos.extension.form", "resource": "$profile:form_config" } ] } ] } }这里的关键是 srcEntry 指向卡片 ExtensionAbility 的源码路径,metadata 里的 resource 指向 form_config.json 的配置资源。路径写错是最常见的启动失败原因,比业务代码的错误隐蔽得多。系统是拿着这个路径去找源码和配置的,路径不对直接导致卡片在桌面上添加失败,而且日志还不容易看明白。
3.2 卡片配置:form_config.json 里的每个字段都有讲究
接下来看 form_config.json,它放在 resources/base/profile 目录下,描述卡片的外形规格。音乐服务卡片一般至少提供 2×2 和 2×4 两种尺寸,2×2 显示歌曲名和播放按钮,2×4 多显示一行歌词或播放进度。这个文件长这样:
{ "name": "music_card", "displayName": "$string:music_card_name", "description": "$string:music_card_desc", "src": "./ets/forms/MusicCard.ets", "uiSyntax": "arkts", "window": { "designWidth": 720, "autoDesignWidth": true }, "forms": [ { "name": "music", "displayName": "$string:music_form_name", "description": "$string:music_form_desc", "src": "./ets/forms/MusicCard.ets", "uiSyntax": "arkts", "isDefault": true, "updateEnabled": true, "scheduledUpdateTime": "10:30", "updateDuration": 1, "defaultDimension": "2*2", "supportDimensions": [ "2*2", "2*4" ] } ] }参数说明:updateEnabled 控制是否允许定时刷新,如果设置成 false,卡片就完全依赖应用主动推送数据更新;updateDuration 的单位是半小时,值为 1 表示每半小时更新一次;scheduledUpdateTime 是每天固定时刻更新,但音乐卡片实际用这个字段很少,因为歌曲状态变化不可能按固定时间对齐。defaultDimension 和 supportDimensions 决定卡片在桌面上的添加尺寸,这里我建议把默认尺寸设成 2×2,因为大多数用户首次添加时直接点默认添加,卡片太小放不下完整信息,太大又显得笨重。
还有一个容易忽略的字段是 forms 数组里的 src,它指向卡片 UI 组件的源码。注意这个路径和 extensionAbilities 里的 srcEntry 不是一回事,前者指向整个 ExtensionAbility 入口文件,后者指向具体的卡片组件。如果你有多张卡片,每张卡片都可以指向不同的组件文件。
3.3 实现 FormExtensionAbility:卡片数据的“第一帧”从哪来
FormExtensionAbility 是卡片数据的源头。系统在桌面添加卡片时,会实例化这个类并回调 onAddForm。你在回调里返回的数据会作为第一帧数据渲染到卡片上。音乐卡片在这个阶段能做的数据有限,因为播放器可能还没初始化。常见做法是返回一个占位数据,让卡片先渲染出“等待播放”的初始态,等应用进程就绪后再推送真实状态。
import formBindingData from '@ohos.app.form.formBindingData'; import FormExtensionAbility from '@ohos.app.form.FormExtensionAbility'; import formProvider from '@ohos.app.form.formProvider'; export default class MusicFormExtAbility extends FormExtensionAbility { onAddForm(want) { const formData = { songName: '暂无播放', artist: '点击卡片选择歌曲', cover: '/resources/base/media/cover_default.png', isPlaying: false, progress: 0 }; return formBindingData.createFormBindingData(formData); } onUpdateForm(formId) { // 定时刷新或系统拉起时回调,这里做一次状态同步 const formData = this.buildCurrentFormData(); formProvider.updateForm(formId, formBindingData.createFormBindingData(formData)); } onFormEvent(formId, message) { // message 是卡片侧通过 postCardAction 传过来的动作标识 } onRemoveForm(formId) { // 清理该卡片对应的内存资源和监听 } private buildCurrentFormData() { // 从应用侧的数据源拿当前播放信息,这里只做示例 return { songName: '当前歌曲', artist: '歌手', isPlaying: true }; } }逻辑说明:onAddForm 返回的数据结构要和卡片 UI 里的字段一一对应,字段名不一致会导致卡片渲染时拿不到值。createFormBindingData 会把普通对象转成卡片可用的数据格式,这个过程是序列化的,所以对象里不能放函数、Date 实例这类非常量值。onUpdateForm 对应前面配置里的定时刷新和系统主动更新,onFormEvent 则用来接收卡片事件。onRemoveForm 里还要注意解绑数据监听,否则应用进程里会留下无效回调,时间长了积累出一堆内存泄漏。
3.4 卡片 UI:声明式写法里做布局和事件上报
卡片 UI 源码和普通页面的写法类似,但有几个限制:不能使用带 UI 的业务组件,不能访问应用上下文,事件上报只能用 postCardAction。下面是一个最小可用的音乐卡片 UI 骨架:
@Entry @Component struct MusicCard { @State songName: string = ''; @State artist: string = ''; @State isPlaying: boolean = false; @State progress: number = 0; togglePlay() { this.postCardAction({ action: 'message', params: { type: 'togglePlay' } }); } openPlayer() { this.postCardAction({ action: 'router', abilityName: 'PlayerAbility', params: { page: 'player' } }); } build() { Column({ space: 8 }) { Row() { Text(this.songName) .fontSize(20) .fontWeight(FontWeight.Bold) Blank() Text(this.artist) .fontSize(14) .fontColor('#99000000') }.width('100%') Progress({ value: this.progress, total: 100 }) .width('100%') .height(4) Row() { Button() { Text(this.isPlaying ? '暂停' : '播放') }.onClick(() => this.togglePlay()) Blank() Button() { Text('打开播放页') }.onClick(() => this.openPlayer()) }.width('100%') } .padding(16) .width('100%') .height('100%') .backgroundColor('#FFFFFF') } }参数说明:postCardAction 是卡片事件上报的统一出口,action 字段有两种值——message 表示发消息给应用进程,router 表示拉起指定 Ability。params 里的内容会传给 FormExtensionAbility 的 onFormEvent 或直接作为路由参数。需要特别注意的是 router 动作里只写 abilityName 的写法要求该 Ability 在本应用内;如果要拉起别的应用,必须写 bundleName。很多新手在这里只写 abilityName,然后发现点了没反应,就是因为没有带 bundleName。
这里的 @State 变量会先拿来接收 formBindingData 里的同名属性值。ArkTS 卡片的渲染机制是数据到 UI 的单向绑定:Extension 侧产生数据,UI 组件消费数据,UI 内部不能反向修改底层。你可以在卡片里维护一些临时状态,但这些状态会随卡片进程回收而丢失,不能当作唯一数据源。音乐卡片的播放状态永远要回到应用进程去确认。
3.5 把播放状态推上卡片:应用侧数据更新的完整闭环
卡片首帧能显示了,但真正的挑战在于“切歌后卡片怎么跟着变”。应用侧需要监听播放器的状态变化,然后调用 formProvider.updateForm 推送。这个调用可以发生在普通页面里,也可以发生在服务进程里,关键是指定 formId。因为同一张卡片有多个尺寸实例时,每个实例的 formId 是不同的。
import formProvider from '@ohos.app.form.formProvider'; import formBindingData from '@ohos.app.form.formBindingData'; function pushPlayingState(formId: string, state: PlayState) { const formData = { songName: state.songName, artist: state.artist, isPlaying: state.isPlaying, progress: state.progress }; formProvider.updateForm( formId, formBindingData.createFormBindingData(formData) ).then(() => { // 推送成功,这里不需要做额外事情 }).catch((err) => { console.error(`updateForm failed, code=${err.code}, msg=${err.message}`); }); }注意这里有两个细节。第一,updateForm 返回 Promise,失败时要有日志,否则卡片状态不更新时很容易以为是 UI 问题,实际是推送失败。第二,formId 的传递:它由 onAddForm 回调的入参带出来,应用侧要把它缓存起来。存哪?一般是存在应用本地的偏好存储里,或者用一个单例管理器维护一套 formId 集合。千万不要只存一个,因为用户可能同时挂了 2×2、2×4、还有桌面上重复添加的卡片,每张都有自己的 formId。
到这里,一张音乐服务卡片的完整闭环算是通了:添加卡片拿到 formId、卡片展示首帧、用户点击按钮上报事件、应用执行动作、调 updateForm 刷新卡片。这个闭环能跑通,那卡片的骨架就立住了。剩下的是各种让骨架不那么脆弱的细节。
4. 音乐服务卡片避坑指南:5个高频踩坑与排查方法
4.1 卡片添加后白屏,日志里却看不到业务报错
现象:在桌面上长按应用图标,能弹出添加卡片的窗口,但添加完成后卡片区域一片白,连默认占位图都没有。打开 DevEco Studio 看日志,业务代码没有任何输出,好像 ExtensionAbility 根本没被调用。
原因:最常见的是 form_config.json 里的 src 路径写错,或者 module.json5 里 metadata 的 resource 指向了一个不存在的配置资源。还有一种情况是卡片组件的 build 方法里用了卡片不支持的组件,编译虽然通过了,但运行时被宿主拦截,渲染直接失败。新手最容易犯的是在卡片里用 List 组件的懒加载能力,卡片环境里数据源机制和页面不完全一致,导致整个组件树构建失败。
解决:先在 module.json5 里核对 extensionAbilities 的 srcEntry 路径,再去 resources/base/profile 下确认 form_config.json 存在且文件名与 resource 引用一致。注意 resource 里写的是 profile 资源名,不带你F架构资源后缀,写错了不会编译报错。如果路径都没问题,用日志过滤器搜卡片相关关键字,比如 FormExtensionAbility 或 form_provider,看系统有没有抛加载异常。还有一招更直接的:把 forms 里 src 指向的组件临时改成只包含一个 Text,逐步加组件定位到导致白屏的元凶。
4.2 卡片上的按钮点了没反应,postCardAction 像被吞了
现象:卡片渲染正常,点击“播放/暂停”按钮没有一点反应,既没有拉起页面,应用进程也没收到消息。按钮本身看起来也没有任何按压反馈。
原因:第一,你点击的组件没有正确挂 onClof 的 onClick,这不是废话——卡片 UI 里如果外层容器设置了 hitTestBehavior 去拦截触摸,内层按钮的点击可能根本到不了。第二,postCardAction 里的 action 字段写错了。action 只支持 router 和 message 两种合法值,拼写错误会被直接丢弃。第三,router 动作的 abilityName 是本应用的 Ability,但你没有配置对应的 skills 过滤,或者写成跨应用调用但没写 bundleName。
解决:先在卡片组件里加一行日志吗?不行,卡片 UI 里不能直接用 console.log?其实卡片 UI 里可以用 hilog 输出日志到系统,这个是可以的。在 onClick 回调第一行加日志,确认事件有没有触发。能触发但消息没到应用进程,那就打印 postCardAction 的入参,重点检查 action 值。要拉起页面,推荐统一写法:action 为 router,params 里带上目标参数,abilityName 写完整。如果需要跳转时先把应用进程从后台拉起来,要在 Ability 的配置文件里确认 exported 和 ability 名对得上。
4.3 应用在后台播放,卡片却一直显示“暂停”
现象:音乐正在播放,App 切到后台,过一段时间锁屏再解锁,发现桌面卡片上的状态还停留在上一次的“暂停”或旧歌名。点一下播放按钮,声音正常,但卡片状态就是不自动变。
原因:播放状态推送没有在应用退后台时及时触发,或者根本没有监听播放器的状态变化回调。很多音乐 App 的播放器状态是靠 UI 层轮询的,页面在前台时一切正常,一旦页面销毁或退后台,状态变化的监听链断了。卡片的数据推送依赖应用进程活着,如果应用进程在后台被系统冻结,updateForm 调用会失败或者延迟,但卡片 UI 不会自动去拉数据。
解决:把播放状态监听放到一个不依赖 UI 生命周期的地方,比如用 UIAbility 的 onBackground 只做暂停恢复,更重要的是把状态推送放到音乐服务模块里。具体做法是在播放器状态变化回调里直接调 updateForm,不去管当前是哪个页面在前台。另外可以配合临时刷新机制,在应用进入后台前主动把最新状态推一遍。状态推送的触发时机遵循一条原则:状态变化即刻推送,不等到页面刷新或定时刷新兜底。
4.4 卡片在 2×4 和 2×2 之间切换时,布局错乱
现象:卡片支持 2×2 和 2×4,默认添加 2×2 显示正常,用户手动拖成 2×4 后,UI 元素没有适配,底部按钮被截掉,歌曲名字段过长时直接换行把布局挤得乱七八糟。
原因:卡片 UI 里写死了高度或用了固定字号、固定间距,导致在不同尺寸下显示不一致。卡片在当前尺寸变化时不会重新拉数据,而是重新触发布局,所以组件本身必须做到自适应。更隐蔽的原因是 build 里用了条件判断区分尺寸,但尺寸判断依据是错误的环境变量,或者使用 formDimension 这个参数时拼写失败,条件分支永远走默认分支。
解决:别在卡片 UI 里区分 size 写两套布局,除非有非常强的需求。最稳妥的是用弹性布局 + 百分比宽度/高度,让卡片组件在任意尺寸下都能自适配。字体大小可以设成响应式的,比如根据卡片宽度按比例缩放,但最简单有效的方式是限制文字行数和字号,配合省略号处理长字段。尺寸切换后要多机型验证,尤其是系统默认的卡片缩放逻辑在不同分辨率下表现差异很大。
4.5 卡片被用户删除后,应用里的缓存引用还留着
现象:用户在桌面长按删除卡片,然后重新添加同一张卡片,发现新卡片显示的是旧数据,甚至点击事件异常。反复删除添加几次后,应用进程里报 formId 相关的错误日志。
原因:onRemoveForm 回调里没有清理缓存的 formId 和监听器,旧的 formId 还在应用进程里,系统已经把这张卡片的实例销毁了。重新添加的卡片拿到了新的 formId,但应用侧可能还是把数据推给旧 id。另外一种情况是应用侧保留了多个相同 formId 的缓存副本,更新时重复推送。
解决:在 onRemoveForm 里做清理是必须的,把缓存的 formId 集合里对应的项移除,并取消该卡片相关的数据监听。建议对 formId 做一套统一管理,用一个 Map 维护 formId 和卡片类型的对应关系,删除时按 key 移除。新增时先检查集合里有没有相同项,避免重复注册。这一步做好了,卡片的生命周期才真正干净。
5. 把卡片调到流畅:调试技巧、刷新策略与性能习惯
卡片开发跑通和卡片开发跑顺之间,隔着调试和性能两道坎。调试方面,我强烈建议养成用命令行过滤日志的习惯。卡片运行在宿主进程里,你很难像调试普通页面那样直接打断点,很多问题只能靠日志定位。在 DevEco Studio 里不要只盯业务日志,要有意识地过滤 form 相关标签,比如搜 form_ability、form_provider、FormExtension,把系统侧和业务侧的日志对照着看。有时候业务日志一切正常,但系统日志里早就报了数据序列化失败,原因可能是 formData 里塞了不支持的数据类型。这套抓日志的流程,比任何调试工具都可靠。
刷新策略上,音乐卡片的进度条更新频率要控制住。播放进度每秒钟都在变,但你不要每秒都调一次 updateForm。卡片更新的本质是跨进程通信加远端渲染,一秒一次对低端机的桌面渲染压力很大。常见做法是进度条用临时刷新,每 5 到 10 秒推一次,或者只在歌曲切换时把进度归零。真正常见的验证方式是连续播放十分钟,观察桌面卡片的进度是否和播放器实际进度偏差大。如果偏差超过半首歌的时间,说明你的刷新策略太被动,要在关键节点做一次校准。
性能上有两个习惯我最看重。第一个是卡片 UI 里的图片资源一定要用本地资源或已缓存的图,不要在卡片里发起网络请求。卡片的网络能力受限,而且网络图片加载失败会让封面区域变成灰块,非常影响观感。如果你必须展示服务器封面图,先下载到本地缓存,再把本地路径写进 formData。第二个是避免在卡片组件里做复杂计算,卡片组件的 build 方法每次数据到达都可能重新执行,把字符串拼接、时间格式化这些工作放在 ExtensionAbility 侧,做完再推给卡片。
还有一个验证方法很值得你养成习惯:在开发者选项里开启卡片回收开关,模拟系统主动回收卡片。你需要验证的是卡片被回收后,用户再次滑到桌面时卡片能不能快速恢复,恢复后显示的数据是不是最新的。这一步不做,你可能会在真实机器上遇到“卡片消失了但应用进程还留着监听”的隐藏问题。
我自己的经验是每次改完卡片代码,至少花十分钟在真实设备上反复添加、删除、切换尺寸,这个动作比写一百行代码更能发现问题。希望这套流程和踩坑记录能帮你在鸿蒙开发的路上少绕几个弯。
本文还有配套的精品资源,点击获取