天气、日历、音乐控制、待办清单——这些"不打开应用就能在桌面看数据"的卡片,是移动端差异化体验的高地。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 开发环境的安装步骤,直接参考官方文档一步到位:

环境搭建指南:Flutter on OpenHarmony 环境搭建

按文档完成环境准备后,可对照下表确认本文的实际验证环境:

组件版本说明
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

二、应用背景:为什么需要 home_widget

在这里插入图片描述

先回答"什么场景值得做卡片"。桌面卡片的本质是把应用的高价值信息前置到系统桌面,绕过"解锁 → 找图标 → 启动 → 等待 → 操作"的完整链路。按价值类型分三类:

类型典型场景对应用的价值
信息前置天气、日程、步数、股票行情提升打开率:用户在桌面即可获取核心信息,应用成为"被动可见"的服务
快捷操作音乐播放控制、扫码付款、一键打卡、快速记事缩短关键操作路径:从 4-5 步缩短到 1 步,这类入口的转化提升通常是数量级的
状态追踪外卖配送进度、快递物流、下载进度、叫车等待低频应用的生命线:无需推送打扰,用户瞄一眼桌面就知道进展

第三类尤其值得关注:低频高价值应用(快递、航班、缴费)很难靠图标在桌面占据心智,卡片是它们在桌面唯一的常驻形态。此外在鸿蒙生态里,服务卡片是系统重点扶持的特性——支持 2×2 / 2×4 多种规格、可添加到负一屏,桌面长按应用图标即可直接添加对应卡片,系统级的曝光入口比 Android AppWidget 更强。

而 home_widget 在这套体系里的定位非常克制:它不负责卡片长什么样(那是 ArkTS 的事),只负责让 Flutter 应用进程把数据"递"进卡片、并把用户的点击"递"回应用。理解了这个定位,第四节要写的原生侧 4 个文件就不会显得突兀——它们就是"卡片本体"在鸿蒙上的注册与实现。

三、home_widget 有哪些功能

3.1 功能接口总览

插件在 Dart 侧暴露的全部接口(home_widget_ohos.dart):

接口类型功能
saveWidgetData<T>(key, value)数据写入向 GSKV 写入一个数据键(String/int/double/bool)
getWidgetData<T>(key, {defaultValue})数据读取从 GSKV 读取指定键
updateWidget({name})卡片刷新取全部数据,刷新所有已加桌卡片
requestPinWidget({name})添加引导拉起系统弹窗,引导用户把卡片添加到桌面(API ≥ 18)
isRequestPinWidgetSupported()能力探测查询当前系统是否支持添加引导
getInstalledWidgets()状态查询获取本应用所有已加桌卡片(formId 等)
startBackgroundUpdate({interval})定时刷新启用系统定时刷新(最小间隔 5 分钟)
stopBackgroundUpdate()定时刷新停用系统定时刷新
renderFlutterWidget(widget, ...)视图渲染把 Flutter Widget 渲染为 PNG(纯 Dart 实现)
widgetClicked / registerInteractivityCallback事件回传卡片点击事件的运行中监听流 / 冷启动回调

3.2 跨进程架构:三个进程如何协作

桌面卡片是移动端特有的"应用外 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 是同构设计——“应用组共享存储”,这正是跨进程卡片方案的通用形态。

3.3 源码解析:三个值得学习的设计

3.3.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"。

3.3.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)。

3.3.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 插件处理早期事件的经典模式,可直接复用到其他事件型插件适配上。

四、如何使用 home_widget

4.1 引入三方库(AtomGit 方式)

在 pubspec.yaml 中以 git 依赖方式引入 CPF-Flutter 组织仓库,并锁定适配 TAG:

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

dependency_overrides:
  path_provider:
    git:
      url: "https://atomgit.com/openharmony-tpc/flutter_packages.git"
      path: packages/path_provider/path_provider

必须用 TAG 锁定:仓库主干跟随 Flutter 上游演进,锁定 TAG 才能保证文章发布半年后读者仍然可以复现。若插件内部传递依赖存在 其他源,用 dependency_overrides 统一覆盖为 atomgit 镜像,避免环境里出现多个 path_provider 版本。

