Flutter 3.44.9 + OpenHarmony7:home_widget 三方库桌面服务卡片(FormKit)的应用
加入开源鸿蒙跨平台社区,与万千开发者共建鸿蒙生态:Flutter 三方库适配成果与工程实践均在 CPF-Flutter 组织仓库持续开放,适配进度可查阅 三方库适配清单。欢迎关注、提 Issue、提 PR。
天气、日历、音乐控制、待办清单——这些"不打开应用就能在桌面看数据"的卡片,是移动端差异化体验的高地。home_widget 是 Flutter 生态中最主流的桌面小组件插件,本文基于 CPF-Flutter 社区适配版本 0.8.0-ohos-1.0.0,在 Flutter 3.44.9 + 鸿蒙 API 26(超出官方实测的最高 6.0.1/API 21 环境)上完成全链路验证,并从源码层面拆解其跨进程架构:Flutter 主进程与卡片进程如何通过 GSKV 共享数据、点击事件如何跨进程回传、以及 requestPinWidget 一个必踩的参数坑。

读完本文你将获得:
- 一套可直接落地的鸿蒙服务卡片集成方案(原生侧 4 个文件的准确位置与完整代码);
- 对 GSKV 跨进程存储、
formProvider刷新、事件回传三条链路的源码级理解; - 5 条实测排查思路,覆盖
requestPinWidget参数坑与静默失效问题; - 完整的可运行 Demo(Dart + ArkTS)。
一、版本与环境
| 组件 | 版本 | 说明 |
|---|---|---|
| Flutter | 3.44.9+ohos-0.0.1-canary1 | revision4f1a4267af,ohos fork 渠道 |
| OpenHarmony SDK | 26.0.0.105(API 26) | hvigor 6.26.4 / ohpm 26.0.0.630 |
| 目标设备 | OpenHarmony 7.0.0.105 模拟器(ohos-x64) | 官方兼容性仅测到 SDK 6.0.1(21),本文为 API 26 补测 |
| home_widget | 0.8.0-ohos-1.0.0 | CPF-Flutter/fluttertpc_home_widget TAG,上游基线 v0.8.0 |
依赖配置:
dependencies:
home_widget:
git:
url: https://atomgit.com/CPF-Flutter/fluttertpc_home_widget.git
path: packages/home_widget # federated 结构,插件本体在子目录
ref: 0.8.0-ohos-1.0.0
# 插件内部依赖 gitcode 源的 path_provider,统一覆盖为 atomgit 镜像
dependency_overrides:
path_provider:
git:
url: "https://atomgit.com/openharmony-tpc/flutter_packages.git"
path: packages/path_provider/path_provider
二、桌面卡片:它能为你的应用带来什么

