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
  • 卡片数据查询:查询已加桌卡片列表与每张卡片的业务数据
  • 可复用卡片 UIFlutterFormCard / FlutterFormCardMini 内置模板,含点击上报
  • FormExtensionAbility 基类FlutterFormExtensionAbility 封装全部卡片生命周期回调

开发者接入只需要三步:继承一个基类、写一个卡片 UI、在 Dart 侧调用 API。

项目地址https://atomgit.com/oh-flutter/flutter_ohos_form

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 / MethodResultDart → ArkTS 方法调用
EventChannel / StreamHandler / EventSinkArkTS → 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/methodsMethodChannelDart → ArkTS加桌、更新、查询等主动调用
flutter_ohos_form/eventsEventChannelArkTS → 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 的差异对照:

维度AndroidOHOS
获取 Messengerbinding.getFlutterEngine().getDartExecutor()binding.getBinaryMessenger()
方法通道MethodChannel(messenger, name)new MethodChannel(messenger, name)
事件通道EventChannel(messenger, name).setStreamHandler(this)new EventChannel(messenger, name).setStreamHandler(this)
上下文获取ActivityAwareonAttachedToActivityAbilityAwareonAttachedToAbility

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 方法分发

onMethodCallswitch 分发,所有方法都做了异常兜底:

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_dataMap<formId, FormDataEntry> 的 JSON每张卡片的业务数据
form_idsstring[] 的 JSON已加桌卡片 ID 列表,便于批量更新
pending_dataMap<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

image-20260919183823720

四、完整代码对照

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.etspreferences 单例仓库:数据落库、待加桌暂存、主动刷新卡片
ohos/.../FormCommonEvent.ets公共事件发布 / 订阅:跨进程事件通路
ohos/.../FormConstants.ets通道名、preferences 键名、事件类型常量
ohos/.../FormTypes.etsArkTS 强类型接口定义
lib/flutter_ohos_form.dartDart 主入口 FlutterOhosForm
lib/flutter_ohos_form_method_channel.dartMethodChannel / EventChannel 实现
lib/flutter_ohos_form_platform_interface.dart平台接口抽象(可测试)
lib/src/form_data.dartFormData 数据模型
lib/src/form_event.dartFormEvent 事件模型 + FormEventType 枚举

合计 12 个文件、2109 行代码(不含 example 与文档)。

4.3 关键 API 对照

能力Flutter OH 引擎 / 鸿蒙 APIAndroid 等价物
插件生命周期FlutterPlugin + onAttachedToEngine
获取通信 Messengerbinding.getBinaryMessenger()getFlutterEngine().getDartExecutor()
获取上下文AbilityAwarebinding.getAbility().contextActivityAwareonAttachedToActivity
方法调用MethodChannel + MethodCallHandler
事件推送EventChannel + StreamHandler
跨进程事件commonEventManager 公共事件BroadcastReceiver
键值存储@ohos.data.preferencesSharedPreferences
卡片更新formProvider.updateFormAppWidgetManager.updateAppWidget
卡片创建FormExtensionAbility.onAddFormAppWidgetProvider.onUpdate

4.4 ArkTS 与 TypeScript / Kotlin 的语法差异(本次实际踩到的)