4.2 原生侧集成:必须向系统注册的 4 件事

插件包(HAR)只负责数据通道,而"卡片"这个系统组件必须由应用在原生侧声明。这是鸿蒙 FormKit 的安全模型,也和 Android 的 Receiver + XML、iOS 的 Widget Extension 完全同构。

4.2.1 文件位置总览:新建 3 个,修改 2 个

以下 5 处改动覆盖了卡片集成的全部原生工作,位置和操作类型如下:
在这里插入图片描述

ohos/
└── entry/src/main/
    ├── module.json5                              # 【修改】+ extensionAbilities 卡片声明(4.2.2)
    ├── ets/
    │   ├── entryability/
    │   │   └── EntryAbility.ets                  # 【修改】+ widgetClick 事件转发(4.2.6)
    │   ├── entryformability/                     # 【新建目录】
    │   │   └── EntryFormAbility.ets              # 【新建】卡片生命周期 + GSKV 读取(4.2.4)
    │   └── widget/pages/                         # 【新建目录】
    │       └── WidgetCard.ets                    # 【新建】ArkTS 卡片 UI(4.2.5)
    └── resources/base/profile/
        └── form_config.json                      # 【新建】卡片规格声明(4.2.3)

注意:EntryFormAbility.ets 与 WidgetCard.ets 的目录(entryformability/、widget/pages/)默认不存在,需要新建;form_config.json 放在 resources/base/profile/ 下,module.json5 中通过 $profile:form_config 引用,文件名必须与引用名一致。

4.2.2 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.3 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.2.4 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.2.5 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 并携带参数——这是第 3.3.3 节点击回传链路的起点。

4.2.6 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)——插件靠这对启动类型区分"应用由卡片首次拉起"与"运行中收到点击"。

4.3 接口调用与运行效果

初始化(main.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();   // 点击后自增并刷新卡片
  }
}

各接口的调用方式与实测效果(设备:OpenHarmony 7.0.0.105 模拟器,API 26):

① saveWidgetData + 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();   // 桌面卡片立刻显示新数据
}

② getWidgetData:数据回读

final title = await HomeWidgetOhos.getWidgetData<String>('title');

③ requestPinWidget:添加卡片引导

final supported = await HomeWidgetOhos.isRequestPinWidgetSupported();
if (supported == true) {
  // ★ name 必传(bundleName 语义),见 FAQ 5.1
  await HomeWidgetOhos.requestPinWidget(name: 'com.example.my_cross_platform_app');
}

④ getInstalledWidgets:查询已加桌卡片

final widgets = await HomeWidgetOhos.getInstalledWidgets();
for (final w in widgets) {
  debugPrint('formId=${w.ohosWidgetId} name=${w.ohosLabel}');
}

⑤ widgetClicked:卡片点击回跳(运行中事件流)

HomeWidgetOhos.widgetClicked.listen((Uri? uri) {
  // 点击卡片后应用回跳,收到 home-widget:/CALLBACK?... 事件
});

⑥ startBackgroundUpdate:后台定时刷新

await HomeWidgetOhos.startBackgroundUpdate(interval: 5);   // 系统约束最小 5 分钟

⑦ renderFlutterWidget:Flutter 视图渲染为图片

await HomeWidgetOhos.renderFlutterWidget(
  const Text('Flutter 渲染的内容'),
  fileName: 'widget_snapshot',
);

五、FAQ:问题排查与社区反馈

5.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 文档。

5.2 isRequestPinWidgetSupported 的双重回调缺陷

源码里 result.success(true) 后缺少 return/else:

if (sdkApiVersionInfo >= 18) {
  result.success(true);
}
result.success(false);   // API ≥ 18 时连续回调两次

MethodChannel 对同一调用的第二次回复会被忽略,所以 Dart 侧拿到正确的 true,功能不受影响——但这是典型的适配代码质量问题,也是 flutter logs 里可能出现 channel 回复警告的来源。

5.3 updateWidget 后桌面无变化

按顺序确认:(1) 卡片是否真的已添加到桌面(getInstalledWidgets 返回数量);(2) saveWidgetData 是否在 updateWidget 之前完成(两者都是异步,必须 await);(3) 卡片 UI 的 @LocalStorageProp 键名与 Dart 写入的 key 是否一致——全量刷新机制下,数据都送达了但键名对不上,卡片只是显示默认值,不会报错。

