Flutter 鸿蒙服务卡片插件 flutter_ohos_form 从 0 到 1 实战
Flutter 鸿蒙服务卡片插件 flutter_ohos_form 从 0 到 1 实战
本文记录了从零实现一个 Flutter 鸿蒙服务卡片(Form / 服务卡片)三方库
flutter_ohos_form的完整过程,
包含架构设计、跨进程通信方案、代码实现、关键决策和真机验证的踩坑复盘。
与常见的"把 Android 库翻译到鸿蒙"不同,服务卡片是鸿蒙独有的能力,Android / iOS 没有对应实现可供参考,
所有方案都需要从鸿蒙的卡片框架约束出发重新设计。
一、背景
1.1 为什么 Flutter 需要服务卡片插件
鸿蒙的服务卡片(Form,官方称"服务卡片")是运行在桌面上的轻量级 UI,用户无需打开应用即可查看信息、点击交互。它是鸿蒙应用体验的重要组成部分,很多应用都会把"今日数据""快捷操作"做成卡片放到桌面。
但对 Flutter 开发者来说,这个能力是完全缺失的。原因在于鸿蒙卡片框架有一条硬性约束:
卡片由 FormExtensionAbility 渲染,运行在独立于应用的卡片进程中,且只能渲染 ArkTS 声明的 UI,无法渲染 Flutter 视图。
这意味着 Flutter 应用要做服务卡片,必须跨越三个技术障碍:
| 障碍 | 具体问题 |
|---|---|
| 加桌握手 | Flutter 侧发起加桌请求,卡片进程创建卡片后才知道 formId,两边如何对上号? |
| 跨进程数据下发 | Flutter 侧的业务数据(比如待办数量)如何推到卡片进程,并在数据变化时实时刷新卡片? |
| 跨进程事件回传 | 用户点击桌面卡片,事件发生在卡片进程,如何传回 Flutter 的 Dart 层? |
这三个问题如果每个应用都自己解决一遍,成本极高且容易出错。因此我决定把它们封装成一个通用的三方库。
1.2 插件提供的能力
flutter_ohos_form 最终提供了以下能力:
- 加桌管理:查询设备是否支持加桌、请求把卡片添加到桌面,并自动完成
formId握手 - 数据更新:更新指定卡片或全部已加桌卡片,卡片内容实时刷新
- 事件回调:卡片点击、加桌、移除、可见性变化四类事件以
Stream形式推送到 Dart - 卡片数据查询:查询已加桌卡片列表与每张卡片的业务数据
- 可复用卡片 UI:
FlutterFormCard/FlutterFormCardMini内置模板,含点击上报 - FormExtensionAbility 基类:
FlutterFormExtensionAbility封装全部卡片生命周期回调
开发者接入只需要三步:继承一个基类、写一个卡片 UI、在 Dart 侧调用 API。
1.3 实现目标
| 维度 | 要求 |
|---|---|
| 功能完整性 | 覆盖服务卡片的加桌、更新、查询、事件全链路,不依赖系统私有权限 |
| 接入成本 | 应用侧接入不超过 3 个文件(卡片 Ability、卡片 UI、module.json5 注册) |
| 平台隔离 | Dart 侧 API 不暴露鸿蒙特有的数据结构,模型统一为 FormData / FormEvent |
| 可测试性 | 基于 plugin_platform_interface 抽象平台接口,Dart 逻辑可单测 |
| 工程规范 | 提供 example 工程、中英文适配文档、CHANGELOG 与真机验证记录 |
二、实现路线图
整个实现分为 6 个阶段:
第 1 阶段:项目初始化 ── 生成插件骨架,确认 Flutter OH 引擎的插件机制
第 2 阶段:通道打通 ── MethodChannel + EventChannel,跑通 Dart ↔ ArkTS 双向通信
第 3 阶段:数据与存储 ── FormDataStore,基于 preferences 的跨进程数据共享
第 4 阶段:跨进程事件 ── commonEventManager 打通卡片进程 → Flutter 主进程
第 5 阶段:能力封装 ── FormExtensionAbility 基类 + 卡片 UI 模板
第 6 阶段:示例与验证 ── example 工程 + 真机全链路验证
其中第 2、4 阶段是技术难点所在,第 5 阶段决定了开发者的接入体验。
三、逐步实现过程
第 1 阶段:项目初始化
1.1 生成插件骨架
flutter create --template=plugin --platforms=ohos,android,ios \
--org com.nutpi --project-name flutter_ohos_form flutter_ohos_form
生成后的 ohos/ 目录结构:
ohos/
├── index.ets # 模块入口,导出插件类
├── oh-package.json5 # 包配置
├── build-profile.json5 # 构建配置
└── src/main/
├── module.json5 # HAR 模块配置
└── ets/components/plugin/
└── FlutterOhosFormPlugin.ets # 原生插件实现(核心)
模板生成的 FlutterOhosFormPlugin.ets 只有一个 getPlatformVersion 方法,是空壳。
1.2 先确认 flutter_ohos 引擎的插件接口
动手前必须先摸清 Flutter OH 引擎提供的接口,否则容易写出编译不过的代码。引擎 HAR 包的位置:
# 引擎 HAR 在 Flutter SDK 的缓存里
ls $FLUTTER_HOME/bin/cache/artifacts/engine/ohos-arm64-release/flutter_embedding_release.har
HAR 是 tar 包(不是 zip),解压后可以读到全部 .ets 源码和 index.ets 导出清单:
mkdir /tmp/fhar && cd /tmp/fhar
tar xzf $FLUTTER_HOME/bin/cache/artifacts/engine/ohos-arm64-release/flutter_embedding_release.har
cat package/index.ets | head -80
这一步非常关键——引擎 index.ets 决定了哪些符号可以 import。本次实现需要用到:
| 接口 | 用途 |
|---|---|
FlutterPlugin / FlutterPluginBinding | 插件生命周期,拿到 BinaryMessenger |
MethodChannel / MethodCall / MethodResult | Dart → ArkTS 方法调用 |
EventChannel / StreamHandler / EventSink | ArkTS → Dart 事件推送 |
AbilityAware / AbilityPluginBinding | 获取 UIAbilityContext(加桌需要) |
FlutterPlugin 接口定义(节选自引擎源码):
export interface FlutterPlugin {
getUniqueClassName(): string;
onAttachedToEngine(binding: FlutterPluginBinding): void;
onDetachedFromEngine(binding: FlutterPluginBinding): void;
}
踩坑预警:引擎里
Any类型是declare type Any = ESObject,而MethodCall.args的实际运行时类型是Map<Any, Any>。用call.argument('key')取值比自己解析 Map 更安全,后面会细说。
第 2 阶段:通道打通
2.1 双通道设计
插件需要双向通信,所以同时注册两个通道:
| 通道名 | 类型 | 方向 | 用途 |
|---|---|---|---|
flutter_ohos_form/methods | MethodChannel | Dart → ArkTS | 加桌、更新、查询等主动调用 |
flutter_ohos_form/events | EventChannel | ArkTS → Dart | 卡片点击、加桌、移除等事件推送 |
通道名统一定义在 FormConstants.ets,避免 Dart 与 ArkTS 两侧写错字符串:
/** 主通道:Dart -> ArkTS 的方法调用 */
export const FORM_METHOD_CHANNEL: string = 'flutter_ohos_form/methods';
/** 事件通道:ArkTS -> Dart 的卡片事件(点击、删除、更新等) */
export const FORM_EVENT_CHANNEL: string = 'flutter_ohos_form/events';
ArkTS 侧注册(FlutterOhosFormPlugin.ets):
export default class FlutterOhosFormPlugin
implements FlutterPlugin, MethodCallHandler, StreamHandler, AbilityAware {
private channel: MethodChannel | null = null;
private eventChannel: EventChannel | null = null;
private abilityContext: common.UIAbilityContext | null = null;
getUniqueClassName(): string {
return 'FlutterOhosFormPlugin';
}
onAttachedToEngine(binding: FlutterPluginBinding): void {
// 方法通道
this.channel = new MethodChannel(binding.getBinaryMessenger(), FORM_METHOD_CHANNEL);
this.channel.setMethodCallHandler(this);
// 事件通道
this.eventChannel = new EventChannel(binding.getBinaryMessenger(), FORM_EVENT_CHANNEL);
this.eventChannel.setStreamHandler(this);
this.store.init(binding.getApplicationContext());
// 卡片进程运行在独立进程,通过公共事件把事件投递过来
this.subscribeCardEvents();
}
onDetachedFromEngine(binding: FlutterPluginBinding): void {
if (this.channel != null) {
this.channel.setMethodCallHandler(null);
this.channel = null;
}
if (this.eventChannel != null) {
this.eventChannel.setStreamHandler(null);
this.eventChannel = null;
}
unsubscribeFormEvent(this.commonEventSubscriber);
this.commonEventSubscriber = null;
this.dispatcher.setEventSink(null);
}
onAttachedToAbility(binding: AbilityPluginBinding): void {
// 加桌需要 UIAbilityContext,在这里拿到
this.abilityContext = binding.getAbility().context;
}
}
与 Android 的差异对照:
| 维度 | Android | OHOS |
|---|---|---|
| 获取 Messenger | binding.getFlutterEngine().getDartExecutor() | binding.getBinaryMessenger() |
| 方法通道 | MethodChannel(messenger, name) | new MethodChannel(messenger, name) |
| 事件通道 | EventChannel(messenger, name).setStreamHandler(this) | new EventChannel(messenger, name).setStreamHandler(this) |
| 上下文获取 | ActivityAware → onAttachedToActivity | AbilityAware → onAttachedToAbility |
OHOS 的接口设计比 Android 更简洁,通道部分几乎是一一对应的。
2.2 StreamHandler 生命周期
EventChannel 在 OHOS 侧要求实现 StreamHandler:
export interface StreamHandler {
onListen(args: Any, events: EventSink): void; // Dart 开始订阅
onCancel(args: Any): void; // Dart 取消订阅
}
本插件的实现——事件源不是系统广播,而是自定义公共事件,所以 onListen 只是保存 EventSink:
onListen(args: ESObject, events: EventSink): void {
this.dispatcher.setEventSink(events);
hilog.info(DOMAIN, TAG, 'event channel listened');
}
onCancel(args: ESObject): void {
this.dispatcher.setEventSink(null);
hilog.info(DOMAIN, TAG, 'event channel cancelled');
}
设计要点:
EventSink被交给单例FormEventDispatcher持有,因为事件来自公共事件回调(异步、可能在任意时刻到达),需要一个全局可达的引用才能推送。这也是FormEventDispatcher存在的唯一理由。
2.3 方法分发
onMethodCall 用 switch 分发,所有方法都做了异常兜底:
onMethodCall(call: MethodCall, result: MethodResult): void {
switch (call.method) {
case 'getPlatformVersion':
result.success('OpenHarmony Form Plugin');
break;
case 'isSupported':
this.isSupported(result);
break;
case 'addForm':
this.addForm(call, result);
break;
case 'updateForm':
this.updateForm(call, result);
break;
case 'removeForm':
this.removeForm(call, result);
break;
case 'getFormIds':
this.getFormIds(result);
break;
case 'getFormData':
this.getFormData(call, result);
break;
case 'getAllFormData':
this.getAllFormData(result);
break;
default:
result.notImplemented();
break;
}
}
每个 handler 内部统一 try/catch,失败时通过 result.error 回传错误码与消息,不让异常穿透到引擎层:
private async getFormIds(result: MethodResult): Promise<void> {
try {
const formIds: string[] = await this.store.getFormIds();
result.success(formIds);
} catch (err) {
const error = err as BusinessError;
result.error('GET_FORM_IDS_ERROR', error.message, error.code);
}
}
2.4 Dart 侧实现
Dart 侧基于 plugin_platform_interface 做了一层抽象,好处是平台实现可替换、Dart 逻辑可单测:
// 平台接口:定义各平台需要实现的能力
abstract class FlutterOhosFormPlatform extends PlatformInterface {
Future<bool> isSupported() {
throw UnimplementedError('isSupported() has not been implemented.');
}
Future<String?> addForm({
required String formName,
int dimension = 2,
String moduleName = 'entry',
String data = '{}',
}) { ... }
Stream<FormEvent> get formEvents { ... }
}
// MethodChannel 实现
class MethodChannelFlutterOhosForm extends FlutterOhosFormPlatform {
final methodChannel = const MethodChannel('flutter_ohos_form/methods');
final eventChannel = const EventChannel('flutter_ohos_form/events');
Future<bool> isSupported() async {
final bool? supported = await methodChannel.invokeMethod<bool>('isSupported');
return supported ?? false;
}
Stream<FormEvent> get formEvents {
_eventStream ??= eventChannel.receiveBroadcastStream().map((Object? event) {
if (event is Map<Object?, Object?>) {
return FormEvent.fromMap(event);
}
return FormEvent(type: FormEventType.unknown, data: event?.toString() ?? '');
});
return _eventStream!;
}
}
对外主入口 FlutterOhosForm 再包一层,提供 addFormWithMap / updateFormWithMap 这类便利方法,内部用 jsonEncode 序列化:
class FlutterOhosForm {
FlutterOhosFormPlatform get _platform => FlutterOhosFormPlatform.instance;
Future<String?> addFormWithMap({
required String formName,
required Map<String, Object?> data,
int dimension = FormDimension.dimension2x2,
String moduleName = 'entry',
}) {
return addForm(
formName: formName,
dimension: dimension,
moduleName: moduleName,
data: jsonEncode(data),
);
}
/// 只监听卡片点击事件
Stream<FormEvent> get onFormClick => formEvents
.where((FormEvent event) => event.type == FormEventType.formClick);
}
第 3 阶段:数据与存储
3.1 为什么需要 preferences
Flutter 主进程与卡片进程是两个独立进程,内存不共享。要让卡片显示 Flutter 的数据,必须走跨进程通道。鸿蒙提供的手段有:
| 方案 | 说明 | 是否选用 |
|---|---|---|
| preferences | 键值对存储,支持跨进程读写,有变更订阅 | ✅ 选用 |
| 分布式数据对象 | 面向分布式场景,本地使用过重 | ❌ |
| 文件读写 | 需自己处理并发与序列化 | ❌ |
| Want 参数传递 | 仅能在拉起 Ability 时传一次,无法持续更新 | ❌ 仅用于加桌握手 |
preferences 是唯一既能持久化、又能跨进程、还有变更通知的轻量方案。
3.2 FormDataStore 设计
FormDataStore 是单例,封装了三个关键能力:
export class FormDataStore {
private static instance: FormDataStore | null = null;
private dataPreferences: preferences.Preferences | null = null;
static getInstance(): FormDataStore {
if (FormDataStore.instance == null) {
FormDataStore.instance = new FormDataStore();
}
return FormDataStore.instance;
}
async init(context: common.Context): Promise<void> {
if (this.initialized && this.dataPreferences != null) {
return; // 可重复调用,卡片进程与主进程都会调
}
this.dataPreferences = await preferences.getPreferences(context, FORM_PREFERENCES_NAME);
this.initialized = true;
}
}
存储结构设计为两张表:
| 键 | 内容 | 用途 |
|---|---|---|
form_data | Map<formId, FormDataEntry> 的 JSON | 每张卡片的业务数据 |
form_ids | string[] 的 JSON | 已加桌卡片 ID 列表,便于批量更新 |
pending_data | Map<formName, FormDataEntry> 的 JSON | 加桌握手用的临时暂存区 |
3.3 加桌握手的核心难题
这是整个插件最需要仔细设计的部分。加桌流程有一个天然的时序问题:
Flutter 侧 卡片进程
│ │
│ ① 发起加桌请求(此时还不知道 formId)
├──────────────────────────────────>│
│ │ ② 系统创建卡片
│ │ ③ onAddForm 回调,拿到 formId
│<──────────────────────────────────┤
│ ④ 收到 formAdded 事件,拿到 formId │
│ ⑤ 把业务数据落库到该 formId 下 │
问题在于:① 时 Flutter 还不知道 formId,但业务数据必须先准备好,否则卡片创建出来是空的。
解决方案是引入 pending_data 暂存区,把流程拆成"先暂存、后落库":
// Flutter 侧:先把数据按 formName 暂存
await this.store.savePendingData(options.formName, entry);
// 注册等待者,等卡片进程回传 formId
const formIdPromise: Promise<string | null> = this.waitForAddResult(options.formName);
// 拉起卡片管理页,由用户确认加桌
this.openFormManager(options);
// 等 formAdded 事件
const formId: string | null = await formIdPromise;
result.success(formId);
卡片进程侧 onAddForm 被调用时,用 formName 去暂存区取出数据,落库到真实 formId 下,并回传事件完成握手:
onAddForm(want: Want): formBindingData.FormBindingData {
const formId: string = this.readWantParam(want, formInfo.FormParam.IDENTITY_KEY);
const formName: string = this.readWantParam(want, formInfo.FormParam.NAME_KEY);
// onAddForm 必须同步返回,先异步落库
this.initStore().then(() => {
this.store.getPendingData(formName).then((pending: FormDataEntry | null) => {
const entry: FormDataEntry = {
formName: pending != null ? pending.formName : formName,
dimension: pending != null ? pending.dimension : DEFAULT_DIMENSION,
moduleName: pending != null ? pending.moduleName : DEFAULT_MODULE_NAME,
data: pending != null ? pending.data : EMPTY_DATA,
};
this.store.saveFormData(formId, entry).then(() => {
// 回传 formId,完成握手
publishFormEvent({ event: EVENT_FORM_ADDED, formId: formId, formName: entry.formName, ... });
});
});
});
// 同步返回首帧数据
return formBindingData.createFormBindingData(payload);
}
关键点:
onAddForm是同步返回的(返回值类型不是 Promise),所以不能用await。这里采取"同步返回空数据构造首帧 + 异步补齐真实数据"的策略,避免阻塞系统创建卡片。
3.4 数据下发
写入 preferences 后,还需要主动调用 formProvider.updateForm 把数据推给卡片进程——preferences 的变更通知不保证跨进程可靠触发,主动推送才是可靠路径:
async saveFormData(formId: string, entry: FormDataEntry): Promise<boolean> {
const allData: Map<string, FormDataEntry> = await this.getAllFormData();
allData.set(formId, entry);
await this.dataPreferences.put(KEY_FORM_DATA, JSON.stringify(this.mapToObject(allData)));
await this.dataPreferences.put(KEY_FORM_IDS, JSON.stringify(Array.from(allData.keys())));
await this.dataPreferences.flush();
// 主动刷新卡片,不依赖变更通知
await this.updateFormBinding(formId, entry);
return true;
}
buildBindingData(entry: FormDataEntry): formBindingData.FormBindingData {
const payload: FormBindingPayload = {
formName: entry.formName,
data: entry.data, // 业务数据 JSON 字符串
updateTime: new Date().toLocaleString(),
};
return formBindingData.createFormBindingData(payload);
}
第 4 阶段:跨进程事件(技术难点)
4.1 问题:EventChannel 无法跨进程
第 2 阶段注册的 EventChannel 只能在同一进程内工作——它依赖 BinaryMessenger,而 BinaryMessenger 绑定的是 Flutter 引擎所在进程。
卡片点击事件发生在卡片进程,那里没有 Flutter 引擎,拿不到 EventSink。所以点击事件无法直接通过 EventChannel 推送。
4.2 方案对比
| 方案 | 优点 | 缺点 | 选用 |
|---|---|---|---|
| commonEventManager 公共事件 | 系统级跨进程总线,API 简单,无需额外权限 | 参数只能是基础类型,需自己序列化 | ✅ |
| 启动 Flutter 进程的 Service | 可直连 | 需要后台 Service,耗电且被杀风险高 | ❌ |
| preferences 轮询 | 实现简单 | 实时性差、耗电、无法传递瞬时事件 | ❌ |
| 数据库 + 监听 | 可持久化 | 瞬时事件不适合落库,且需自建通知机制 | ❌ |
最终选择 commonEventManager——它是鸿蒙标准的跨进程事件总线,自定义事件无需申请权限。
4.3 实现
FormCommonEvent.ets 封装了发布与订阅两端:
/** 自定义公共事件名称:卡片进程 -> Flutter 主进程 */
export const FORM_EVENT_COMMON_NAME: string = 'com.nutpi.flutter_ohos_form.FORM_EVENT';
/** 卡片进程侧:发布事件 */
export function publishFormEvent(params: FormEventParams): void {
try {
const parameters: Record<string, Object> = {};
parameters[PARAM_EVENT] = params.event ?? '';
parameters[PARAM_FORM_ID] = params.formId ?? '';
parameters[PARAM_FORM_NAME] = params.formName ?? '';
parameters[PARAM_DATA] = params.data ?? '';
parameters[PARAM_MESSAGE] = params.message ?? '';
const options: commonEventManager.CommonEventPublishData = {
parameters: parameters,
};
commonEventManager.publish(FORM_EVENT_COMMON_NAME, options, (err: BusinessError) => {
if (err != null && err.code !== 0) {
hilog.error(DOMAIN, TAG, 'publish failed, code: %{public}d, message: %{public}s',
err.code, err.message);
return;
}
hilog.info(DOMAIN, TAG, 'publish success: %{public}s', params.event ?? '');
});
} catch (err) {
// ...
}
}
/** Flutter 主进程侧:订阅事件 */
export async function subscribeFormEvent(
callback: FormCommonEventCallback
): Promise<commonEventManager.CommonEventSubscriber | null> {
const subscribeInfo: commonEventManager.CommonEventSubscribeInfo = {
events: [FORM_EVENT_COMMON_NAME],
};
const subscriber: commonEventManager.CommonEventSubscriber =
await commonEventManager.createSubscriber(subscribeInfo);
commonEventManager.subscribe(subscriber,
(err: BusinessError, data: commonEventManager.CommonEventData) => {
if (err != null && err.code !== 0) {
return;
}
const raw: Record<string, Object> = data.parameters ?? {};
callback(toFormEventParams(raw));
});
return subscriber;
}
Flutter 主进程在 onAttachedToEngine 订阅,收到事件后转发到 EventChannel:
private subscribeCardEvents(): void {
subscribeFormEvent((params: FormEventParams) => {
this.onCardEvent(params);
}).then((subscriber: commonEventManager.CommonEventSubscriber | null) => {
this.commonEventSubscriber = subscriber;
});
}
private onCardEvent(params: FormEventParams): void {
const eventType: string = params.event ?? '';
const formId: string = params.formId ?? '';
const formName: string = params.formName ?? '';
// 卡片加桌:把待加桌数据落库到该 formId,并唤醒等待中的 addForm 调用
if (eventType === EVENT_FORM_ADDED && formId.length > 0) {
this.store.getPendingData(formName).then((pending: FormDataEntry | null) => {
if (pending != null) {
this.store.saveFormData(formId, pending);
}
});
this.resolveAddWaiter(formId); // 唤醒 addForm 的 Promise
}
// 转发到 EventChannel
this.dispatcher.dispatchEvent(eventType, formId, formName,
params.data ?? '', params.message ?? '');
}
完整链路:
用户点击桌面卡片
│
▼
FlutterFormCard.onClick()
│ postCardAction(this, { action: 'message', params: {...} })
▼
FormExtensionAbility.onFormEvent(formId, message) [卡片进程]
│ publishFormEvent({ event: 'formClick', formId, message })
▼
commonEventManager(系统级跨进程总线)
│ subscribeFormEvent 回调
▼
FlutterOhosFormPlugin.onCardEvent() [Flutter 主进程]
│ dispatcher.dispatchEvent(...)
▼
EventSink.success(event) → Dart: form.onFormClick.listen(...)
4.4 事件参数的类型陷阱
公共事件的 parameters 类型是 Record<string, Object>,取值出来是 Object,需要转成字符串。这里写了个辅助函数统一处理:
function readParam(source: Record<string, Object>, key: string): string {
const value: Object | undefined = source[key];
if (value == null) {
return '';
}
return `${value}`; // 统一转为字符串,避免 Dart 侧类型不一致
}
注意:事件参数全部以字符串传递。虽然
dimension这类数值在 Dart 侧是int,但跨进程 + 跨语言两次转换后类型容易漂移,统一转字符串、在 Dart 侧按需解析更稳妥(FormData.fromMap里就是这么做的)。
第 5 阶段:能力封装
5.1 FormExtensionAbility 基类
第 3、4 阶段的能力如果让每个应用自己实现,接入成本会很高。所以插件提供了一个基类,把 4 个生命周期回调全部封装:
export default class FlutterFormExtensionAbility extends FormExtensionAbility {
protected store: FormDataStore = FormDataStore.getInstance();
/** 卡片被创建:读取暂存数据构造首帧,并回传 formId 完成握手 */
onAddForm(want: Want): formBindingData.FormBindingData { ... }
/** 卡片内触发事件(点击) */
onFormEvent(formId: string, message: string): void {
publishFormEvent({ event: EVENT_FORM_CLICK, formId: formId, formName: '', data: '', message: message });
}
/** 卡片被移除:清理数据并通知 Flutter */
onRemoveForm(formId: string): void {
this.initStore().then(() => {
this.store.getFormData(formId).then((entry: FormDataEntry | null) => {
const formName: string = entry != null ? entry.formName : '';
this.store.removeFormData(formId).then(() => {
publishFormEvent({ event: EVENT_FORM_REMOVED, formId: formId, formName: formName, ... });
});
});
});
}
/** 卡片可见性变化 */
onChangeFormVisibility(newStatus: Record<string, number>): void { ... }
}
应用侧接入只需要一行:
import { FlutterFormExtensionAbility } from 'flutter_ohos_form';
export default class EntryFormAbility extends FlutterFormExtensionAbility {
}
5.2 卡片 UI 模板
卡片 UI 必须由应用自己声明(卡片进程渲染不了 Flutter 视图),但布局可以复用。插件提供了两个模板:
@Component
export struct FlutterFormCard {
@Prop @Watch('onRawDataChange') rawData: string = '{}';
@Prop title: string = 'Flutter 服务卡片';
@Prop cardBackgroundColor: string = '#F1F3F5';
@State cardData: FormCardData = {};
aboutToAppear(): void {
this.cardData = parseFormCardData(this.rawData);
}
onRawDataChange(_newValue: string): void {
this.cardData = parseFormCardData(this.rawData);
}
build() {
Column() {
// 标题行 / 主体数值 / 描述 / 自定义条目 / 底部提示
...
}
.onClick(() => {
postClickAction(this, this.rawData); // 点击上报
})
}
}
应用侧卡片页面:
import { FlutterFormCard } from 'flutter_ohos_form';
@Entry
@Component
struct WidgetCard {
@LocalStorageProp('data') data: string = '{}';
build() {
Column() {
FlutterFormCard({ rawData: this.data, title: 'Flutter 服务卡片' })
}
.width('100%')
.height('100%')
}
}
这里有一个必须踩过的坑:模板最初只在
aboutToAppear里解析一次rawData,导致updateForm更新数据后桌面卡片不刷新(一直显示--)。根因见第八章踩坑复盘。
5.3 加桌流程封装
由于公开 SDK 的限制(详见第八章),加桌最终实现为"拉起卡片管理页 + 等待事件回传":
private async addForm(call: MethodCall, result: MethodResult): Promise<void> {
if (this.abilityContext == null) {
result.error('ABILITY_NULL', 'UIAbilityContext is null', null);
return;
}
const formName: string = call.argument('formName') as string;
const options: AddFormOptions = {
formName: formName,
dimension: call.argument('dimension') as number ?? DEFAULT_DIMENSION,
moduleName: call.argument('moduleName') as string ?? DEFAULT_MODULE_NAME,
data: call.argument('data') as string ?? '{}',
};
// 1. 暂存业务数据
await this.store.savePendingData(options.formName, entry);
// 2. 注册等待者(60 秒超时)
const formIdPromise: Promise<string | null> = this.waitForAddResult(options.formName);
// 3. 拉起卡片管理页
this.openFormManager(options);
// 4. 等 formAdded 事件回传 formId
const formId: string | null = await formIdPromise;
result.success(formId);
}
private openFormManager(options: AddFormOptions): void {
const parameters: Record<string, Object> = {};
parameters[formInfo.FormParam.DIMENSION_KEY] = options.dimension;
parameters[formInfo.FormParam.NAME_KEY] = options.formName;
parameters[formInfo.FormParam.MODULE_NAME_KEY] = options.moduleName;
const want: Want = {
bundleName: this.abilityContext.applicationInfo.name,
abilityName: 'EntryFormAbility',
parameters: parameters,
};
formProvider.openFormManager(want);
}
第 6 阶段:示例与验证
6.1 example 工程结构
cd example
flutter create . --platforms=ohos
生成后补充卡片相关文件:
example/ohos/entry/src/main/
├── ets/
│ ├── entryability/EntryAbility.ets # Flutter 入口 Ability(脚手架生成)
│ ├── entryformability/EntryFormAbility.ets # 卡片 Ability(新增,继承基类)
│ └── widget/pages/WidgetCard.ets # 卡片 UI(新增)
└── resources/base/profile/
└── form_config.json # 卡片配置(新增)
卡片 Ability:
import { FlutterFormExtensionAbility } from 'flutter_ohos_form';
export default class EntryFormAbility extends FlutterFormExtensionAbility {
}
卡片配置 form_config.json:
{
"forms": [
{
"name": "widget",
"description": "$string:form_desc",
"src": "./ets/widget/pages/WidgetCard.ets",
"uiSyntax": "arkts",
"window": { "designWidth": 720, "autoDesignWidth": true },
"colorMode": "auto",
"isDefault": true,
"updateEnabled": true,
"defaultDimension": "2*2",
"supportDimensions": ["2*2", "2*4", "4*4"]
}
]
}
module.json5 中注册:
{
"module": {
"extensionAbilities": [
{
"name": "EntryFormAbility",
"srcEntry": "./ets/entryformability/EntryFormAbility.ets",
"label": "$string:EntryAbility_label",
"description": "$string:form_desc",
"type": "form",
"metadata": [
{
"name": "ohos.extension.form",
"resource": "$profile:form_config"
}
]
}
]
}
}
6.2 插件注册
pubspec.yaml 中声明 OHOS 平台:
flutter:
plugin:
platforms:
android:
package: com.nutpi.flutter_ohos_form
pluginClass: FlutterOhosFormPlugin
ohos:
pluginClass: FlutterOhosFormPlugin # ← 对应 index.ets 的默认导出
ios:
pluginClass: FlutterOhosFormPlugin
Flutter 工具会自动生成 GeneratedPluginRegistrant.ets,无需手写:
export class GeneratedPluginRegistrant {
static registerWith(flutterEngine: FlutterEngine) {
flutterEngine.getPlugins()?.add(new FlutterOhosFormPlugin());
}
}
6.3 HAR 包导出
ohos/index.ets 需要把应用侧要用的类全部导出,否则应用 import { FlutterFormExtensionAbility } 会失败:
import FlutterOhosFormPlugin from './src/main/ets/components/plugin/FlutterOhosFormPlugin';
import FlutterFormExtensionAbility from './src/main/ets/components/plugin/FlutterFormExtensionAbility';
import { FlutterFormCard, FlutterFormCardMini, parseFormCardData } from './src/main/ets/components/plugin/FormCardTemplate';
import { FormDataStore } from './src/main/ets/components/plugin/FormDataStore';
import { FormEventDispatcher } from './src/main/ets/components/plugin/FlutterOhosFormPlugin';
export default FlutterOhosFormPlugin;
export {
FlutterFormExtensionAbility,
FlutterFormCard,
FlutterFormCardMini,
parseFormCardData,
FormDataStore,
FormEventDispatcher,
};
6.4 构建与安装
cd example
flutter build hap --debug
# 安装到设备
hdc install -r build/ohos/hap/entry-default-signed.hap
# 启动应用
hdc shell aa start -a EntryAbility -b com.nutpi.flutter_ohos_form_example