在动手集成之前,先回答"什么场景值得做卡片"。桌面卡片的本质是把应用的高价值信息前置到系统桌面,绕过"解锁 → 找图标 → 启动 → 等待 → 操作"的完整链路。按价值类型分三类:
| 类型 | 典型场景 | 对应用的价值 |
|---|---|---|
| 信息前置 | 天气、日程、步数、股票行情 | 提升打开率:用户在桌面即可获取核心信息,应用成为"被动可见"的服务 |
| 快捷操作 | 音乐播放控制、扫码付款、一键打卡、快速记事 | 缩短关键操作路径:从 4-5 步缩短到 1 步,这类入口的转化提升通常是数量级的 |
| 状态追踪 | 外卖配送进度、快递物流、下载进度、叫车等待 | 低频应用的生命线:无需推送打扰,用户瞄一眼桌面就知道进展 |
第三类尤其值得关注:低频高价值应用(快递、航班、缴费)很难靠图标在桌面占据心智,卡片是它们在桌面唯一的常驻形态。此外在鸿蒙生态里,服务卡片是系统重点扶持的特性——支持 2×2 / 2×4 多种规格、可添加到负一屏,桌面长按应用图标即可直接添加对应卡片,系统级的曝光入口比 Android AppWidget 更强。
而 home_widget 在这套体系里的定位非常克制:它不负责卡片长什么样(那是 ArkTS 的事),只负责让 Flutter 应用进程把数据"递"进卡片、并把用户的点击"递"回应用。理解了这个定位,第四节要写的原生侧 4 个文件就不会显得突兀——它们就是"卡片本体"在鸿蒙上的注册与实现。
三、适配架构:三个进程如何协作
桌面卡片是移动端特有的"应用外 UI":卡片由系统桌面进程渲染,数据更新由应用进程驱动,卡片的生命周期回调运行在独立的 FormExtensionAbility 进程。Flutter 引擎只存在于应用进程——这决定了适配的架构形态:
与 Android/iOS 官方实现的对应关系:
| 产物 | Android(官方实现) | iOS(官方实现) | OpenHarmony(本适配) |
|---|---|---|---|
| 数据共享 | SharedPreferences + 文件 | App Group + UserDefault | preferences GSKV(Group Shared KV) |
| 刷新调度 | AppWidgetManager.update | WidgetCenter.reloadTimelines | formProvider.updateForm |
| 卡片 UI | RemoteViews(XML) | SwiftUI @Widget | ArkTS 声明式卡片 |
| 点击拉起 | Activity onNewIntent | widgetURL | FormLink router + EventChannel |
| 后台定时刷新 | WorkManager | TimelineReloadPolicy | setFormNextRefreshTime |
可以看到鸿蒙端的 GSKV 与 iOS 的 App Group 是同构设计——“应用组共享存储”,这正是跨进程卡片方案的通用形态。
四、原生侧集成:必须向系统注册的 4 件事
插件包(HAR)只负责数据通道,而"卡片"这个系统组件必须由应用在原生侧声明。这是鸿蒙 FormKit 的安全模型,也和 Android 的 Receiver + XML、iOS 的 Widget Extension 完全同构。
4.0 文件位置总览:新建 3 个,修改 2 个
以下 5 处改动覆盖了卡片集成的全部原生工作,位置和操作类型如下:
ohos/
└── entry/src/main/
├── module.json5 # 【修改】+ extensionAbilities 卡片声明(4.1)
├── ets/
│ ├── entryability/
│ │ └── EntryAbility.ets # 【修改】+ widgetClick 事件转发(4.5)
│ ├── entryformability/ # 【新建目录】
│ │ └── EntryFormAbility.ets # 【新建】卡片生命周期 + GSKV 读取(4.3)
│ └── widget/pages/ # 【新建目录】
│ └── WidgetCard.ets # 【新建】ArkTS 卡片 UI(4.4)
└── resources/base/profile/
└── form_config.json # 【新建】卡片规格声明(4.2)
注意:
EntryFormAbility.ets与WidgetCard.ets的目录(entryformability/、widget/pages/)默认不存在,需要新建;form_config.json放在resources/base/profile/下,module.json5中通过$profile:form_config引用,文件名必须与引用名一致。
4.1 module.json5:声明卡片提供方
在 ohos/entry/src/main/module.json5 的 module 节点下新增 extensionAbilities 字段(如果已有其他 extensionAbilities,往数组里追加即可):
"extensionAbilities": [
{
"name": "EntryFormAbility",
"srcEntry": "./ets/entryformability/EntryFormAbility.ets",
"label": "$string:widget_display_name",
"type": "form", // 声明为卡片类型扩展能力
"metadata": [
{ "name": "ohos.extension.form",
"resource": "$profile:form_config" } // 指向卡片配置
]
}
]
4.2 form_config.json:卡片规格
新建 ohos/entry/src/main/resources/base/profile/form_config.json:
{
"forms": [{
"name": "widget",
"src": "./ets/widget/pages/WidgetCard.ets", // 卡片 UI 入口
"isDynamic": false, // 静态卡片:数据由 FormBindingData 推送
"updateEnabled": true, // 允许系统定时刷新
"updateDuration": 1, // 每 30 分钟
"defaultDimension": "2*2",
"supportDimensions": ["2*2"]
}]
}
4.3 EntryFormAbility.ets:卡片进程的数据读取者
新建 ohos/entry/src/main/ets/entryformability/EntryFormAbility.ets。卡片被添加到桌面(onAddForm)和系统定时刷新(onUpdateForm)时,从 GSKV 读取全量数据组装 FormBindingData:
onAddForm(want: Want): formBindingData.FormBindingData {
const options: preferences.Options = {
name: 'HomeWidgetPlugin', // ★ 与插件端存储名一致
storageType: preferences.StorageType.GSKV // ★ 跨进程共享存储
};
let dataPreferences = preferences.getPreferencesSync(this.context, options);
let param: Object = dataPreferences.getAllSync();
return formBindingData.createFormBindingData(param);
}
onUpdateForm(formId: string): void {
let updateEnabled = dataPreferences.getSync("updateEnabled", false) as boolean;
if (!updateEnabled) { return; } // 定时刷新受 Dart 侧 startBackgroundUpdate 控制
formProvider.updateForm(formId, formBindingData.createFormBindingData(dataPreferences.getAllSync()));
}
注意两个 ★:存储名 HomeWidgetPlugin 与 GSKV 类型必须和插件端完全一致,否则卡片进程读不到数据——这是跨进程协作的契约点。
4.4 WidgetCard.ets:ArkTS 卡片 UI
新建 ohos/entry/src/main/ets/widget/pages/WidgetCard.ets。数据通过 @LocalStorageProp 按键绑定,键名与 Dart 侧 saveWidgetData 的 key 对应:
@Entry(storage)
@Component
struct WidgetCard {
@LocalStorageProp('title') title: string = 'HomeWidget';
@LocalStorageProp('message') message: string = '等待 Flutter 侧刷新';
@LocalStorageProp('count') count: number = 0;
build() {
FormLink({ action: 'router', abilityName: 'EntryAbility', params: { msg: true } }) {
Column() {
Text(`${this.count}`).fontSize(36).fontWeight(FontWeight.Bold)
Text(this.title).fontSize(16)
Text(this.message).fontSize(12)
}
}
}
}
FormLink 是卡片专用交互组件:action: 'router' 表示点击后以 router 方式拉起 EntryAbility 并携带参数——这是第 5.3 节点击回传链路的起点。
4.5 EntryAbility.ets:把拉起参数转成插件事件
修改 ohos/entry/src/main/ets/entryability/EntryAbility.ets,在 onCreate 与 onNewWant 中新增转发逻辑(其余代码保持不变):
onNewWant(want: Want, launchParam: AbilityConstant.LaunchParam): void {
super.onNewWant(want, launchParam)
if (want.parameters?.["params"]) { // 由卡片 FormLink 拉起
formProvider.getPublishedFormInfoById(want.parameters?.formID?.toString())
.then((data: formInfo.FormInfo) => {
let uri = `home-widget:/CALLBACK?formId=${data["formId"]}&viewId=${viewId}`
this.context.eventHub.emit("widgetClick", uri, AppLaunchType.SUBSEQUENT_WIDGET_LAUNCH)
})
} else {
this.context.eventHub.emit("widgetClick", "", AppLaunchType.SUBSEQUENT_DESKTOP_LAUNCH)
}
}
冷启动走 onCreate(FIRST_WIDGET_LAUNCH),后台被拉起走 onNewWant(SUBSEQUENT_WIDGET_LAUNCH)——插件靠这对启动类型区分"应用由卡片首次拉起"与"运行中收到点击"。
五、源码解析:三个值得学习的设计
5.1 GSKV:一行代码选型背后的进程模型
插件端的存储初始化:
private options: preferences.Options = {
name: 'HomeWidgetPlugin',
storageType: preferences.StorageType.GSKV // Group Shared KV
};
普通 preferences 是进程私有的;GSKV(Group Shared KV)允许同一应用的多个进程读写同一份数据。Flutter 主进程写入 title,几秒后桌面卡片进程 getAllSync() 读到——插件不需要任何跨进程通信代码,存储层天然共享。同时插件启动时做了能力探测 preferences.isStorageTypeSupported(GSKV),低版本系统优雅降级。这就是鸿蒙版的"App Group"。
5.2 updateWidget:全量刷新策略
case "updateWidget":
let param = await this.dataPreferences?.getAll(); // 1. 取全部键值
let obj = formBindingData.createFormBindingData(param);
await formProvider.getPublishedFormInfos().then((data) => {
data.forEach(element => {
formProvider.updateForm(element["formId"], obj) // 2. 逐个刷新所有已加桌卡片
});
})
一个值得注意的细节:name 参数在 updateWidget 中被完全忽略——实现是"取全部数据、刷新全部已加桌卡片"的全量策略。这意味着:(1) 应用只有一种卡片时零心智负担;(2) 多卡片类型场景下每次刷新会把所有键推给所有卡片,键命名需要用前缀隔离(如 weather_temp / todo_count)。
5.3 QueuingEventSink:不丢事件的点击流
卡片点击事件走 EventChannel(home_widget/updates),但存在时序窗口:事件到达时 Flutter 侧可能还没开始 listen(冷启动场景)。适配用 QueuingEventSink 解决——EventSink 的装饰器实现:
success(event: Object): void {
this.enqueue(event); // delegate 为空时先入队
this.maybeFlush(); // delegate 就绪时批量 flush
}
配合 TwoElementArray(容量 2 的环形数组)保存最近一次启动的 uri + appLaunchType 对,initiallyLaunchedFromHomeWidget() 才能在 Dart 侧首次调用时仍能取到冷启动参数。这套"队列 + 装饰器"是 Flutter 插件处理早期事件的经典模式,可直接复用到其他事件型插件适配上。
六、Dart 侧应用
void main() {
WidgetsFlutterBinding.ensureInitialized();
// 冷启动回调:应用被卡片拉起时触发
HomeWidgetOhos.registerInteractivityCallback(interactiveCallback);
runApp(const HomeWidgetDemoApp());
}
('vm:entry-point')
Future<void> interactiveCallback(Uri? data) async {
if (data?.host == 'CALLBACK') { // 与 EntryAbility 拼的 uri 对应
final count = await HomeWidgetOhos.getWidgetData<int>('count', defaultValue: 0) ?? 0;
await HomeWidgetOhos.saveWidgetData<int>('count', count + 1);
await HomeWidgetOhos.saveWidgetData<String>('message', '卡片点击于 ${时间}');
await HomeWidgetOhos.updateWidget(); // 点击后自增并刷新卡片
}
}
// 刷新卡片的主链路
Future<void> _saveAndRefresh() async {
await HomeWidgetOhos.saveWidgetData<String>('title', 'Flutter 已更新');
await HomeWidgetOhos.saveWidgetData<int>('count', _counter);
await HomeWidgetOhos.saveWidgetData<String>('message', '数据来自 Dart');
await HomeWidgetOhos.updateWidget();
}
// 运行中监听点击事件流
HomeWidgetOhos.widgetClicked.listen((Uri? uri) { ... });
// 后台定时刷新(系统约束最小 5 分钟)
await HomeWidgetOhos.startBackgroundUpdate(interval: 5);
七、问题排查思路(实测 + 源码依据)
7.1 requestPinWidget 不弹窗:name 参数必传,且语义是 bundleName
现象:调用 requestPinWidget() 无报错、无桌面弹窗。
排查链路:Dart 侧 requestPinWidget({String? name}) 的 name 在上游语义是 Android 的 widget 名称(可空),但鸿蒙端实现完全改变了它的语义:
case "requestPinWidget":
let name: string = call.args.get("name");
if (name) {
const want: Want = { bundleName: name }; // ← name 直接作为 bundleName
formProvider.openFormManager(want); // 拉起系统卡片管理弹窗
return result.success(null);
}
result.error("-1", "InvalidArguments requestPinWidget must be called with name", null);
不传 name 走 result.error 分支,返回 PlatformException(-1)——弹窗根本不会执行。正确用法是传入应用包名:
await HomeWidgetOhos.requestPinWidget(name: 'com.example.my_cross_platform_app');
方法论:跨平台插件的同名参数在不同平台语义漂移是高频坑,排查时优先读 ArkTS 端的 onMethodCall 实现而不是照搬 Android 文档。
7.2 isRequestPinWidgetSupported 的双重回调缺陷
源码里 result.success(true) 后缺少 return/else:
if (sdkApiVersionInfo >= 18) {
result.success(true);
}
result.success(false); // API ≥ 18 时连续回调两次
MethodChannel 对同一调用的第二次回复会被忽略,所以 Dart 侧拿到正确的 true,功能不受影响——但这是典型的适配代码质量问题,也是 flutter logs 里可能出现 channel 回复警告的来源。给上游提 PR 时这是一个明确的修复点。
7.3 updateWidget 后桌面无变化
按顺序确认:(1) 卡片是否真的已添加到桌面(getInstalledWidgets 返回数量);(2) saveWidgetData 是否在 updateWidget 之前完成(两者都是异步,必须 await);(3) 卡片 UI 的 @LocalStorageProp 键名与 Dart 写入的 key 是否一致——全量刷新机制下,数据都送达了但键名对不上,卡片只是显示默认值,不会报错。
7.4 getWidgetData 返回异常值
GSKV 中按 key 存储任意简单类型,但读取时 defaultValue 的类型必须与写入时一致(存 int 读 String 会命中 defaultValue 而不是报错)。团队协作时建议把键名和类型集中定义成常量表。
7.5 定时刷新不生效
两层开关:form_config.json 的 updateEnabled: true 是系统级允许;ArkTS onUpdateForm 内部还检查 GSKV 里的 updateEnabled 键——只有 Dart 侧调用过 startBackgroundUpdate 后定时刷新才真正生效。另外系统约束 setFormNextRefreshTime 的最小间隔为 5 分钟,传入更小的值会被系统拒绝。
八、运行验证