5.4 getWidgetData 返回异常值

GSKV 中按 key 存储任意简单类型,但读取时 defaultValue 的类型必须与写入时一致(存 int 读 String 会命中 defaultValue 而不是报错)。团队协作时建议把键名和类型集中定义成常量表。

5.5 定时刷新不生效

两层开关:form_config.json 的 updateEnabled: true 是系统级允许;ArkTS onUpdateForm 内部还检查 GSKV 里的 updateEnabled 键——只有 Dart 侧调用过 startBackgroundUpdate 后定时刷新才真正生效。另外系统约束 setFormNextRefreshTime 的最小间隔为 5 分钟,传入更小的值会被系统拒绝。

5.6 库本身存在问题:如何提交 Issue

排查后确认问题出在插件实现本身(如 5.2 的双重回调缺陷、静默失败、行为与源码逻辑不符),应到适配仓库提 Issue 反馈:

  1. 提交地址:fluttertpc_home_widget 的 Issues 页面(登录 AtomGit 后点击"新建 Issue");
  2. 提 Issue 前先自查:(1) 在 Issues 里搜索关键词,避免重复;(2) 用最小复现 Demo 验证问题与业务代码无关;(3) 确认使用的是 TAG 版本(说明你锁定的 TAG 号);
  3. Issue 内容要素:环境版本(Flutter / OpenHarmony SDK / 插件 TAG 三项缺一不可)、完整复现步骤、期望行为与实际行为、关键日志(flutter run -v 输出或 hvigor 构建日志)、运行截图或录屏;
  4. 文档类问题(适配清单、README 描述与实际不符)提交到 CPF-Flutter/docs 仓库。
    在这里插入图片描述

5.7 能自行解决:如何提交 PR

如果定位到了根因并完成了修复(比如 5.2 的 result.success 缺 return 问题,就是一个非常典型的可提 PR 的修复点),欢迎直接向社区贡献代码:

  1. Fork 仓库:在 fluttertpc_home_widget 页面点击 Fork 到个人账号;
  2. 拉修复分支:基于你锁定的 TAG 创建分支(如 fix/double-success-callback),完成修改并确保 flutter analyze 无告警;
  3. 自测验证:在鸿蒙真机/模拟器上验证修复效果,保留修改前后的对比截图或日志;
  4. 提交 PR:向 CPF-Flutter/fluttertpc_home_widget 主干发起 PR,标题简述修复点,正文关联 Issue 编号、说明根因分析与验证方式;
  5. 社区 Review 通过后合并,修复会跟随下一个 TAG 发布;也可以在 Issue 中先附上分析结论,与适配维护者讨论方案后再动手。
    在这里插入图片描述

六、运行验证

在这里插入图片描述

验证设备: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. startBackgroundUpdate 的 interval 受系统 5 分钟下限约束。

八、完整示例代码

8.1 main.dart(核心逻辑)


import 'package:flutter/material.dart';
import 'package:home_widget/home_widget_ohos.dart';

void main() {
  WidgetsFlutterBinding.ensureInitialized();
  // 注册卡片点击回调:应用被卡片冷启动时触发
  HomeWidgetOhos.registerInteractivityCallback(interactiveCallback);
  runApp(const HomeWidgetDemoApp());
}

/// 卡片点击回调(卡片 router 拉起应用后触发)
('vm:entry-point')
Future<void> interactiveCallback(Uri? data) async {
  if (data?.host == 'CALLBACK') {
    // 点击卡片:计数 +1 并刷新卡片
    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();
  }
}

class HomeWidgetDemoApp extends StatelessWidget {
  const HomeWidgetDemoApp({super.key});

  
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'home_widget 鸿蒙 Demo',
      theme: ThemeData(colorSchemeSeed: Colors.indigo, useMaterial3: true),
      home: const HomeWidgetDemoPage(),
    );
  }
}

class HomeWidgetDemoPage extends StatefulWidget {
  const HomeWidgetDemoPage({super.key});

  
  State<HomeWidgetDemoPage> createState() => _HomeWidgetDemoPageState();
}

