把项目从 Android 迁到鸿蒙(HarmonyOS NEXT)的那段时间,我踩得最深的坑不是 Flutter 引擎能不能跑起来,而是应用装到手机上之后,用户在桌面那个图标点开一次就再也不碰了。后来决定接服务卡片,把核心数据直接放到桌面上,事情才有了转机。但这里有个绕不开的现状:Flutter 的 UI 没办法直接渲染服务卡片,鸿蒙的卡片跑在独立的 ArkTS 运行环境里,只能靠系统级菜单(FormMenu)和应用跳转,把 Flutter 的业务逻辑和卡片交互串起来。这篇文章就是我把“服务卡片 + FormMenu + Flutter 页面跳转”整个链路在真实工程里跑通的完整记录,操作步骤、代码、翻车点都放在里面,正在做 Flutter 鸿蒙化改造的同学可以直接照着改。
1. 服务卡片在 Flutter 鸿蒙应用里是什么角色
1.1 Flutter UI 为什么不能直接渲染服务卡片
先说一个很多人一开始会误解的点:服务卡片不是 Flutter 页面,也不是在 Flutter 工程里用 Dart 写的组件。鸿蒙的服务卡片由 FormExtensionAbility 承载,卡片页面本身是 ArkTS 写的,运行在独立的表单运行环境里。你在卡片上看到的每一个组件、每一个数字,背后都是一套独立的 ArkTS UI 树。
为什么会这样?因为服务卡片的定位是“轻量、免启动、秒开”。系统为了保证桌面在低功耗场景下依然流畅,不可能在每张小卡片里塞一个 Flutter 引擎。Flutter 引擎在鸿蒙上跑在 UIAbility 里,也就是你的主应用进程。卡片和主应用之间是两套运行时,它们不共享内存,也不能直接调用彼此的方法。
所以实际架构就变成了这样:
| 层级 | 运行环境 | 负责的事情 |
|---|---|---|
| 卡片页面(ArkTS 组件) | FormExtensionAbility | 展示数据、接收点击 |
| FormMenu(系统级菜单) | 系统菜单服务 | 提供“打开应用”“刷新”等操作入口 |
| Flutter 业务层 | UIAbility + FlutterEngine | 主界面、复杂业务逻辑、数据持久化 |
这个分层带来的直接结论就是:服务卡片的开发,你绕不开 ArkTS。但好消息是,卡片通常只承担“展示核心信息 + 引导用户打开 App”这个任务,不需要很复杂,所以工作量不会太大。
1.2 FormMenu 在这套链路里的桥梁位置
很多人按官方文档一步步接服务卡片,卡片能显示了,但发现用户在桌面上点卡片右上角的“...”菜单,什么反应都没有。这就是典型的三层链路没打通:卡片页面有了,Flutter 主应用也有了,但中间缺了一个能承载“用户操作意图”的桥梁。
FormMenu 就是这座桥。它由系统菜单框架渲染,不依赖 Flutter 引擎,也不依赖卡片页面里某个具体按钮。你在 FormMenu 里配置一个菜单项,用户点击后,系统会直接拉起你指定的 Ability,同时携带你预先定义好的 parameters 参数。Flutter 侧拿到这些参数,就知道“用户是从卡片上某个入口点进来的”,再去做对应页面的路由跳转。
FormMenu 的另一个好处是它不占卡片空间。小卡片本来就只有巴掌大,你要是把按钮堆在卡片页面里,视觉效果和信息密度都会变得很难看。把操作项收进系统菜单,卡片页面只留核心数据,界面干净,交互也符合用户对鸿蒙卡片的预期。
2. 开工前的工程基线:先把 Flutter 鸿蒙壳搭稳
2.1 确定 Flutter SDK 和 DevEco Studio 的匹配关系
接入服务卡片之前,首先要保证 Flutter 工程能在鸿蒙设备上正常跑起来。这块不同项目差异很大,取决于你手里的 Flutter SDK 分支和鸿蒙 API 版本。
目前社区和官方适配比较常用的路径是:Flutter 多平台框架通过 OpenHarmony 适配分支支持鸿蒙设备,DevEco Studio 负责构建和签名 HAP 包。我自己的基线是 DevEco Studio 5.x 配套的 API 12,Flutter 侧使用支持鸿蒙目标的 SDK 分支。你在选择版本时,先确认三件事:
- Flutter SDK 分支是否支持你目标设备的鸿蒙 API 版本;
- DevEco Studio 的 SDK 版本是否匹配 ArkTS 编译要求;
- 签名证书和配置文件是否已经申请到位。
这一项卡住,后面所有卡片代码都白写,所以务必先跑通一个空的 Flutter 鸿蒙工程再继续。
2.2 在既有 Flutter 工程里追加鸿蒙壳和卡片模块
Flutter 工程接鸿蒙服务卡片,不需要把 Flutter 部分重写,只需要在鸿蒙壳工程里增加卡片所需的模块和资源。
典型目录结构如下:
my_flutter_app/ ├─ lib/ # Flutter 业务代码 ├─ ohos/ # 鸿蒙壳工程(或 harmony/) │ └─ entry/src/main/ │ ├─ ets/ │ │ ├─ entryability/ │ │ │ └─ EntryAbility.ets # 主 Ability │ │ ├─ entryformability/ │ │ │ └─ EntryFormAbility.ets # 卡片 Ability │ │ └─ widget/ │ │ └─ pages/ │ │ └─ FlutterCard.ets # 卡片页面 │ ├─ resources/ │ │ └─ base/profile/ │ │ └─ form_config.json # 卡片配置文件 │ └─ module.json5 # 模块配置 └─ pubspec.yamlmodule.json5里需要注册卡片扩展 Ability,类型固定为form,同时指定卡片的元数据配置:
{ "module": { "name": "entry", "type": "entry", "abilities": [ { "name": "EntryAbility", "srcEntry": "./ets/entryability/EntryAbility.ets", "exported": true, "skills": [ { "entities": ["entity.system.home"], "actions": ["action.system.home"] } ] } ], "extensionAbilities": [ { "name": "EntryFormAbility", "srcEntry": "./ets/entryformability/EntryFormAbility.ets", "label": "$string:EntryFormAbility_label", "description": "$string:EntryFormAbility_desc", "type": "form", "metadata": [ { "name": "ohos.extension.form", "resource": "$profile:form_config" } ] } ] } }extensionAbilities里的type必须是form,不能写成别的名字。我见过有人把type误写成service,结果桌面长按应用图标永远看不到“服务卡片”入口。
2.3 form_config.json 基础配置
form_config.json负责定义卡片尺寸、更新策略和默认状态。一个最小可用的配置如下:
{ "forms": [ { "name": "flutter_card", "displayName": "$string:card_name", "description": "$string:card_description", "src": "./ets/widget/pages/FlutterCard.ets", "uiSyntax": "arkts", "window": { "designWidth": 720, "autoDesignWidth": true }, "isDefault": true, "updateEnabled": true, "scheduledUpdateTime": "10:30", "updateDuration": 1, "defaultDimension": "2*2", "supportDimensions": ["2*2", "2*4"] } ] }几个字段值得单独解释一下:
updateEnabled和updateDuration控制定时刷新,updateDuration最小粒度是 30 分钟(按小时配的话填 1 代表每小时),不是你想刷就能刷;scheduledUpdateTime是指定每天某个时刻刷新;supportDimensions建议把2*2和2*4都写上,实际渲染时系统按桌面可用空间自动选;isDefault如果不设成true,用户长按应用图标后可能看不到默认卡片预览图,误以为没做成。
配置好之后先 build 一个 HAP 装到手机上,确认桌面长按应用确实能拉到卡片,再往下做 FormMenu 和跳转逻辑,避免后面排查问题时混入环境因素。
3. FormMenu 配置实战:静态占位与动态下发
3.1 两种下发方式,按场景取舍
FormMenu 的配置不是写在 ArkTS 页面里的,而是挂在服务卡片的数据绑定对象上。你可以把它理解成:卡片 UI 绑定一份数据,这份数据里除了展示字段,还包含一个formMenu字段,系统读取到之后负责渲染右上角的菜单。
下发方式有两条路:
- 在
FormExtensionAbility的onAddForm/onUpdateForm回调里,动态构造formMenu字段; - 固定配置在
form_config.json的forms项里。
我实际项目里大多用动态方式。因为菜单项经常要跟应用状态联动:用户在 Flutter 里登录了,卡片菜单要显示“查看详情”;没登录,菜单要显示“去登录”。这种动态变化,靠静态配置是搞不定的。
3.2 动态下发 FormMenu 的完整代码
EntryFormAbility.ets里核心逻辑如下:
import FormExtensionAbility from '@ohos.app.form.FormExtensionAbility'; import formBindingData from '@ohos.app.form.formBindingData'; import Want from '@ohos.app.ability.Want'; const CARD_MENU = { menuItems: [ { text: '打开应用', action: 'router', bundleName: 'com.example.fluttercard', abilityName: 'EntryAbility', parameters: { targetPage: 'home', from: 'serviceCard' } }, { text: '刷新数据', action: 'message', bundleName: 'com.example.fluttercard', abilityName: 'EntryAbility', parameters: { actionType: 'refresh' } } ] }; export default class EntryFormAbility extends FormExtensionAbility { onAddForm(want: Want) { const dataObj = { title: 'Flutter 卡片', value: '26', formMenu: JSON.stringify(CARD_MENU) }; return formBindingData.createFormBindingData(dataObj); } onUpdateForm(formId: string) { const latestValue = this.fetchLatestValue(); const dataObj = { title: 'Flutter 卡片', value: latestValue, formMenu: JSON.stringify(CARD_MENU) }; return formBindingData.createFormBindingData(dataObj); } private fetchLatestValue(): string { // 这里从共享存储或数据源拉最新值 return '27'; } }有个细节已经在上面的代码里体现:formMenu的值必须是JSON.stringify之后的字符串,不能直接塞一个对象进去。我第一次就是直接塞对象,系统解析失败,菜单静默不显示,也不报错,非常坑。
3.3 menuItems 关键字段逐项拆解
FormMenu 的menuItems数组里每一项支持的字段差别很大,配错一个整个菜单都失效。我常用的字段整理如下:
| 字段 | 类型 | 作用 |
|---|---|---|
text | string | 菜单项显示文本 |
action | string | 点击行为类型:router/message/call |
bundleName | string | 目标应用包名,必须和签名一致 |
abilityName | string | 目标 Ability 名 |
parameters | object | 传给目标 Ability 的透传参数 |
bundleName是最容易配错的地方。它不是工程的模块名entry,也不是你在 Flutter pubspec 里写的包名,而是鸿蒙应用配置里最终签名的bundleName。这个值通常长这样:com.example.fluttercard。拿不准的时候,安装 HAP 后在终端执行下面命令查:
hdc shell bm dump -n com.example.fluttercard输出里的bundleName字段,才是你配置菜单时要填的那一个。
3.4 router、message、call 三种 action 怎么选
action字段决定了用户点击菜单项后系统做什么,三种值适用场景完全不同:
router:拉起指定 Ability 并切换到前台,适合“打开详情页”“进入某个业务模块”这类需要用户看到界面的操作。message:向指定 Ability 发送一条消息,不一定会切换到前台,适合“刷新数据”“标记已读”这类后台操作。服务卡片收到 message 后可以在后台更新卡片数据,用户无感。call:执行一个不受 UI 约束的调用,适合一些系统级能力。日常业务里我用得很少,因为一旦处理不当,会打断用户当前操作。
实际产品里,我给卡片菜单只保留两项:打开应用用router,刷新数据用message。菜单项不宜超过三四个,每多一项,用户选择成本就高一分,卡片本身追求的就是“一秒获取信息、一秒进入应用”。
4. 应用内添加服务卡片的入口与回跳链路
4.1 应用内做一个“添加到桌面”入口,不靠蛮力
“应用内添加服务卡片”听起来像是一个 API 就能搞定的事,但真实鸿蒙系统对第三方应用直接往桌面写卡片是有限制的。我最初尝试直接调系统接口添加,结果在部分设备上被权限拦截,后来改用“应用内提供状态检测 + 引导用户到桌面添加”的组合方案,反而最稳。
产品交互上,我在 Flutter 的设置页放了一个“添加到桌面”入口。用户点击后,Flutter 侧先通过 MethodChannel 问鸿蒙原生层:当前应用有没有已经添加到桌面的卡片。
Flutter 侧代码:
import 'package:flutter/services.dart'; const cardChannel = MethodChannel('com.example.fluttercard/card_service'); Future<void> onAddCardClick() async { try { final alreadyAdded = await cardChannel.invokeMethod<bool>('isCardAdded'); if (alreadyAdded == true) { // 提示用户:服务卡片已添加到桌面,长按可编辑尺寸 showToast('服务卡片已在桌面,长按可调整大小'); return; } // 引导用户长按桌面空白处添加卡片 showDialog( context: context, builder: (context) => AlertDialog( title: Text('添加服务卡片'), content: Text('回到桌面,长按空白区域,找到本应用图标后长按并选择“服务卡片”。'), actions: [ TextButton( onPressed: () => Navigator.pop(context), child: Text('我知道了'), ), ], ), ); } on PlatformException catch (e) { // 这里要做兜底提示,不要静默失败 debugPrint('检查卡片状态失败: ${e.message}'); } }鸿蒙原生侧在EntryAbility.ets里实现isCardAdded:
import formHost from '@ohos.app.form.formHost'; private async isCardAdded(): Promise<boolean> { try { const forms = await formHost.getAllFormsInfo(this.context); return forms.length > 0; } catch (error) { console.error('检查卡片失败: ' + JSON.stringify(error)); return false; } }这个方案不依赖特殊系统权限,行为也符合系统约束。后面 Flutter 侧只需要根据结果切换按钮文案:已添加就显示“桌面卡片管理”,未添加就显示“添加到桌面”。
4.2 从卡片菜单回跳到 Flutter 页面的参数链路
用户从桌面卡片点击 FormMenu 的“打开应用”,系统会拉起EntryAbility。此时分两种情况:
- 应用冷启动:走
onCreate,Flutter 引擎还在加载; - 应用在后台:走
onNewWant,Flutter 引擎已经就绪。
两种情况下都要把 FormMenu 里parameters的内容接住,再传给 Flutter。我用的方案是:原生层先把参数保存到一个公共变量里,Flutter 侧通过 EventChannel 监听,同时启动后主动调用一次原生方法拉取挂起参数,防止漏消息。
EntryAbility.ets关键逻辑:
import UIAbility from '@ohos.app.ability.UIAbility'; import Want from '@ohos.app.ability.Want'; import AbilityConstant from '@ohos.app.ability.AbilityConstant'; export default class EntryAbility extends UIAbility { private pendingCardParams: Record<string, Object> | null = null; onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void { super.onCreate(want, launchParam); this.pendingCardParams = want.parameters || null; } onNewWant(want: Want, launchParam: AbilityConstant.LaunchParam): void { super.onNewWant(want, launchParam); this.pendingCardParams = want.parameters || null; // 通知 Flutter 侧有新参数 this.notifyFlutterCardParams(this.pendingCardParams); } getPendingCardParams(): Record<string, Object> | null { return this.pendingCardParams; } }Flutter 侧初始化时主动拉取一次:
Future<Map?> fetchPendingCardParams() async { return cardChannel.invokeMethod<Map>('getPendingCardParams'); }拿到参数后,根据parameters['targetPage']做路由:
void handleCardParams(Map? params) { if (params == null || params['from'] != 'serviceCard') return; final targetPage = params['targetPage']?.toString() ?? 'home'; switch (targetPage) { case 'home': navigatorKey.currentState?.pushNamed('/home'); break; case 'detail': navigatorKey.currentState?.pushNamed('/detail'); break; default: navigatorKey.currentState?.pushNamed('/home'); } }这里特别注意:冷启动场景下不要指望原生主动往 Flutter 推参数,因为 Flutter 引擎还在初始化,监听器不一定挂上了。稳妥做法就是上面说的“启动后主动拉取一次 + 后台热启动走事件推送”,双保险。
4.3 卡片数据刷新与 Flutter 数据源共享
服务卡片和 Flutter 应用不在同一个运行时,数据不能直接共享。我试过把数据存在 Flutter 侧的内存里,卡片完全读不到。后来的做法是引入一个轻量的持久化通道:
- Flutter 侧在业务数据变化后,写入一个 JSON 文件或 Preferences;
EntryFormAbility的onUpdateForm读同一个数据源;- 需要主动刷新时,Flutter 侧调原生方法,原生通过
formProvider通知卡片更新。
主动刷新卡片的关键 API 是formProvider:
import formProvider from '@ohos.app.form.formProvider'; private refreshCardById(formId: string, data: Record<string, Object>) { const formData = formBindingData.createFormBindingData({ title: data['title'], value: data['value'], formMenu: JSON.stringify(CARD_MENU) }); formProvider.updateForm(formId, formData) .then(() => console.info('卡片刷新成功')) .catch((error) => console.error('卡片刷新失败: ' + JSON.stringify(error))); }这块的现实约束是:卡片太频繁刷新会被系统节流。我的策略是只在关键业务事件(用户完成一笔记录、状态发生切换)时主动刷新,其余时间依赖updateDuration定时兜底。
5. 联调踩坑实录:菜单不显、跳转不灵、参数丢失
5.1 formMenu 忘了 stringify,菜单静默消失
这个问题我在前面已经提到,但值得再放大讲一遍。现象是:卡片正常显示,长按卡片也弹出了系统菜单按钮,但点开后菜单列表是空的。
排查过程里我先检查了form_config.json,没有问题;又怀疑是系统菜单服务缓存,重启手机也没用。最后断点在onAddForm返回的dataObj上,发现formMenu字段是对象而不是字符串。鸿蒙的卡片数据绑定层对formMenu的解析要求必须是 JSON 字符串,否则直接跳过,而且不抛异常。
修改方式就是一行:
formMenu: JSON.stringify(CARD_MENU)改完重新装 HAP,菜单立刻出现。这类问题隐蔽在没有报错,一旦遇到菜单不显示,第一反应就该看数据绑定对象里的formMenu类型。
5.2 bundleName 配错,点击菜单引发不了任何跳转
还有一次是菜单显示没问题,点“打开应用”却毫无反应。我用 hdc 反复看日志,发现系统在拉起 Ability 时提示找不到目标。
原因出在bundleName上。我在配菜单时直接用了 module 名entry,但系统需要的是应用的全量包名。鸿蒙的跳转匹配是严格匹配bundleName+abilityName,不会给你做自动补全。
排查命令:
hdc shell bm dump -n com.example.fluttercard对照实际包名修正后,跳转就正常了。顺带提醒:abilityName也别写错目录层级,它对应EntryAbility在module.json5里的name字段。
5.3 桌面长按应用,看不到“服务卡片”入口
这个坑在接入初期出现的概率很高。卡片代码写好了,HAP 也装了,但长按桌面应用图标就是没有“服务卡片”菜单。
逐项排查顺序建议:
module.json5里extensionAbilities的type是不是form;srcEntry路径能不能正确指向EntryFormAbility.ets;metadata的resource是否正确指向form_config;form_config.json里forms数组是否为空;- 卡片配置里的
src是否指向了真实存在的 ArkTS 页面文件; - 签名证书是否带上了 Profile 文件,调试包经常漏这一步。
还可以用 hdc 查系统侧到底有没有注册这个表单扩展:
hdc shell aa dump form info -u 0这个命令能列出设备上所有已注册的卡片 Ability,信息非常全。如果这里看不到你的EntryFormAbility,就要回头检查配置;能看到,再排查桌面交互层面的问题。
5.4 冷启动跳回 Flutter,页面白屏或丢参数
FormMenu 配置正确、Ability 也能拉起之后,最容易遇到的是“参数丢了”。表现是:应用被杀掉,点卡片菜单进 App,Flutter 页面加载完了,但没有跳转到目标页。
根因我在 4.2 里说过:冷启动时 Flutter 引擎还在初始化,原生想主动推送也推不进去。我最早在onCreate里面拿参数立刻发给 Flutter,Flutter 侧还没注册监听,消息就丢了。
解决思路调整为:
- 原生侧把参数放到一个变量里,提供
getPendingCardParams方法供 Flutter 拉取; - Flutter 侧在 onGenerateRoute 或页面初始化完成后,主动调用一次;
- 后台热启动场景走
onNewWant+ EventChannel 推送。
这样两路覆盖,冷启动热启动都不丢。
5.5 定时刷新被系统节流,数据看起来像“没更新”
最后一个是关于数据时效性的。服务卡片的scheduledUpdateTime我最初配置的是每分钟刷新一次,心想这样数据能保证最新。结果发现到了时间点卡片纹丝不动,日志里也没有任何异常。
查了官方说明才发现,updateDuration的时效单位是小时,最小不能低于 30 分钟的约束在部分系统版本上还会更严格。你配了 1,不代表 1 分钟,代表 1 小时。要真正实现秒级/分钟级的数据更新,得靠主动推送,不能依赖定时刷新。
所以实际项目里我做了两层配合:
- 低频数据(天气、每日目标)走
updateDuration定时兜底; - 高频、事件型数据(用户打卡、完成记录)走 Flutter 业务触发 +
formProvider.updateForm主动推。
这样的组合既符合系统机制,又能让卡片数据相对及时。
6. 过了基础卡点后,值得继续做的事
整套链路跑通之后,我有几个比较深的体会。
第一,卡片 UI 一定要克制。我第一次做卡片时恨不得把图表、列表、状态全塞进去,结果在2*2尺寸下一团糟。后来砍到只剩两个核心数字 + 一个状态文案,信息清晰度反而大幅提升。桌面卡片是给用户扫一眼用的,不是给用户沉浸阅读的。
第二,FormMenu 的菜单项也需要克制。我的默认配置是打开应用和刷新数据两个,最多再加一个跳转设置页。超过三个菜单项后,选择成本上升,用户的使用反馈明显变差。
第三,数据通道尽早设计。Flutter 和卡片是两个运行时,这个事实越早接受,架构上越省事。我给后续项目的建议是:一开始就在 Flutter 侧做一层统一的数据导出接口,把需要展示到卡片的数据序列化成 JSON 存到公共存储,卡片侧只负责读和展示,不要各写一套逻辑。
再分享一个操作上的小技巧:调试卡片和 FormMenu 的时候,大部分时间不需要在桌面上手动反复拖拽。命令行里直接装包、查表单信息、手动触发一次onUpdateForm,效率会高很多:
hdc install -r entry-default-signed.hap hdc shell aa dump form info -u 0经历过这次 Flutter 鸿蒙化的实践,我最直观的感受是:服务卡片本身不难,难的是把 ArkTS 卡片、系统菜单、Flutter 业务这三个不同运行时之间的逻辑彻底理顺。把 FormMenu 的配置规范背熟,把 “添加入口检测 + 冷热启动参数回跳” 这条链路设计好,再去填其他功能,整个过程就会顺很多。之后我还会继续在这一版架构上扩展更复杂的卡片交互,有新的坑和心得再来更新。