常见写法ArkTS 要求说明
const o = { a: 1, b: 2 } 直接传给 Record<string, T>必须对应显式声明的 interfacearkts-no-untyped-obj-literals,见踩坑 2
{ [key]: value } 计算属性名不支持arkts-identifiers-as-prop-names,需改为 obj[key] = value
@Prop backgroundColor: stringCustomComponent 内置属性冲突需改名(本项目改为 cardBackgroundColor
@Entry 组件 build() 直接返回自定义组件必须是唯一容器组件需用 Column() 等容器包裹
any 类型严格模式受限,用 ESObject引擎里 Anydeclare 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 读出
④ 落库到真实 formIdsaveFormData(formId, entry)form_data[formId]
⑤ 回传事件唤醒 FlutterpublishFormEvent(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 测试环境

项目版本
Flutter3.47.4-ohos-1.0.4
Dart3.13.3
HarmonyOS SDK26.0.0
IDEDevEco Studio 26.0.0
设备 ROMOpenHarmony 7.0.0.106(SP1DEVC00E999R4P11)

版本获取方式:

版本项获取方式
Flutter / Dartflutter --version
HarmonyOS SDK读取 example/ohos/build-profile.json5compatibleSdkVersion
IDEdefaults read /Applications/DevEco-Studio.app/Contents/Info.plist CFBundleShortVersionString
设备 ROMhdc 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 完成握手。这是能力降级,已在文档中明确说明
2ArkTS 禁止无类型对象字面量arkts-no-untyped-obj-literals(6 处)ArkTS 严格模式下 { a: 1 } 这类字面量必须对应显式声明的 interface解法:新建 FormTypes.ets,为所有跨语言结构声明 interface(FormDataEntry / FormBindingPayload / FormEventParams / FormCardInfo / AddFormOptions
3ArkTS 禁止计算属性名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 backgroundColorCustomComponent 内置的 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%') 包裹
6postCardAction 导入方式错误编译报找不到模块 @ohos.arkui.postCardActionpostCardAction 是 ArkUI 的全局声明函数(在 component/common.d.tsdeclare 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); }
8HAR 是 tar 不是 zipunzip flutter_embedding_release.harcannot find zipfile directory想读引擎源码确认接口,用 unzip 解不开。解法:HAR 是 tar 格式,用 tar xzf 解压
9formHost 模块不存在import formHost from '@ohos.app.form.formHost' 编译失败翻阅 SDK 的 .d.ts 目录确认:API 26 公开 SDK 里根本没有 @ohos.app.form.formHost 这个模块。解法:删除删除卡片的系统调用,改为只清理插件侧数据,桌面卡片由用户长按移除(onRemoveForm 会同步清理)
10单测运行器与 Dart SDK 版本不匹配flutter testInvalid 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 已知问题

  1. 无法主动删除桌面卡片 — OpenHarmony 未向三方应用开放 formHost.deleteForm(系统接口,需 ohos.permission.REQUIRE_FORM)。removeForm() 只能清理插件侧数据记录,桌面卡片需用户长按移除。移除时会触发 onRemoveForm 自动同步清理数据。

  2. 加桌需用户手动确认 — 受公开 SDK 限制,加桌通过 formProvider.openFormManager 拉起卡片管理页,需用户点击"添加至桌面"。addForm() 内部等待 formAdded 事件(默认超时 60 秒),超时返回 null;应用可通过 onFormAdded 事件流兜底获取结果。

  3. 卡片与 Flutter 运行时隔离 — 卡片进程无法复用 Flutter 的渲染能力与内存状态,所有要展示的数据必须通过 updateForm 显式下发。这也是为什么卡片上不能直接显示"Flutter 里的某个对象"。

  4. 卡片尺寸适配有限 — 模板针对 2x2 / 2x4 / 4x4 做了自适应,1x1 圆形卡片建议用 FlutterFormCardMini 或自定义 UI。

8.3 未来优化方向

  • 待环境修复后补齐单元测试 — Dart 侧已基于 plugin_platform_interface 抽象,测试用例(test/flutter_ohos_form_test.darttest/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 引擎,用不了 EventChannelcommonEventManager 公共事件 + 主进程订阅转发

9.2 封装层次

插件的价值在于把复杂度收敛到三个层次,应用侧几乎无感:

应用侧只需写 3 个文件
  ├─ EntryFormAbility.ets   → 继承基类,1 行代码
  ├─ WidgetCard.ets         → 用 FlutterFormCard 模板,约 10 行
  └─ module.json5           → 注册 extensionAbility,约 15 行
              │
              ▼
插件承担全部复杂度
  ├─ 双通道通信(MethodChannel + EventChannel)
  ├─ 加桌握手(pending_data + 事件回传)
  ├─ 跨进程数据(preferences + 主动推送)
  ├─ 跨进程事件(commonEventManager)
  └─ 生命周期封装(FormExtensionAbility 基类)

9.3 三条经验

  1. 先读引擎源码,再写代码 — Flutter OH 引擎的 HAR 包里有完整的 .ets 源码和 index.ets 导出清单。动手前花 10 分钟确认可用的接口符号,比写完再排查编译错误高效得多。

  2. 公开 SDK 的能力边界要早探明requestPublishForm / formHost.deleteForm 都不在公开 SDK 里,属于系统接口。如果做到一半才发现,架构就要推翻重来。建议动手前先翻一遍 SDK 的 .d.ts 目录,确认关键 API 是否存在。

  3. 真机验证不可替代 — 第 7 个坑(卡片不刷新)在静态检查和编译阶段完全看不出来updateForm 返回成功、preferences 里数据也是新的、日志一切正常,只有真正看桌面卡片才发现一直显示 --。这类"数据链路通但 UI 不更新"的问题,只能靠真机验证暴露。

9.4 成果

flutter_ohos_form 最终实现 12 个源文件、2109 行代码,在 OpenHarmony 7.0.0(API 26)真机上完成 6 项功能验证全部通过,并配套提供了中英文适配文档、CHANGELOG 与 example 工程。

项目地址https://atomgit.com/oh-flutter/flutter_ohos_form


参考文档

环境:Flutter 3.47.4-ohos-1.0.4 · Dart 3.13.3 · HarmonyOS SDK 26.0.0 · OpenHarmony 7.0.0.106

Logo

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

更多推荐