class _HomeWidgetDemoPageState extends State<HomeWidgetDemoPage> {
  String _log = '等待操作…';
  String _readBack = '-';
  List<String> _installedWidgets = [];
  int _counter = 0;
  bool _bgRunning = false;

  
  void initState() {
    super.initState();
    _init();
  }

  Future<void> _init() async {
    // 初始化卡片默认数据
    await HomeWidgetOhos.saveWidgetData<String>('title', 'HomeWidget Demo');
    await HomeWidgetOhos.saveWidgetData<int>('count', 0);
    await HomeWidgetOhos.saveWidgetData<String>('message', '应用已初始化');
    // 监听卡片点击事件流(应用运行中被卡片拉起)
    HomeWidgetOhos.widgetClicked.listen((Uri? uri) {
      _appendLog('收到卡片点击事件:$uri');
    });
    // 检查本次启动是否由卡片拉起
    final launchUri = await HomeWidgetOhos.initiallyLaunchedFromHomeWidget();
    if (launchUri != null) {
      _appendLog('应用由卡片启动:$launchUri');
    } else {
      _appendLog('普通桌面启动,初始数据已写入 GSKV');
    }
  }

  void _appendLog(String msg) {
    setState(() => _log = '${DateTime.now().toIso8601String().substring(11, 19)}  $msg');
  }

