加入开源鸿蒙跨平台社区,与万千开发者共建鸿蒙生态: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)。

一、版本与环境

组件版本说明
Flutter3.44.9+ohos-0.0.1-canary1revision4f1a4267af,ohos fork 渠道
OpenHarmony SDK26.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_widget0.8.0-ohos-1.0.0CPF-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 引擎只存在于应用进程——这决定了适配的架构形态:

系统桌面进程

FormExtensionAbility 进程

跨进程桥梁:preferences GSKV 存储

应用进程(Flutter 引擎)

saveWidgetData 写入

updateWidget 触发

getAllSync 读取

FormBindingData 推送

FormLink router 点击拉起

Dart 业务代码
HomeWidgetOhos

MethodChannel('home_widget')
EventChannel('home_widget/updates')

HomeWidgetPlugin
(title / message / count…)

EntryFormAbility
onAddForm / onUpdateForm

formProvider.updateForm

WidgetCard.ets(ArkTS 静态卡片)
@LocalStorageProp 绑定数据键

与 Android/iOS 官方实现的对应关系:

产物Android(官方实现)iOS(官方实现)OpenHarmony(本适配)
数据共享SharedPreferences + 文件App Group + UserDefaultpreferences GSKV(Group Shared KV)
刷新调度AppWidgetManager.updateWidgetCenter.reloadTimelinesformProvider.updateForm
卡片 UIRemoteViews(XML)SwiftUI @WidgetArkTS 声明式卡片
点击拉起Activity onNewIntentwidgetURLFormLink router + EventChannel
后台定时刷新WorkManagerTimelineReloadPolicysetFormNextRefreshTime

可以看到鸿蒙端的 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.etsWidgetCard.ets 的目录(entryformability/widget/pages/)默认不存在,需要新建;form_config.json 放在 resources/base/profile/ 下,module.json5 中通过 $profile:form_config 引用,文件名必须与引用名一致。

4.1 module.json5:声明卡片提供方

ohos/entry/src/main/module.json5module 节点下新增 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,在 onCreateonNewWant新增转发逻辑(其余代码保持不变):

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)
  }
}

冷启动走 onCreateFIRST_WIDGET_LAUNCH),后台被拉起走 onNewWantSUBSEQUENT_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);

不传 nameresult.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类型必须与写入时一致(存 intString 会命中 defaultValue 而不是报错)。团队协作时建议把键名和类型集中定义成常量表。

7.5 定时刷新不生效

两层开关:form_config.jsonupdateEnabled: 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 源码为准):

  1. setAppGroupId 为 iOS 兼容接口,鸿蒙端空操作(返回 true 不执行逻辑);
  2. updateWidget 忽略 name 参数,多卡片类型场景需自行用键前缀隔离数据;
  3. 卡片 UI 必须原生 ArkTS 编写(静态卡片不跑 Flutter 引擎),Flutter 视图只能通过 renderFlutterWidget 截图降级使用——交互型卡片无法复用 Flutter 代码;
  4. startBackgroundUpdateinterval 受系统 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.etsohos/entry/src/main/ets/entryformability/
WidgetCard.etsohos/entry/src/main/ets/widget/pages/
form_config.jsonohos/entry/src/main/resources/base/profile/
module.json5 / EntryAbility.etsextensionAbilities 声明与 widgetClick 转发(第 4.1/4.5 节代码)

十一、总结

home_widget 的鸿蒙适配抓住了一个正确的抽象:用 GSKV 共享存储代替跨进程消息——Flutter 进程写、卡片进程读,架构复杂度被压缩到存储选型一行代码。配合 QueuingEventSink 的事件排队、formProvider 的全量刷新,插件在 Dart 侧暴露的接口面与 Android/iOS 保持同构。

对开发者的三点实用结论:

  1. 集成成本集中在原生侧 4 个文件——这是 FormKit 安全模型决定的,与 Android/iOS 做 Widget 的成本同构,不存在"零原生代码"的桌面卡片方案;
  2. 参数语义以 ArkTS 实现为准——requestPinWidgetname 在鸿蒙端是 bundleName,照搬 Android 文档会静默失败;
  3. 全量刷新策略下注意键名规划——多卡片场景用前缀隔离,键名与 @LocalStorageProp 绑定的一致性是"卡片不报错但也不更新"这类静默问题的唯一排查点。

欢迎在 CPF-Flutter 组织仓库提交 Issue 与 PR,共同完善鸿蒙 Flutter 三方库生态。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