验证设备:OpenHarmony 7.0.0.105 模拟器(ohos-x64,API 26)。
| 验证项 | 结果 |
|---|---|
requestPinWidget 拉起桌面添加确认弹窗 | 通过(传 bundleName 后) |
卡片添加桌面 →onAddForm 读取 GSKV 渲染初始数据 | 通过 |
| Flutter 侧刷新 → 桌面卡片 title/count/message 实时变化 | 通过 |
| 点击卡片 → 应用回跳 → count 自增并再次刷新 | 通过 |
getWidgetData 回读 | 通过 |
renderFlutterWidget Flutter 视图渲染为 PNG | 通过 |
九、适用范围与已知限制
已验证可用:卡片添加引导、GSKV 数据写入/读取、全量刷新、点击回跳自增、后台定时刷新、Flutter 视图渲染。
当前限制(以 TAG 0.8.0-ohos-1.0.0 源码为准):
setAppGroupId为 iOS 兼容接口,鸿蒙端空操作(返回 true 不执行逻辑);updateWidget忽略name参数,多卡片类型场景需自行用键前缀隔离数据;- 卡片 UI 必须原生 ArkTS 编写(静态卡片不跑 Flutter 引擎),Flutter 视图只能通过
renderFlutterWidget截图降级使用——交互型卡片无法复用 Flutter 代码; startBackgroundUpdate的interval受系统 5 分钟下限约束。
十、完整示例代码
10.1 main.dart(核心逻辑)
// home_widget 0.8.0-ohos-1.0.0 鸿蒙服务卡片 Demo
import 'package:flutter/material.dart';
import 'package:home_widget/home_widget_ohos.dart';
void main() {
WidgetsFlutterBinding.ensureInitialized();
HomeWidgetOhos.registerInteractivityCallback(interactiveCallback);
runApp(const HomeWidgetDemoApp());
}
('vm:entry-point')
Future<void> interactiveCallback(Uri? data) async {
if (data?.host == 'CALLBACK') {
final count = await HomeWidgetOhos.getWidgetData<int>('count', defaultValue: 0) ?? 0;
await HomeWidgetOhos.saveWidgetData<int>('count', count + 1);
await HomeWidgetOhos.saveWidgetData<String>(
'message', '卡片点击于 ${DateTime.now().toIso8601String().substring(11, 19)}');
await HomeWidgetOhos.updateWidget();
}
}
// —— Demo 页面核心方法 ——
Future<void> _saveAndRefresh() async { // 主链路:写数据 + 全量刷新
_counter++;
final okTitle = await HomeWidgetOhos.saveWidgetData<String>('title', 'Flutter 已更新 $_counter 次');
await HomeWidgetOhos.saveWidgetData<int>('count', _counter);
await HomeWidgetOhos.saveWidgetData<String>('message', '数据来自 Dart');
final okUpdate = await HomeWidgetOhos.updateWidget();
debugPrint('save=$okTitle update=$okUpdate');
}
Future<void> _requestPin() async { // ★ name 必传(bundleName 语义)
final supported = await HomeWidgetOhos.isRequestPinWidgetSupported();
if (supported == true) {
try {
await HomeWidgetOhos.requestPinWidget(name: 'com.example.my_cross_platform_app');
} catch (e) {
debugPrint('requestPinWidget 失败:$e');
}
}
}
Future<void> _listWidgets() async {
final widgets = await HomeWidgetOhos.getInstalledWidgets();
for (final w in widgets) {
debugPrint('formId=${w.ohosWidgetId} name=${w.ohosLabel} bundle=${w.ohosClassName}');
}
}
Future<void> _toggleBackground(bool running) async {
running
? await HomeWidgetOhos.stopBackgroundUpdate()
: await HomeWidgetOhos.startBackgroundUpdate(interval: 5); // 系统 ≥5 分钟
}
10.2 ArkTS 原生侧(4 个文件的完整实现见第 4 节)
| 文件 | 位置 |
|---|---|
| EntryFormAbility.ets | ohos/entry/src/main/ets/entryformability/ |
| WidgetCard.ets | ohos/entry/src/main/ets/widget/pages/ |
| form_config.json | ohos/entry/src/main/resources/base/profile/ |
| module.json5 / EntryAbility.ets | extensionAbilities 声明与 widgetClick 转发(第 4.1/4.5 节代码) |
十一、总结
home_widget 的鸿蒙适配抓住了一个正确的抽象:用 GSKV 共享存储代替跨进程消息——Flutter 进程写、卡片进程读,架构复杂度被压缩到存储选型一行代码。配合 QueuingEventSink 的事件排队、formProvider 的全量刷新,插件在 Dart 侧暴露的接口面与 Android/iOS 保持同构。
对开发者的三点实用结论:
- 集成成本集中在原生侧 4 个文件——这是 FormKit 安全模型决定的,与 Android/iOS 做 Widget 的成本同构,不存在"零原生代码"的桌面卡片方案;
- 参数语义以 ArkTS 实现为准——
requestPinWidget的name在鸿蒙端是 bundleName,照搬 Android 文档会静默失败; - 全量刷新策略下注意键名规划——多卡片场景用前缀隔离,键名与
@LocalStorageProp绑定的一致性是"卡片不报错但也不更新"这类静默问题的唯一排查点。
欢迎在 CPF-Flutter 组织仓库提交 Issue 与 PR,共同完善鸿蒙 Flutter 三方库生态。
更多推荐



所有评论(0)