  /// 保存数据并刷新所有已加桌卡片
  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,时间 ${DateTime.now().toIso8601String().substring(11, 19)}');
    final okUpdate = await HomeWidgetOhos.updateWidget();
    _appendLog('saveWidgetData=$okTitle,updateWidget=$okUpdate,计数=$_counter');
  }

  /// 读取 GSKV 中保存的数据(验证跨方法读取)
  Future<void> _readData() async {
    final title = await HomeWidgetOhos.getWidgetData<String>('title', defaultValue: '无');
    final count = await HomeWidgetOhos.getWidgetData<int>('count', defaultValue: -1);
    setState(() => _readBack = 'title="$title" count=$count');
    _appendLog('getWidgetData 读取完成');
  }

  /// 请求把卡片添加到桌面(需要桌面弹窗确认)
  ///
  /// 鸿蒙适配版要求 name 必传(ArkTS 端将其作为 bundleName 传给
  /// formProvider.openFormManager),不传会直接返回 InvalidArguments 错误
  Future<void> _requestPin() async {
    final supported = await HomeWidgetOhos.isRequestPinWidgetSupported();
    _appendLog('isRequestPinWidgetSupported=$supported');
    if (supported == true) {
      try {
        await HomeWidgetOhos.requestPinWidget(
          name: 'com.example.my_cross_platform_app',
        );
        _appendLog('已发出 requestPinWidget 请求,请到桌面弹窗确认');
      } catch (e) {
        _appendLog('requestPinWidget 失败:$e');
      }
    }
  }

  /// 获取所有已加桌的卡片实例
  Future<void> _listWidgets() async {
    final widgets = await HomeWidgetOhos.getInstalledWidgets();
    setState(() {
      _installedWidgets = widgets
          .map((w) => 'formId=${w.ohosWidgetId} name=${w.ohosLabel} bundle=${w.ohosClassName}')
          .toList();
    });
    _appendLog('getInstalledWidgets=${widgets.length} 个');
  }

  /// Flutter Widget 渲染为图片(纯 Dart RepaintBoundary 实现)
  Future<void> _renderWidget() async {
    final path = await HomeWidgetOhos.renderFlutterWidget(
      Container(
        width: 200,
        height: 200,
        color: Colors.indigo,
        alignment: Alignment.center,
        child: const Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Icon(Icons.flutter_dash, size: 80, color: Colors.white),
            Text('Rendered by Flutter', style: TextStyle(color: Colors.white)),
          ],
        ),
      ),
      key: 'flutterRender.png',
      logicalSize: const Size(200, 200),
    );
    await HomeWidgetOhos.saveWidgetData<String>('flutterImagePath', path);
    _appendLog('renderFlutterWidget 输出:$path');
  }

  Future<void> _toggleBackground() async {
    if (!_bgRunning) {
      // 系统约束:setFormNextRefreshTime 最小间隔 5 分钟
      await HomeWidgetOhos.startBackgroundUpdate(interval: 5);
      _appendLog('startBackgroundUpdate:定时刷新已开启(5 分钟)');
    } else {
      await HomeWidgetOhos.stopBackgroundUpdate();
      _appendLog('stopBackgroundUpdate:定时刷新已停止');
    }
    setState(() => _bgRunning = !_bgRunning);
  }

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('home_widget 鸿蒙服务卡片 Demo')),
      body: ListView(
        padding: const EdgeInsets.all(16),
        children: [
          Text('日志:$_log', style: const TextStyle(fontSize: 13, color: Colors.black87)),
          const Divider(height: 24),
          FilledButton.icon(
            onPressed: _saveAndRefresh,
            icon: const Icon(Icons.sync),
            label: const Text('保存数据并刷新卡片(saveWidgetData + updateWidget)'),
          ),
          const SizedBox(height: 8),
          OutlinedButton.icon(
            onPressed: _readData,
            icon: const Icon(Icons.download),
            label: const Text('读取卡片数据(getWidgetData)'),
          ),
          if (_readBack != '-')
            Padding(
              padding: const EdgeInsets.only(left: 8, top: 4),
              child: Text('回读结果:$_readBack',
                  style: const TextStyle(fontSize: 12, color: Colors.black54)),
            ),
          const SizedBox(height: 8),
          OutlinedButton.icon(
            onPressed: _requestPin,
            icon: const Icon(Icons.add_box),
            label: const Text('请求添加卡片到桌面(requestPinWidget)'),
          ),
          const SizedBox(height: 8),
          OutlinedButton.icon(
            onPressed: _listWidgets,
            icon: const Icon(Icons.widgets),
            label: const Text('查询已加桌卡片(getInstalledWidgets)'),
          ),
          if (_installedWidgets.isNotEmpty)
            Padding(
              padding: const EdgeInsets.only(left: 8, top: 4),
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: _installedWidgets
                    .map((w) => Text(w, style: const TextStyle(fontSize: 12, color: Colors.black54)))
                    .toList(),
              ),
            ),
          const SizedBox(height: 8),
          OutlinedButton.icon(
            onPressed: _renderWidget,
            icon: const Icon(Icons.image),
            label: const Text('渲染 Flutter Widget 为图片(renderFlutterWidget)'),
          ),
          const SizedBox(height: 8),
          OutlinedButton.icon(
            onPressed: _toggleBackground,
            icon: Icon(_bgRunning ? Icons.pause : Icons.timer),
            label: Text(_bgRunning
                ? '停止后台定时刷新(stopBackgroundUpdate)'
                : '开启后台定时刷新(startBackgroundUpdate)'),
          ),
          const SizedBox(height: 16),
          Card(
            color: const Color(0xFFEEF2FF),
            child: const Padding(
              padding: EdgeInsets.all(12),
              child: Text(
                '验证步骤:\n'
                '1. 点"保存数据并刷新"(先添加卡片才能看到效果)\n'
                '2. 点"请求添加卡片"→ 桌面弹窗确认 → 桌面出现 2×2 卡片\n'
                '3. 回到应用继续刷新,观察桌面卡片 title/count/message 变化\n'
                '4. 点击桌面卡片 → 应用回跳,count 自增并刷新卡片\n'
                '5. "读取数据"验证 GSKV 中 Flutter 写入的数据',
                style: TextStyle(fontSize: 13, height: 1.6),
              ),
            ),
          ),
        ],
      ),
    );
  }
}

8.2 ArkTS 原生侧(4 个文件的完整实现见第 4.2 节)

文件位置
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.2.2/4.2.6 节代码)

九、总结

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

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

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

加入开源鸿蒙跨平台社区,与万千开发者共建鸿蒙生态:Flutter 三方库适配成果与工程实践均在 CPF-Flutter 组织仓库持续开放,适配进度可查阅 三方库适配清单。欢迎关注、提 Issue、提 PR,共同完善鸿蒙 Flutter 三方库生态。

Logo

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

更多推荐