尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Flutter鸿蒙化实战:服务卡片+FormMenu跳转链路全解析

Flutter鸿蒙化实战:服务卡片+FormMenu跳转链路全解析 把项目从 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 12Flutter 侧使用支持鸿蒙目标的 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数组里每一项支持的字段差别很大配错一个整个菜单都失效。我常用的字段整理如下字段类型作用textstring菜单项显示文本actionstring点击行为类型router/message/callbundleNamestring目标应用包名必须和签名一致abilityNamestring目标 Ability 名parametersobject传给目标 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); Futurevoid onAddCardClick() async { try { final alreadyAdded await cardChannel.invokeMethodbool(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里实现isCardAddedimport formHost from ohos.app.form.formHost; private async isCardAdded(): Promiseboolean { 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。此时分两种情况应用冷启动走onCreateFlutter 引擎还在加载应用在后台走onNewWantFlutter 引擎已经就绪。两种情况下都要把 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: Recordstring, 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(): Recordstring, Object | null { return this.pendingCardParams; } }Flutter 侧初始化时主动拉取一次FutureMap? fetchPendingCardParams() async { return cardChannel.invokeMethodMap(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 文件或 PreferencesEntryFormAbility的onUpdateForm读同一个数据源需要主动刷新时Flutter 侧调原生方法原生通过formProvider通知卡片更新。主动刷新卡片的关键 API 是formProviderimport formProvider from ohos.app.form.formProvider; private refreshCardById(formId: string, data: Recordstring, 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但系统需要的是应用的全量包名。鸿蒙的跳转匹配是严格匹配bundleNameabilityName不会给你做自动补全。排查命令hdc shell bm dump -n com.example.fluttercard对照实际包名修正后跳转就正常了。顺带提醒abilityName也别写错目录层级它对应EntryAbility在module.json5里的name字段。5.3 桌面长按应用看不到“服务卡片”入口这个坑在接入初期出现的概率很高。卡片代码写好了HAP 也装了但长按桌面应用图标就是没有“服务卡片”菜单。逐项排查顺序建议module.json5里extensionAbilities的type是不是formsrcEntry路径能不能正确指向EntryFormAbility.etsmetadata的resource是否正确指向form_configform_config.json里forms数组是否为空卡片配置里的src是否指向了真实存在的 ArkTS 页面文件签名证书是否带上了 Profile 文件调试包经常漏这一步。还可以用 hdc 查系统侧到底有没有注册这个表单扩展hdc shell aa dump form info -u 0这个命令能列出设备上所有已注册的卡片 Ability信息非常全。如果这里看不到你的EntryFormAbility就要回头检查配置能看到再排查桌面交互层面的问题。5.4 冷启动跳回 Flutter页面白屏或丢参数FormMenu 配置正确、Ability 也能拉起之后最容易遇到的是“参数丢了”。表现是应用被杀掉点卡片菜单进 AppFlutter 页面加载完了但没有跳转到目标页。根因我在 4.2 里说过冷启动时 Flutter 引擎还在初始化原生想主动推送也推不进去。我最早在onCreate里面拿参数立刻发给 FlutterFlutter 侧还没注册监听消息就丢了。解决思路调整为原生侧把参数放到一个变量里提供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 的配置规范背熟把 “添加入口检测 冷热启动参数回跳” 这条链路设计好再去填其他功能整个过程就会顺很多。之后我还会继续在这一版架构上扩展更复杂的卡片交互有新的坑和心得再来更新。
返回列表