四、完整代码对照
4.1 整体架构
┌─────────────────────────────────────────────────────────┐
│ Flutter 主进程 │
│ │
│ FlutterOhosForm (Dart) │
│ ├─ addForm / updateForm / removeForm │
│ ├─ getFormIds / getFormData / getAllFormData │
│ └─ formEvents / onFormClick / onFormAdded ... │
│ │ │
│ │ MethodChannel: flutter_ohos_form/methods │
│ │ EventChannel: flutter_ohos_form/events │
│ ▼ │
│ FlutterOhosFormPlugin (ArkTS) │
│ ├─ MethodCallHandler → 处理 Dart 调用 │
│ ├─ StreamHandler → 持有 EventSink │
│ └─ subscribeCardEvents → 订阅公共事件 │
│ │ │
│ FormDataStore (preferences 单例) │
│ ├─ form_data (formId → 业务数据) │
│ ├─ form_ids (已加桌卡片列表) │
│ └─ pending_data (formName → 待落库数据) │
└──────────────┬──────────────────────────────────────────┘
│
│ ① formProvider.updateForm 下发数据
│ ② commonEventManager 回传事件
│ ③ preferences 持久化共享
│
┌──────────────┴──────────────────────────────────────────┐
│ 卡片进程(独立进程) │
│ │
│ FlutterFormExtensionAbility (应用侧继承) │
│ ├─ onAddForm → 读暂存数据 + 回传 formId │
│ ├─ onFormEvent → 点击事件上报 │
│ ├─ onRemoveForm → 清理数据 │
│ └─ onChangeFormVisibility → 可见性变化 │
│ │ │
│ ▼ │
│ WidgetCard.ets → FlutterFormCard 模板 │
│ └─ postCardAction → 点击上报 │
└─────────────────────────────────────────────────────────┘
4.2 文件清单
| 文件 | 职责 |
|---|---|
ohos/.../FlutterOhosFormPlugin.ets | 插件主体:双通道注册、方法分发、加桌握手、事件转发 |
ohos/.../FlutterFormExtensionAbility.ets | 卡片 Ability 基类:4 个生命周期回调封装 |
ohos/.../FormCardTemplate.ets | 卡片 UI 模板:完整版 FlutterFormCard + 精简版 FlutterFormCardMini |
ohos/.../FormDataStore.ets | preferences 单例仓库:数据落库、待加桌暂存、主动刷新卡片 |
ohos/.../FormCommonEvent.ets | 公共事件发布 / 订阅:跨进程事件通路 |
ohos/.../FormConstants.ets | 通道名、preferences 键名、事件类型常量 |
ohos/.../FormTypes.ets | ArkTS 强类型接口定义 |
lib/flutter_ohos_form.dart | Dart 主入口 FlutterOhosForm |
lib/flutter_ohos_form_method_channel.dart | MethodChannel / EventChannel 实现 |
lib/flutter_ohos_form_platform_interface.dart | 平台接口抽象(可测试) |
lib/src/form_data.dart | FormData 数据模型 |
lib/src/form_event.dart | FormEvent 事件模型 + FormEventType 枚举 |
合计 12 个文件、2109 行代码(不含 example 与文档)。
4.3 关键 API 对照
| 能力 | Flutter OH 引擎 / 鸿蒙 API | Android 等价物 |
|---|---|---|
| 插件生命周期 | FlutterPlugin + onAttachedToEngine | 同 |
| 获取通信 Messenger | binding.getBinaryMessenger() | getFlutterEngine().getDartExecutor() |
| 获取上下文 | AbilityAware → binding.getAbility().context | ActivityAware → onAttachedToActivity |
| 方法调用 | MethodChannel + MethodCallHandler | 同 |
| 事件推送 | EventChannel + StreamHandler | 同 |
| 跨进程事件 | commonEventManager 公共事件 | BroadcastReceiver |
| 键值存储 | @ohos.data.preferences | SharedPreferences |
| 卡片更新 | formProvider.updateForm | AppWidgetManager.updateAppWidget |
| 卡片创建 | FormExtensionAbility.onAddForm | AppWidgetProvider.onUpdate |
4.4 ArkTS 与 TypeScript / Kotlin 的语法差异(本次实际踩到的)
| 常见写法 | ArkTS 要求 | 说明 |
|---|---|---|
const o = { a: 1, b: 2 } 直接传给 Record<string, T> | 必须对应显式声明的 interface | 报 arkts-no-untyped-obj-literals,见踩坑 2 |
{ [key]: value } 计算属性名 | 不支持 | 报 arkts-identifiers-as-prop-names,需改为 obj[key] = value |
@Prop backgroundColor: string | 与 CustomComponent 内置属性冲突 | 需改名(本项目改为 cardBackgroundColor) |
@Entry 组件 build() 直接返回自定义组件 | 必须是唯一容器组件 | 需用 Column() 等容器包裹 |
any 类型 | 严格模式受限,用 ESObject | 引擎里 Any 即 declare type Any = ESObject |
五、关键决策说明
决策 1:通道名与数据契约先行定义
背景:Dart 与 ArkTS 是两个语言、两个工程,通道名、preferences 键名、事件类型字符串如果两边各写一遍,极易拼错且难以排查(错误表现是"静默无响应",非常难定位)。
决策:把全部跨语言字符串常量集中到 FormConstants.ets 统一维护,Dart 侧与之严格对应。
export const FORM_METHOD_CHANNEL: string = 'flutter_ohos_form/methods';
export const FORM_EVENT_CHANNEL: string = 'flutter_ohos_form/events';
export const FORM_PREFERENCES_NAME: string = 'flutter_ohos_form_store';
export const KEY_FORM_DATA: string = 'form_data';
export const KEY_FORM_IDS: string = 'form_ids';
export const KEY_PENDING_DATA: string = 'pending_data';
export const EVENT_FORM_CLICK: string = 'formClick';
export const EVENT_FORM_ADDED: string = 'formAdded';
export const EVENT_FORM_REMOVED: string = 'formRemoved';
维护策略:新增事件类型或键名时必须同时更新此文件与 Dart 侧的 FormEventType 枚举,并保持字符串字面量一致(FormEvent._parseType 里用的是字符串匹配)。
决策 2:用 pending_data 暂存区解决加桌时序问题
背景:加桌流程存在天然的时序矛盾——Flutter 发起加桌时还不知道 formId,但业务数据必须先准备好,否则卡片创建出来是空白。这个矛盾无法通过"等一会儿再发数据"绕开,因为 formId 是由系统在卡片创建时才分配的。
决策:引入 pending_data 暂存区,把"数据准备"与"数据落库"解耦。
| 阶段 | 动作 | 数据位置 |
|---|---|---|
| ① Flutter 发起加桌 | savePendingData(formName, entry) | pending_data[formName] |
| ② 系统创建卡片 | onAddForm 拿到 formId | — |
| ③ 卡片进程读暂存 | getPendingData(formName) | 从 pending_data 读出 |
| ④ 落库到真实 formId | saveFormData(formId, entry) | form_data[formId] |
| ⑤ 回传事件唤醒 Flutter | publishFormEvent(formAdded) | resolveAddWaiter(formId) |
维护策略:pending_data 中的条目在落库后不主动清理(体积极小,且便于用户重复加桌时复用最新数据);若后续需要,可在 saveFormData 成功后删除对应 formName 条目。
决策 3:主动 updateForm 而非依赖 preferences 变更通知
背景:preferences 提供了 on('change') 变更订阅,理论上 Flutter 写入后卡片进程能收到通知。但实测发现跨进程的变更通知不保证可靠触发——这与实现机制有关(通知回调绑定在调用方进程的上下文中)。
决策:写入 preferences 后,主动调用 formProvider.updateForm 把数据推送到卡片进程。
async saveFormData(formId: string, entry: FormDataEntry): Promise<boolean> {
// 1. 持久化(供卡片进程重启后读取、供查询接口使用)
await this.dataPreferences.put(KEY_FORM_DATA, ...);
await this.dataPreferences.flush();
// 2. 主动推送(保证卡片实时刷新)
await this.updateFormBinding(formId, entry);
return true;
}
| 方案 | 可靠性 | 说明 |
|---|---|---|
| 主动 updateForm ✅ | 高 | 每次写入都显式推送,实测必达 |
仅依赖 on('change') | 低 | 跨进程不保证触发,卡片会显示旧数据 |
| 两者结合 | 最高 | 本插件实际做法:持久化 + 主动推送 |
维护策略:FormDataStore.onFormDataChanged 仍保留订阅能力(供卡片进程使用),但不作为刷新卡片的唯一手段。
决策 4:卡片 UI 由应用声明,插件只提供模板
背景:卡片进程只能渲染 ArkTS 声明的 UI,无法渲染 Flutter 视图。这意味着插件无法替应用决定卡片长什么样。
决策:插件提供开箱即用的 FlutterFormCard 模板,但不强制使用——应用可以完全自定义卡片 UI,只要从 LocalStorage 读取 data 字段并按约定结构解析即可。
// 用模板
FlutterFormCard({ rawData: this.data, title: 'Flutter 服务卡片' })
// 或者完全自定义
@LocalStorageProp('data') data: string = '{}';
build() {
Column() {
Text(JSON.parse(this.data).title)
// ... 自己的布局
}
}
维护策略:卡片数据的字段约定(title / subtitle / value / unit / footer / items)写在 FormCardData 接口里,自定义 UI 时按此结构解析即可;新增字段属于向后兼容变更。
决策 5:跨进程事件全部以字符串传递
背景:公共事件的 parameters 类型是 Record<string, Object>,而 Dart 侧的数据模型有 int(如 dimension)。跨"ArkTS → 公共事件 → ArkTS → MethodCodec → Dart"多次转换后,类型容易漂移(比如 int 变成 string)。
决策:事件参数全部转为字符串传递,在 Dart 侧按需解析。
function readParam(source: Record<string, Object>, key: string): string {
const value: Object | undefined = source[key];
if (value == null) {
return '';
}
return `${value}`; // 统一转字符串
}
Dart 侧 FormData.fromMap 做了兼容处理:
static int _parseInt(Object? value) {
if (value is int) return value;
if (value is num) return value.toInt();
if (value is String) return int.tryParse(value) ?? 2; // 兼容字符串
return 2;
}
维护策略:新增数值型字段时保持"传输用字符串、解析在 Dart"的原则,避免跨语言类型问题。
决策 6:错误不抛出,统一通过 result.error 回传
背景:ArkTS 侧的异常如果直接抛出,会穿透到 Flutter 引擎层,可能导致插件通道静默失效,排查困难。
决策:所有 method handler 统一 try/catch,失败时通过 result.error(code, message, details) 回传,Dart 侧以 PlatformException 形式抛出,由应用决定如何处理。
private async getFormIds(result: MethodResult): Promise<void> {
try {
const formIds: string[] = await this.store.getFormIds();
result.success(formIds);
} catch (err) {
const error = err as BusinessError;
hilog.error(DOMAIN, TAG, 'getFormIds failed, code: %{public}d', error.code);
result.error('GET_FORM_IDS_ERROR', error.message, error.code);
}
}
维护策略:错误码采用 大写下划线 命名(ABILITY_NULL / INVALID_ARGS / ADD_FORM_ERROR 等),便于应用侧精确分支处理。
六、测试与验证
6.1 测试环境
| 项目 | 版本 |
|---|---|
| Flutter | 3.47.4-ohos-1.0.4 |
| Dart | 3.13.3 |
| HarmonyOS SDK | 26.0.0 |
| IDE | DevEco Studio 26.0.0 |
| 设备 ROM | OpenHarmony 7.0.0.106(SP1DEVC00E999R4P11) |
版本获取方式:
| 版本项 | 获取方式 |
|---|---|
| Flutter / Dart | flutter --version |
| HarmonyOS SDK | 读取 example/ohos/build-profile.json5 的 compatibleSdkVersion |
| IDE | defaults read /Applications/DevEco-Studio.app/Contents/Info.plist CFBundleShortVersionString |
| 设备 ROM | hdc shell param get const.product.software.version(先 hdc list targets 确认连接) |
6.2 静态检查与单元测试
Dart 静态检查:
$ flutter analyze
Analyzing flutter_ohos_form...
No issues found! (ran in 3.8s)
Dart 单元测试:
$ flutter test --reporter expanded
00:00 +0: loading test/flutter_ohos_form_test.dart
00:00 +1: getPlatformVersion 返回平台版本描述
00:00 +2: addForm 使用正确的参数名
00:00 +3: addForm 透传 formName 与业务数据
00:00 +4: addFormWithMap 自动序列化 Map 数据
00:00 +5: getAllFormData 兼容字符串型 dimension
00:00 +6: updateForm 透传 formId 与数据
00:00 +7: getFormData 在平台返回 null 时返回 null
00:00 +8: getFormIds / getFormData / getAllFormData 返回解析后的模型
00:00 +9: onFormClick 只透出点击事件
00:00 +10: FormData.fromMap 兼容 ArkTS 返回的字符串型 dimension
00:00 +11: FormEvent.fromMap 未知事件回退为 unknown
00:00 +12: getPlatformVersion 调用同名方法
00:00 +13: All tests passed!
13 个测试用例全部通过,覆盖两条测试路径:
| 测试文件 | 覆盖内容 |
|---|---|
test/flutter_ohos_form_test.dart | 基于 FakeFlutterOhosFormPlatform 验证 FlutterOhosForm 的 API 透传、Map 序列化、模型解析、事件过滤 |
test/flutter_ohos_form_method_channel_test.dart | 基于 setMockMethodCallHandler 验证真实 MethodChannel 的通道名、参数名与返回值解析 |
测试环境修复说明:本机缓存的
flutter_tester与 Dart SDK 内核版本不匹配(详见第八章踩坑 10、11),需重新拉取匹配的宿主产物并 ad-hoc 重签后才能运行测试。
ArkTS 侧通过 flutter build hap 编译验证(DevEco 的 ArkTS 编译器会做严格检查):
$ flutter build hap --debug
start hap build...
Running Hvigor task assembleHap... 9.3s
✓ Built build/ohos/hap/entry-default-signed.hap.
6.3 插件注册验证
应用启动后,hilog 中确认插件已挂载:
FlutterEngineCxnRegistry --> Adding plugin: FlutterOhosFormPlugin
FlutterOhosFormPlugin: ability attached: EntryAbility
FormDataStore: preferences opened: flutter_ohos_form_store
FlutterOhosFormPlugin: store initialized on attach
FormCommonEvent: subscribeFormEvent success
FlutterOhosFormPlugin: event channel listened
六个关键节点全部正常:插件注册 → Ability 绑定 → 存储初始化 → 公共事件订阅 → 事件通道就绪。
6.4 功能验证用例
用例 1:查询设备支持情况
操作:启动应用,查看"运行环境"卡片。
预期:显示平台版本与"支持加桌: 是"。
实测:uitest dumpLayout 抓取到的界面文本:
平台版本: OpenHarmony Form Plugin
支持加桌: 是
已加桌卡片: 0 张
用例 2:添加卡片到桌面
操作:点击"添加卡片到桌面"按钮 → 在卡片管理页点击"添加至桌面"。
预期:返回 formId,桌面出现卡片,Flutter 页面收到 formAdded 事件。
实测日志:
FormDataStore: updateForm success, formId: 876618136
FormDataStore: saveFormData success, formId: 876618136
com.ohos.formrenderservice: Render form, abilityName=EntryFormAbility,
formName=widget, moduleName=entry, formId=876618136
界面显示:已请求加桌,卡片 ID: 876618136。桌面卡片成功渲染。
用例 3:更新卡片数据
操作:点击"更新卡片数据"。
预期:桌面卡片内容刷新。
实测日志:
FlutterOhosFormPlugin: updateForm finished, updated: 3
桌面卡片实测文本(uitest dumpLayout):
'Flutter 服务卡片' | [152,1196][428,1249]
'次' | [214,1314][254,1360]
'数据来自 Flutter 进程' | [152,1378][550,1420]
'更新次数: 1' | [197,1447][550,1486]
'当前时间: 18:13:44' | [197,1495][550,1534] ← 时间已刷新
'点击卡片查看回调' | [152,1559][550,1594]
用例 4:卡片点击回调
操作:点击桌面卡片。
预期:Dart 侧 onFormClick 收到事件。
实测日志(完整跨进程链路):
[卡片进程] FlutterFormExtensionAbility: onFormEvent, formId: 1126089015,
message: {"message":"cardClick","params":"{\"title\":\"Flutter 服务卡片\",...}"}
[卡片进程] FormCommonEvent: publish success: formClick
[主进程] FlutterOhosFormPlugin: dispatch success: formClick, formId: 1126089015
跨进程链路完整打通。
用例 5:清理卡片记录
操作:点击"清理卡片记录"。
预期:清理全部卡片数据记录并派发事件。
实测日志:
FlutterOhosFormPlugin: removeForm finished, cleared: 3
FlutterOhosFormPlugin: dispatch success: formRemoved, formId: 807816797
FlutterOhosFormPlugin: dispatch success: formRemoved, formId: 876618136
FlutterOhosFormPlugin: dispatch success: formRemoved, formId: 1126089015
用例 6:用户长按移除卡片
操作:在桌面长按卡片并移除。
预期:卡片进程 onRemoveForm 触发,清理数据并通知 Flutter。
实测日志:
[卡片进程] FlutterFormExtensionAbility: onRemoveForm, formId: 876618136
[卡片进程] FormDataStore: removeFormData success, formId: 876618136
[卡片进程] FormCommonEvent: publish success: formRemoved
[主进程] FlutterOhosFormPlugin: dispatch success: formRemoved, formId: 876618136
6.5 验证结论
| 用例 | 结果 |
|---|---|
| 查询设备支持情况 | ✅ 通过 |
| 添加卡片到桌面 | ✅ 通过 |
| 更新卡片数据 | ✅ 通过 |
| 卡片点击回调 | ✅ 通过 |
| 清理卡片记录 | ✅ 通过 |
| 用户长按移除卡片 | ✅ 通过 |
静态检查 flutter analyze | ✅ 无问题 |
ArkTS 编译 assembleHap | ✅ 构建成功 |
七、运行效果
7.1 获取运行截图
# 确认设备连接
hdc list targets
# 截取当前屏幕
flutter screenshot -d 127.0.0.1:5555
# 或者直接用 hdc(无需 Flutter 工具链)
hdc shell snapshot_display -f /data/local/tmp/screenshot.jpeg
hdc file recv /data/local/tmp/screenshot.jpeg ./docs/images/
7.2 界面文本快照
除截图外,用 uitest dumpLayout 抓取界面文本可以更精确地记录验证结果(本文第六章的实测数据即来自此方式):
hdc shell uitest dumpLayout -p /data/local/tmp/layout.json
hdc file recv /data/local/tmp/layout.json /tmp/layout.json
Flutter 应用主页面:
Flutter 鸿蒙服务卡片
├─ 运行环境
│ 平台版本: OpenHarmony Form Plugin
│ 支持加桌: 是
│ 已加桌卡片: 3 张
├─ 卡片操作
│ [添加卡片到桌面] [更新卡片数据] [清理卡片记录] [刷新卡片列表]
│ 已请求加桌,卡片 ID: 876618136
├─ 最近事件
│ formRemoved | formId=807816797 | message=
└─ 卡片数据
formId: 807816797
formName: widget
dimension: 2
{ "title": "Flutter 服务卡片", "value": "1", ... }
桌面上的服务卡片:
┌──────────────────────────┐
│ Flutter 服务卡片 [Flutter]│
│ │
│ 1 次 │
│ 数据来自 Flutter 进程 │
│ • 更新次数: 1 │
│ • 当前时间: 18:13:44 │
│ │
│ 点击卡片查看回调 │
└──────────────────────────┘
卡片上的"当前时间"每次点击"更新卡片数据"都会刷新,这是验证跨进程数据下发是否生效的最直观标志。
7.3 验证命令速查
# 构建
cd example && flutter build hap --debug
# 安装
hdc install -r build/ohos/hap/entry-default-signed.hap
# 启动
hdc shell aa start -a EntryAbility -b com.nutpi.flutter_ohos_form_example
# 查看插件日志
hdc shell "hilog -x" | grep -E "FlutterOhosFormPlugin|FormDataStore|FormCommonEvent|FlutterFormExtension"
# 抓取界面文本
hdc shell uitest dumpLayout -p /data/local/tmp/layout.json
hdc file recv /data/local/tmp/layout.json /tmp/layout.json
# 模拟点击(坐标从 dumpLayout 结果中获取)
hdc shell uitest uiInput click 355 993
# 回到桌面
hdc shell uitest uiInput keyEvent Home
八、遗留问题与改进方向
8.1 踩坑复盘
本次实现中真正花了时间的问题,按踩坑顺序记录:
| # | 踩坑点 | 现象 / 报错 | 根因与解法 |
|---|---|---|---|
| 1 | 公开 SDK 没有加桌 API | 编译报 Property 'requestPublishForm' does not exist on type 'typeof formProvider' | API 26 公开 SDK 的 formProvider 里没有 requestPublishForm——它属于 formHost,需 ohos.permission.REQUIRE_FORM 系统权限。解法:改用公开的 formProvider.openFormManager(want) 拉起卡片管理页,由用户点击"添加至桌面";再靠卡片进程 onAddForm 回传 formAdded 事件拿到 formId 完成握手。这是能力降级,已在文档中明确说明 |
| 2 | ArkTS 禁止无类型对象字面量 | arkts-no-untyped-obj-literals(6 处) | ArkTS 严格模式下 { a: 1 } 这类字面量必须对应显式声明的 interface。解法:新建 FormTypes.ets,为所有跨语言结构声明 interface(FormDataEntry / FormBindingPayload / FormEventParams / FormCardInfo / AddFormOptions) |
| 3 | ArkTS 禁止计算属性名 | arkts-identifiers-as-prop-names(11 处) | { [PARAM_EVENT]: value } 这种动态键写法不被支持。解法:改为先声明空对象再逐项赋值:const parameters: Record<string, Object> = {}; parameters[PARAM_EVENT] = ... |
| 4 | 组件属性名冲突 | Property 'backgroundColor' in type 'FlutterFormCard' is not assignable to the same property in base type 'CustomComponent' | @Prop backgroundColor 与 CustomComponent 内置的 backgroundColor 属性(方法签名类型)冲突。解法:改名为 cardBackgroundColor |
| 5 | @Entry 组件根节点限制 | In an '@Entry' decorated component, the 'build' method can have only one root node, which must be a container component | @Entry 组件的 build() 不能直接返回自定义组件,必须是唯一容器组件。解法:用 Column() { FlutterFormCard(...) }.width('100%').height('100%') 包裹 |
| 6 | postCardAction 导入方式错误 | 编译报找不到模块 @ohos.arkui.postCardAction | postCardAction 是 ArkUI 的全局声明函数(在 component/common.d.ts 里 declare function),不需要也不应该 import。解法:直接删除 import 语句 |
| 7 | 卡片数据更新后界面不刷新 ⭐ | updateForm 返回成功、preferences 里数据也是新的,但桌面卡片一直显示 -- | 最隐蔽的坑。FlutterFormCard 最初只在 aboutToAppear() 里解析一次 rawData。而卡片刷新时系统只更新 LocalStorage 的数据,不会重建组件,所以 aboutToAppear 不会再执行,cardData 永远是初始的空对象。解法:给 rawData 加 @Watch 监听,数据变化时重新解析:@Prop @Watch('onRawDataChange') rawData: string = '{}';onRawDataChange(_newValue: string): void { this.cardData = parseFormCardData(this.rawData); } |
| 8 | HAR 是 tar 不是 zip | unzip flutter_embedding_release.har 报 cannot find zipfile directory | 想读引擎源码确认接口,用 unzip 解不开。解法:HAR 是 tar 格式,用 tar xzf 解压 |
| 9 | formHost 模块不存在 | import formHost from '@ohos.app.form.formHost' 编译失败 | 翻阅 SDK 的 .d.ts 目录确认:API 26 公开 SDK 里根本没有 @ohos.app.form.formHost 这个模块。解法:删除删除卡片的系统调用,改为只清理插件侧数据,桌面卡片由用户长按移除(onRemoveForm 会同步清理) |
| 10 | 单测运行器与 Dart SDK 版本不匹配 | flutter test 报 Invalid kernel binary format version (expected 130, found 138) | 本机缓存的 flutter_tester 是用 Dart 3.12.2 构建的(内核格式 130),而缓存里的 Dart SDK 已是 3.13.3(格式 138),两者不匹配。解法:从官方镜像按当前引擎 revision 重新拉取匹配的 darwin-arm64/artifacts.zip,替换 bin/cache/artifacts/engine/darwin-x64/ 下的宿主产物(原产物已备份可回滚) |
| 11 | 替换后的 flutter_tester 被系统直接 SIGKILL | 内核版本报错消失,但 flutter_tester process ... exited with code=-9,测试报 Connection closed before test suite loaded | 崩溃报告(~/Library/Logs/DiagnosticReports/flutter_tester-*.ips)显示 SIGKILL (Code Signature Invalid) + Taskgated Invalid Signature——下载的二进制虽通过 codesign --verify,但 taskgated 拒绝其 Developer ID 签名。解法:用 codesign --force --sign - --preserve-metadata=identifier,entitlements,flags 做 ad-hoc 重签(保留原 entitlements),签名后测试正常运行 |
8.2 已知问题
-
无法主动删除桌面卡片 — OpenHarmony 未向三方应用开放
formHost.deleteForm(系统接口,需ohos.permission.REQUIRE_FORM)。removeForm()只能清理插件侧数据记录,桌面卡片需用户长按移除。移除时会触发onRemoveForm自动同步清理数据。 -
加桌需用户手动确认 — 受公开 SDK 限制,加桌通过
formProvider.openFormManager拉起卡片管理页,需用户点击"添加至桌面"。addForm()内部等待formAdded事件(默认超时 60 秒),超时返回null;应用可通过onFormAdded事件流兜底获取结果。 -
卡片与 Flutter 运行时隔离 — 卡片进程无法复用 Flutter 的渲染能力与内存状态,所有要展示的数据必须通过
updateForm显式下发。这也是为什么卡片上不能直接显示"Flutter 里的某个对象"。 -
卡片尺寸适配有限 — 模板针对 2x2 / 2x4 / 4x4 做了自适应,1x1 圆形卡片建议用
FlutterFormCardMini或自定义 UI。
8.3 未来优化方向
- 待环境修复后补齐单元测试 — Dart 侧已基于
plugin_platform_interface抽象,测试用例(test/flutter_ohos_form_test.dart、test/flutter_ohos_form_method_channel_test.dart)已写好,可直接运行。 - 支持更多卡片事件 — 目前支持点击、加桌、移除、可见性变化;可扩展
onUpdateForm(定时刷新回调)、onCastToNormalForm(临时卡片转正式)。 - 卡片 UI 模板增强 — 支持图表、图片等富内容卡片,提供更多尺寸的预设布局。
pending_data清理策略 — 落库成功后清理对应条目,避免长期积累。- 补充 Android / iOS 实现 — 服务卡片是鸿蒙独有能力,但 Android 的 App Widget、iOS 的 WidgetKit 有类似概念,可在同一套 Dart API 下提供对应实现。
九、总结
9.1 核心难点回顾
与常规的"把 Android 库翻译到鸿蒙"不同,本次实现面对的是鸿蒙独有能力,没有现成参照,三个核心难点都是重新设计的:
| 难点 | 本质 | 解法 |
|---|---|---|
| 加桌时序矛盾 | 发起加桌时不知道 formId,但数据必须先准备好 | pending_data 暂存区 + 事件回传握手 |
| 跨进程数据下发 | 两个进程内存不共享 | preferences 持久化 + 主动 updateForm 推送 |
| 跨进程事件回传 | 卡片进程没有 Flutter 引擎,用不了 EventChannel | commonEventManager 公共事件 + 主进程订阅转发 |
9.2 封装层次
插件的价值在于把复杂度收敛到三个层次,应用侧几乎无感:
应用侧只需写 3 个文件
├─ EntryFormAbility.ets → 继承基类,1 行代码
├─ WidgetCard.ets → 用 FlutterFormCard 模板,约 10 行
└─ module.json5 → 注册 extensionAbility,约 15 行
│
▼
插件承担全部复杂度
├─ 双通道通信(MethodChannel + EventChannel)
├─ 加桌握手(pending_data + 事件回传)
├─ 跨进程数据(preferences + 主动推送)
├─ 跨进程事件(commonEventManager)
└─ 生命周期封装(FormExtensionAbility 基类)
9.3 三条经验
-
先读引擎源码,再写代码 — Flutter OH 引擎的 HAR 包里有完整的
.ets源码和index.ets导出清单。动手前花 10 分钟确认可用的接口符号,比写完再排查编译错误高效得多。 -
公开 SDK 的能力边界要早探明 —
requestPublishForm/formHost.deleteForm都不在公开 SDK 里,属于系统接口。如果做到一半才发现,架构就要推翻重来。建议动手前先翻一遍 SDK 的.d.ts目录,确认关键 API 是否存在。 -
真机验证不可替代 — 第 7 个坑(卡片不刷新)在静态检查和编译阶段完全看不出来:
updateForm返回成功、preferences 里数据也是新的、日志一切正常,只有真正看桌面卡片才发现一直显示--。这类"数据链路通但 UI 不更新"的问题,只能靠真机验证暴露。
9.4 成果
flutter_ohos_form 最终实现 12 个源文件、2109 行代码,在 OpenHarmony 7.0.0(API 26)真机上完成 6 项功能验证全部通过,并配套提供了中英文适配文档、CHANGELOG 与 example 工程。
参考文档
- flutter_ohos_form 项目仓库
- HarmonyOS Flutter 适配指南(flutter_flutter)
- 服务卡片开发指导(FormKit)
- formProvider API 文档
- FormExtensionAbility API 文档
- commonEventManager API 文档
- preferences API 文档
环境:Flutter 3.47.4-ohos-1.0.4 · Dart 3.13.3 · HarmonyOS SDK 26.0.0 · OpenHarmony 7.0.0.106
更多推荐


所有评论(0)