让 RN 也能往桌面贴卡片:鸿蒙 Form Kit 服务卡片三方库 0 到 1——react-native-ohos-form × RNOH 0.84

标签:React Native · HarmonyOS / OpenHarmony · RNOH(@rnoh/react-native-openharmony 0.84.3)· New Architecture · TurboModule · Form Kit · 服务卡片 · FormExtensionAbility · 跨进程公共事件 · methodMap


React Native鸿蒙官方社区:https://atomgit.com/org/CPF-rn

项目地址:https://atomgit.com/oh-react-native/react-native-ohos-form

0. 背景

RN 生态里,"往桌面放一张卡片"一直是原生平台的专属能力:iOS 走 WidgetKit,Android 走 AppWidgetHost,两边都要写 Swift/Kotlin。鸿蒙侧对应的是 Form Kit(服务卡片 / 桌面万能卡片),其形态和 iOS/Android 又不一样——卡片 UI 由系统进程 com.ohos.formrenderservice 独立渲染,不能渲染 RN 视图,因此这不是一个"把某个原生能力包一层"的常规适配,而是一次架构级设计

本文记录 react-native-ohos-form 从 0 到 1 的完整实现过程:接口与平台能力调研 → 跨进程架构设计 → ArkTS/C++ 编码 → 宿主接入 → 真机验证 → 踩坑与沉淀。参考实现是 Flutter 侧的 flutter_ohos_form(同为服务卡片能力封装,设计思路可借鉴:基类收敛模板代码 + 公共事件打通跨进程)。

适配目标:

  1. RN 侧用几个 Promise API 就能加桌、刷新、查询、监听桌面卡片;

  2. ArkTS 侧提供 FormExtensionAbility 基类与现成卡片 UI,使用方只关心"卡片显示什么";


1. 三平台服务卡片机制对比(决定架构的起点)

做适配前必须先搞清楚:鸿蒙的卡片和 iOS/Android 到底差在哪。

维度iOS(WidgetKit)Android(AppWidget)HarmonyOS(Form Kit)
卡片 UI 渲染进程独立 Extension 进程Launcher 进程 + RemoteViews独立 formrenderservice 进程
UI 技术SwiftUIRemoteViews(受限布局)ArkTS(@Entry + ArkUI)
能否渲染宿主框架视图
加桌入口系统/用户操作系统/用户操作formProvider.openFormManager(API 18+)
数据下发WidgetCenter.reloadTimelinesAppWidgetManager.updateAppWidgetformProvider.updateForm / setFormNextRefreshTime
卡片生命周期回调Timeline ProvideronUpdate 广播FormExtensionAbilityonAddForm/onUpdateForm/onRemoveForm…)
卡片 → 宿主通信widgetURL / Link 深链PendingIntent卡片内 postCardActiononFormEvent
卡片数据载体Timeline EntryBundleformBindingData仅基础类型

三条结论,直接决定了本库的架构:

  1. 卡片 UI 无法渲染 RN 视图 → 卡片 UI 必须用 ArkTS 写,库能提供的是"现成组件 + 基类",而不是"RN 组件桥接"。这也是为什么本库在 ArkTS 侧提供 FormCardView 组件、在宿主侧要求复制一份卡片入口页。
  2. 卡片进程与 RN 应用进程隔离 → 两者不能直接函数调用,必须走跨进程通路。鸿蒙这里可用的手段是 commonEventManager(自定义公共事件),这也是 flutter_ohos_form 的选择。
  3. formBindingData 只接受基础类型 → 复杂对象必须先序列化成 JSON 字符串塞进 data 字段,卡片侧再解析。

2. 平台能力调研(核心决策点)

接下来逐个确认"每个能力在鸿蒙上到底有没有对应 API,三方应用能不能用"。这一步比写代码重要得多——鸿蒙很多 Form API 是系统接口,三方应用根本调不到,如果不先查清就动手,写完才发现无法落地。

调研方法:直接 grep 本地 SDK 的 .d.ts,不依赖记忆与文档。

SDK=/Applications/DevEco-Studio.app/Contents/sdk/default/openharmony/ets/api
grep -nE "^    function [a-zA-Z]+\(" $SDK/@ohos.app.form.formProvider.d.ts

formProvider 的公开方法清单(API 26 SDK):

setFormNextRefreshTime(formId, minute)            ✅ 定时刷新
updateForm(formId, formBindingData)               ✅ 主动刷新
getFormsInfo(filter?)                             ✅ 查询卡片模板信息
getPublishedFormInfoById / getPublishedFormInfos   ✅ 查询已发布卡片
getPublishedRunningFormInfoById / ...Infos         ✅ 查询已添加卡片(API 20+)
openFormManager(want)                             ✅ 拉起卡片管理页(API 18+)
openFormEditAbility(abilityName, formId, ...)      ✅ 打开卡片编辑页
requestOverflow / cancelOverflow / getFormRect     ✅ 动效与尺寸
reloadForms / reloadAllForms                       ✅ 重载卡片
closeFormEditAbility                              ✅

关键发现(本库最重要的两个决策点):

想做的事理想 API现实
代码主动加桌formProvider.requestPublishForm系统接口,三方应用不可用
代码删除桌面卡片formHost.deleteForm系统接口(SDK 里连 formHost 模块都不对三方开放)

于是形成两条能力边界,这是平台限制而非本库缺陷,必须在文档里诚实声明:

  • 加桌:改用 formProvider.openFormManager(want) 拉起卡片管理页,由用户确认后卡片才创建。所以 addForm 只能是异步的——formId 要等 onAddForm 触发、经跨进程事件回传后才知道。
  • 删除removeForm() 只能做"清理库内记录 + reloadAllForms",真正的移除必须由用户在桌面长按卡片 → 移除,该操作会触发 onRemoveFormformRemoved 事件。

其余能力的对应关系:

能力鸿蒙 API结论
主动刷新卡片formProvider.updateForm✅ 完整实现
定时刷新formProvider.setFormNextRefreshTime✅ 完整实现
查询已加桌卡片preferences 自维护记录 + getPublishedRunningFormInfos✅ 库内记录为准(跨进程可见性见 §7 坑 4)
卡片点击回传postCardActiononFormEvent → 公共事件✅ 完整实现
卡片可见性变化onChangeFormVisibility(需 formVisibleNotify: true✅ 完整实现
卡片尺寸变化onSizeChanged✅ 记录同步 + 钩子

3. 架构设计:跨进程是这个库的全部难点

3.1 两个进程,四类数据流

┌─────────────────────────────┐         ┌──────────────────────────────────┐
│  RN 应用进程(app)          │         │  卡片进程(formrenderservice)    │
│                             │         │                                  │
│  RNOH TurboModule           │         │  FormExtensionAbility            │
│  RNOhosFormTurboModule.ets  │         │  RNOhosFormAbility.ets           │
│                             │         │  (继承 FormExtensionAbilityBase)│
│  ┌───────────────────────┐  │         │  ┌────────────────────────────┐  │
│  │ FormStore             │  │         │  │ FormStore(同一份文件)     │  │
│  │ preferences 持久化     │◄─┼─ 共享 ──┼─►│ 读待加桌数据 / 写卡片记录   │  │
│  └───────────────────────┘  │  文件    │  └────────────────────────────┘  │
│                             │         │                                  │
│  ① addForm(data) ───────────┼─暂存───►│  ② onAddForm 读暂存 → 首帧渲染    │
│  ③ updateForm ──────────────┼─IPC────►│  ④ formProvider.updateForm       │
│                             │         │                                  │
│  ⑥ useFormEvent(cb) ◄───────┼─公共事件─┼── ⑤ publishFormEvent            │
└─────────────────────────────┘         └──────────────────────────────────┘

四类数据流:

流向通路用途
JS → 卡片(首帧)preferences 共享文件addForm 暂存 payload,onAddForm 读取渲染首帧
JS → 卡片(刷新)formProvider.updateForm(系统 IPC)主动刷新,最可靠
卡片 → JS(事件)commonEventManager 自定义公共事件formAdded/formRemoved/formClick/formVisibilityChanged
卡片 → JS(状态)preferences 共享文件卡片记录(formId/formName/data

3.2 为什么"首帧"要绕道 preferences

onAddForm(want) 必须同步返回 formBindingData——它是卡片创建时的第一帧数据。但此时用户刚在卡片管理页确认,RN 侧可能早就调过 addForm 了。两者对不上,所以设计成:

  1. addForm 先把 payload 写进 preferencespending_data(按 formName 索引);
  2. onAddForm 同步读出 pending_data[formName],据此构造首帧并返回;
  3. 同时把这条记录写进 form_data,并发出 formAdded 事件(带 formId)。

这样卡片加桌瞬间就有内容,不会先显示一个空白卡片再等刷新。

3.3 为什么事件要绕道公共事件

卡片进程拿不到 RN 的 EventEmitter(跨进程)。可选方案对比:

方案可行性结论
直接调用 RN instance❌ 跨进程不可行
commonEventManager 公共事件✅ 跨进程、系统提供、无需额外权限采用
数据共享(DataShare)✅ 但重过度设计,仅用 preferences 即可
Socket / IPC 自建✅ 但需权限与生命周期管理复杂度高,无必要

公共事件是这里最轻的方案。卡片进程 publish,应用进程 subscribe 后转成 RN device event:

// 卡片进程(FormExtensionAbilityBase 内)
publishFormEvent({ event: FORM_ADDED, formId, formName, data, message: '' });

// 应用进程(TurboModule 内)
subscribeFormEvent((params) => {
  this.ctx.rnInstance.emitDeviceEvent(EVENT_NAME, toFormEvent(params));
});

3.4 对外 API 设计

JS 侧只暴露 8 个 API 方法 + 2 个监听辅助方法 + 1 个 Hook,命名尽量对齐 iOS/Android 生态习惯(addForm/updateForm/removeForm):

方法返回说明
isFormSupported()Promise<boolean>设备/系统是否支持(API ≥ 18)
addForm(options)Promise<string | null>加桌;用户确认后 resolve formId,60s 超时 resolve null
updateForm({formId, data})Promise<boolean>刷新卡片
removeForm(formId)Promise<boolean>清理库内记录 + reloadAllForms(见 §2 边界说明)
getFormIds()Promise<string[]>已加桌卡片 ID
getFormData(formId)Promise<FormCardInfo | null>单张卡片元信息
getAllFormData()Promise<FormCardInfo[]>全部卡片元信息
setFormNextRefreshTime(formId, minutes)Promise<boolean>定时刷新
addFormEventListener(cb) / removeAllFormEventListeners()订阅 / 清理非 Hook 场景

事件侧用 useFormEvent Hook 收敛(内部是 NativeEventEmitter + useEffect 自动清理):

useFormEvent(event => {
  // event: { event, formId, formName, data, message }
  if (event.event === FORM_CLICK) { /* 卡片被点击 */ }
});

设计取舍addForm 返回 string | null 而不是抛错——因为"用户没确认"是正常流程而非异常。同时事件流始终会把 formId 送出来,调用方即使拿到 null 也能通过事件补获。


4. 工程结构:库由四块组成

react-native-ohos-form/
├── src/                                  # JS/TS 侧
│   ├── index.tsx                         # 对外 API + useFormEvent Hook
│   └── NativeRNOhosForm.ts               # TurboModule spec
├── harmony/rn_ohos_form/                 # 鸿蒙 HAR 模块
│   ├── Index.ets                         # 双导出(命名 + default)
│   └── src/main/
│       ├── cpp/                          # C++ 胶水层(autolinking 必需)
│       └── ets/
│           ├── RNOhosFormTurboModule.ets # ★ 应用进程:API + 事件转发
│           ├── RNOhosFormPackage.ets     # RNOHPackage 注册
│           └── form/                     # ★ 卡片侧能力
│               ├── FormExtensionAbilityBase.ets
│               ├── FormStore.ets
│               ├── FormCommonEvent.ets
│               ├── FormBindingUtil.ets
│               ├── FormCardView.ets
│               └── FormTypes.ets / FormConstants.ets / FormLogger.ets
├── templates/                            # 复制到宿主的模板
└── tools/build-har.sh                    # 打包源码式 HAR

一个容易忽略的点:form/ 子目录里的文件属于"卡片进程侧"(由宿主的 FormExtensionAbility 继承使用),而 RNOhosFormTurboModule.ets 属于"应用进程侧"。二者虽然打在同一个 HAR 里,但运行在不同进程中——这就是为什么 FormStore 必须处理跨进程缓存问题(§7 坑 4)。


5. 核心实现

5.1 第一课:HAR 必须是"源码式"包

这是本项目踩得最深的坑,先讲。RNOH autolinking 会生成这样的 autolinking.cmake

function(autolink_libraries target)
    add_subdirectory("${OH_MODULES_DIR}/@rnoh/react-native-ohos-form/src/main/cpp" ./rnoh__react_native_ohos_form)
    ...

也就是说,autolinking 会无条件对每个库执行 add_subdirectory(<har>/src/main/cpp)。这带来两个硬性要求:

  1. 纯 ArkTS 库也必须提供 src/main/cpp(哪怕只有一个空 .cpp),否则 CMake 直接报目录不存在;
  2. HAR 必须包含 src/main/cpp——但 hvigorw assembleHar 的产物只有编译后的 modules.abc + .d.ets不含 native 源码
# assembleHar 的产物(不含 cpp,autolinking 会失败)
$ tar -tzf rn_ohos_form.har
package/Index.d.ets
package/ets/modules.abc
package/ets/sourceMaps.map
package/src/main/ets/RNOhosFormTurboModule.d.ets
...

对比一个能在 RNOH 里正常工作的 HAR(如 react-native-qr),它是源码式的:

$ tar -tzf react_native_qr.har
package/Index.ets                       ← 源码,不是 .d.ets
package/src/main/ets/QrPackage.ets      ← 源码
package/src/main/cpp/CMakeLists.txt     ← native 源码
package/src/main/cpp/ReactNativeQrPackage.h
package/src/main/module.json
package/oh-package.json5                ← 含 metadata.sourceRoots

关键差异在 oh-package.json5metadata

{
  "name": "@rnoh/react-native-ohos-form",
  "main": "Index.ets",
  "metadata": {
    "sourceRoots": ["./src/main"],   // 标记为源码包,让宿主能解析源码与 cpp
    "debug": true
  }
}

所以最终方案是写一个打包脚本 tools/build-har.sh从源码组装 HAR而不是用 assembleHar

PKG_DIR="${STAGE_DIR}/package"
mkdir -p "${PKG_DIR}/src/main/ets" "${PKG_DIR}/src/main/cpp" "${PKG_DIR}/libs" "${PKG_DIR}/hvigor"

cp "${MODULE_DIR}/Index.ets" "${PKG_DIR}/Index.ets"
cp -R "${MODULE_DIR}/src/main/ets/." "${PKG_DIR}/src/main/ets/"   # ArkTS 源码
cp -R "${MODULE_DIR}/src/main/cpp/." "${PKG_DIR}/src/main/cpp/"   # native 源码(关键)
# ... 生成 module.json / oh-package.json5 / BuildProfile.ets / ResourceTable.txt

# COPYFILE_DISABLE=1 阻止 macOS 注入 ._* AppleDouble 垃圾文件
# (它们会被 CMake 的 file(GLOB) 当成 .cpp 捞进编译,直接构建失败)
( cd "${STAGE_DIR}" && COPYFILE_DISABLE=1 tar --exclude='._*' --exclude='.DS_Store' -czf "${OUTPUT_HAR}" package )

还有一个相关约束:harmony/ 目录下只能有一个 .har。因为 autolinking 在多 HAR 时会追加 --<har名> 后缀:

// react-native-harmony-cli/src/autolinking/Autolinking.ts
const suffix = harFilePaths.length > 1
  ? '--' + pathUtils.basename(harFileName, '.har')
  : '';
result.push(createResolvedHar(harPath, ohPackageNameConfig + suffix));

一旦变成 @rnoh/react-native-ohos-form--rn_ohos_form,生成的 RNOHPackagesFactory.ets 就 import 不到,编译报 Cannot find module。我就是因为 assembleHar 的产物留在 build/ 里、同时根目录又有一个手打包的 HAR,被判定成"两个 HAR"而中招。修法:把 build/ 清理掉,只保留根目录的 rn_ohos_form.har

5.2 TurboModule 实现(应用进程侧)

export class RNOhosFormTurboModule extends AnyThreadTurboModule {
  public static readonly NAME = TM_NAME;  // 'RNOhosForm',必须与 JS getEnforcing 一致

  private commonEventSubscriber: commonEventManager.CommonEventSubscriber | null = null;
  private addWaiter: AddFormWaiter | null = null;

  constructor(ctx: AnyThreadTurboModuleContext) {
    super(ctx);
    FormStore.init(ctx.uiAbilityContext);
    this.subscribeCardEvents();     // 订阅卡片进程的公共事件
  }

  isFormSupported(): Promise<boolean> {
    return Promise.resolve(deviceInfo.sdkApiVersion >= MIN_API_FOR_FORM_MANAGER);  // 18
  }

  async addForm(options: AddFormOptions): Promise<string | null> {
    // ① 暂存 payload,供卡片进程 onAddForm 渲染首帧
    FormStore.savePendingData({ formName, dimension, moduleName, data });

    // ② 先挂上等待者,再拉起卡片管理页(避免事件先于等待者到达)
    const formIdPromise = this.waitForAddResult();

    // ③ 拉起卡片管理页,用户确认
    const context = this.ctx.uiAbilityContext as common.UIAbilityContext;
    const parameters: Record<string, Object> = {};
    parameters[formInfo.FormParam.DIMENSION_KEY] = dimension;
    parameters[formInfo.FormParam.NAME_KEY] = formName;
    parameters[formInfo.FormParam.MODULE_NAME_KEY] = moduleName;
    formProvider.openFormManager({ bundleName: context.applicationInfo.name, parameters });

    // ④ 等 formAdded 事件把 formId 送回来(60s 超时 → null)
    return await formIdPromise;
  }
}

两个实现细节值得注意:

  • 顺序很重要:必须先 waitForAddResult() 挂上等待者,再 openFormManager。否则用户手速快时 formAdded 可能先到,等待者后挂,就会白等到超时。
  • 超时即 nullsetTimeout 60s 后 resolve null 并清掉等待者,避免 Promise 永久悬挂。

事件转发部分:

private onCardEvent(params: FormEventParams): void {
  const eventType = params.event ?? '';
  const formId = params.formId ?? '';

  if (eventType === FORM_ADDED && formId.length > 0) {
    this.recordAddedForm(formId, params.formName ?? '');   // 应用进程自己也落库(§7 坑 4)
    this.resolveAddWaiter(formId);                          // 唤醒 addForm
  }
  if (eventType === FORM_REMOVED && formId.length > 0) {
    FormStore.removeRecord(formId);
  }

  this.ctx.rnInstance.emitDeviceEvent(EVENT_NAME, {
    event: eventType, formId,
    formName: params.formName ?? '', data: params.data ?? '', message: params.message ?? '',
  });
}

addListener / removeListeners 按 RNOH 事件契约实现为空方法(事件走 emitDeviceEvent,不走 TurboModule 回调)。

5.3 FormExtensionAbilityBase:把模板代码全部收敛

直接写 FormExtensionAbility 时,每个应用都要重复实现:从 Want 里逐个解析 FormParam、用 preferences 手工维护卡片列表、拼 formBindingData、处理 onUpdateForm 返回值被忽略的坑、异常兜底……

本库把它封装成抽象基类,使用方只实现业务回调:

export abstract class FormExtensionAbilityBase extends FormExtensionAbility {
  /** 卡片首次创建:返回首帧数据(必须实现) */
  protected abstract onFormCreate(record: FormRecord): FormCardData;

  /** 系统触发刷新(定时/updateForm),默认复用 onFormCreate */
  protected onFormUpdate(record: FormRecord): FormCardData {
    return this.onFormCreate(record);
  }

  /** 卡片被移除 / 尺寸变化 / 可见性变化 / 自定义消息,默认空实现 */
  protected onFormRemove(record: FormRecord | undefined): void {}
  protected onFormDimensionChange(record: FormRecord | undefined, dimension: number): void {}
  protected onFormVisibilityChange(status: Record<string, number>): void {}
  protected onFormMessage(formId: string, message: string): void {}

  // ------- 以下为对 FormExtensionAbility 的最终实现,子类无需覆盖 -------

  onAddForm(want: Want): formBindingData.FormBindingData {
    try {
      FormStore.init(this.context);
      const formId = this.readWantParam(want, formInfo.FormParam.IDENTITY_KEY);
      const formName = this.readWantParam(want, formInfo.FormParam.NAME_KEY);
      const dimension = this.readWantDimension(want);
      const moduleName = this.readWantParam(want, formInfo.FormParam.MODULE_NAME_KEY);

      // onAddForm 必须同步返回,所以首帧数据来自 addForm 暂存的 pending payload
      const pending = FormStore.getPendingData(formName);
      const record: FormRecord = {
        formId, formName: formName || (pending?.formName ?? ''),
        dimension: dimension || (pending?.dimension ?? DEFAULT_DIMENSION),
        moduleName: moduleName || (pending?.moduleName ?? DEFAULT_MODULE_NAME),
        data: pending?.data ?? EMPTY_DATA,
        refreshCount: 0,
      };
      FormStore.saveRecord(record);

      // 通知 RN 侧:卡片已加桌(携带 formId,唤醒 addForm 的等待者)
      publishFormEvent({ event: FORM_ADDED, formId: record.formId,
                         formName: record.formName, data: record.data, message: '' });

      return FormStore.buildBindingData(record, this.onFormCreate(record));
    } catch (error) {
      // 绝不崩溃卡片进程:异常时退回一张默认卡片
      FormLogger.error(`onAddForm failed: ${JSON.stringify(error)}`);
      return FormStore.buildBindingData(fallbackRecord(), FormCardDataBuilder.create().build());
    }
  }
}

onUpdateForm 里有一个必须记住的坑

onUpdateForm(formId: string, wantParams?: Record<string, Object>): void {
  const record = FormStore.getRecord(formId);
  const count = FormStore.increaseRefreshCount(formId);
  const data = this.onFormUpdate(FormStore.getRecord(formId) ?? record);

  // ⚠️ 真机实测:onUpdateForm 的返回值会被系统【忽略】(与 onAddForm 不同),
  //    必须显式调用 updateForm 才能刷新卡片,否则卡片一直显示旧内容。
  FormStore.pushToCard(current, next)
    .then(() => FormLogger.info(`onUpdateForm pushed, formId=${formId}`))
    .catch(e => FormLogger.error(`onUpdateForm push failed: ${JSON.stringify(e)}`));
}

onFormEvent 则负责把卡片内消息分流:既发出 formClick 事件,又按命令前缀分发内建指令:

onFormEvent(formId: string, message: string): void {
  publishFormEvent({ event: FORM_CLICK, formId, formName: ..., data: '', message });

  // 消息格式:<command>[:payload]
  const separator = message.indexOf(':');
  const command = separator >= 0 ? message.substring(0, separator) : message;
  if (command === FORM_MESSAGE_REFRESH) {
    this.handleRefreshRequest(formId);        // 立即刷新
  } else if (command === FORM_MESSAGE_REFRESH_LATER) {
    formProvider.setFormNextRefreshTime(formId, 1);  // 1 分钟后由系统刷新
  } else {
    this.onFormMessage(formId, message);      // 交给业务自定义
  }
}

5.4 FormStore:为什么必须用同步接口

FormExtensionAbility 的生命周期非常短——官方文档说明空闲约 10s 就会被系统回收。如果用异步 preferences API,回调返回后进程可能已被回收,写入丢失。因此全部改用同步接口:

static init(context: common.Context): void {
  const options: preferences.Options = { name: FORM_PREFERENCES_NAME };
  FormStore.store = preferences.getPreferencesSync(context, options);
}

private static writeRecords(records: FormRecord[]): void {
  store.putSync(KEY_FORM_DATA, JSON.stringify(records));
  store.putSync(KEY_FORM_IDS, JSON.stringify(records.map(r => r.formId)));
  store.flush();          // 确保落盘
}

存储结构:

Key内容
form_dataFormRecord[]formId/formName/dimension/moduleName/data/refreshCount
form_ids卡片 ID 数组(便于快速读取)
pending_data{ [formName]: PendingFormRecord },加桌流程中暂存的 payload

5.5 事件通路:公共事件桥

// 卡片进程 publish
export function publishFormEvent(params: FormEventParams): void {
  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 ?? '';

  commonEventManager.publish(FORM_EVENT_COMMON_NAME,
    { parameters } as commonEventManager.CommonEventPublishData, (err) => { /* log */ });
}

// 应用进程 subscribe
export async function subscribeFormEvent(cb: FormCommonEventCallback)
    : Promise<commonEventManager.CommonEventSubscriber | null> {
  const subscriber = await commonEventManager.createSubscriber({
    events: [FORM_EVENT_COMMON_NAME],
  });
  commonEventManager.subscribe(subscriber, (err, data) => {
    cb(toFormEventParams(data.parameters ?? {}));
  });
  return subscriber;   // 需保存,__onDestroy__ 时 unsubscribe,避免泄漏
}

5.6 卡片 UI:为什么必须用 @LocalStorageProp

卡片 UI 由库提供现成组件(FormCardView 完整版 / FormCardMiniView 精简版),宿主只需一个薄薄的 @Entry 页面:

@Entry
@Component
struct RNOhosFormCard {
  @LocalStorageProp('data') data: string = '{}';
  @LocalStorageProp('refreshCount') refreshCount: number = 0;

  build() {
    Stack() {
      FormCardView({ rawData: this.data, refreshCount: this.refreshCount })
    }.width('100%').height('100%')
  }
}

两个约束:

  • @LocalStorageProp 的 key 必须与下发字段名一致。库下发的绑定数据固定为 data / refreshCount / formName / updateTime,key 写错卡片就不会更新。
  • 卡片刷新只更新 LocalStorage,不重建组件,所以组件内必须 @Watch 重新解析 JSON,否则界面不刷新:
@Component
export struct FormCardView {
  @Prop @Watch('onRawDataChange') rawData: string = '{}';
  @State cardData: FormCardData = {...};

  onRawDataChange(_newValue: string): void {
    this.cardData = parseFormCardData(this.rawData);
  }
}

点击回传用 postCardAction

postCardAction(this, {
  action: 'message',
  params: { message: FORM_MESSAGE_CLICK, params: this.rawData },
});

5.7 C++ 胶水层:两件事,少一件就挂

src/main/cpp 里需要三样东西:CMakeLists.txtempty.cpp(凑一个编译单元)、以及 Package 头文件。CMakeLists.txt 的 target 名必须与 autolinking 推导一致:

file(GLOB rn_ohos_form_SRC CONFIGURE_DEPENDS *.cpp)
add_library(rnoh__react_native_ohos_form SHARED ${rn_ohos_form_SRC})
target_include_directories(rnoh__react_native_ohos_form PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})
target_link_libraries(rnoh__react_native_ohos_form PUBLIC rnoh)

target 命名规则:npm 包名转 snake 并加 rnoh__ 前缀;含 scope 时形如 rnoh__<scope>__<name>

头文件里则是本库最关键的一段 C++——方法表:

/**
 * 方法表是必须的:裸 ArkTSTurboModule 不暴露任何方法,
 * 少了它 JS 调用一律报 "TypeError: undefined is not a function"。
 */
class RNOhosFormArkTSTurboModule : public ArkTSTurboModule {
 public:
  RNOhosFormArkTSTurboModule(const ArkTSTurboModule::Context ctx, const std::string name)
      : rnoh::ArkTSTurboModule(ctx, name) {
    methodMap_ = {
        ARK_METHOD_METADATA(addListener, 1),
        ARK_METHOD_METADATA(removeListeners, 1),
        ARK_ASYNC_METHOD_METADATA(isFormSupported, 0),
        ARK_ASYNC_METHOD_METADATA(addForm, 1),
        ARK_ASYNC_METHOD_METADATA(updateForm, 1),
        ARK_ASYNC_METHOD_METADATA(removeForm, 1),
        ARK_ASYNC_METHOD_METADATA(getFormIds, 0),
        ARK_ASYNC_METHOD_METADATA(getFormData, 1),
        ARK_ASYNC_METHOD_METADATA(getAllFormData, 0),
        ARK_ASYNC_METHOD_METADATA(setFormNextRefreshTime, 2),
    };
  }
};

class RNOhosFormTurboModuleFactoryDelegate : public TurboModuleFactoryDelegate {
 public:
  SharedTurboModule createTurboModule(Context ctx, const std::string& name) const override {
    if (name == "RNOhosForm") {
      return std::make_shared<RNOhosFormArkTSTurboModule>(ctx, name);
    }
    return nullptr;
  }
};

第二件事是 TurboModuleFactoryDelegate。官方 codegen 生成的 Base 包会自动提供它,手动适配必须自己实现,否则运行期报 Couldn't find Turbo Module on the ArkTs side ... on the CPP side

注意宏的选择:返回 Promise 的方法用 ARK_ASYNC_METHOD_METADATA,同步方法用 ARK_METHOD_METADATA(RNOH 没有同步专用宏,同步方法也用这个)。


6. 宿主接入:四步

RNOH084Demo 为宿主(RNOH 0.84.3 + RN 0.84.1)。

6.1 装 JS 依赖并接入 autolinking

库的 package.json 已声明 autolinking 配置:

{
  "harmony": {
    "autolinking": {
      "ohPackageName": "@rnoh/react-native-ohos-form",
      "etsPackageClassName": "RNOhosFormPackage"
    }
  }
}

宿主 harmony/entry/oh-package.json5 增加依赖后执行 ohpm install --all,会自动生成 RNOHPackagesFactory.etsautolinking.cmake

{
  "dependencies": {
    "@rnoh/react-native-ohos-form": "file:../../node_modules/react-native-ohos-form/harmony/rn_ohos_form.har"
  }
}

生成结果(自动,无需手写):

// entry/src/main/ets/RNOHPackagesFactory.ets
import RNOhosFormPackage from '@rnoh/react-native-ohos-form';
export function createRNOHPackages(ctx: RNPackageContext): RNOHPackage[] {
  return [ /* ... */ new RNOhosFormPackage(ctx) /* ... */ ];
}

注意 import 名是 @rnoh/react-native-ohos-form(oh-package 名),不是 npm 包名——我在宿主侧一度写成 npm 名,编译报 Failed to resolve OhmUrl

6.2 注册 form 扩展能力

entry/src/main/module.json5

{
  "module": {
    "extensionAbilities": [
      {
        "name": "RNOhosFormAbility",
        "srcEntry": "./ets/formability/RNOhosFormAbility.ets",
        "label": "$string:form_demo_display_name",
        "description": "$string:form_demo_desc",
        "type": "form",
        "exported": true,
        "metadata": [
          { "name": "ohos.extension.form", "resource": "$profile:form_config" }
        ]
      }
    ]
  }
}

6.3 复制模板三件套

模板(库内 templates/复制到宿主
form_config.jsonentry/src/main/resources/base/profile/form_config.json
RNOhosFormCard.etsentry/src/main/ets/pages/RNOhosFormCard.ets
RNOhosFormAbility.etsentry/src/main/ets/formability/RNOhosFormAbility.ets

为什么卡片页不能放在 HAR 里form_config.jsonsrc 是相对声明了 form 扩展的模块解析的,而 HAR 只是编译期依赖、不参与卡片配置解析。所以卡片 @Entry 页面必须落在宿主的 entry 模块内。

6.4 业务代码:两侧各一小段

卡片侧(ArkTS,只关心"显示什么"):

export default class RNOhosFormAbility extends FormExtensionAbilityBase {
  protected onFormCreate(record: FormRecord): FormCardData {
    return RNOhosFormAbility.buildCardData(record);
  }

  private static buildCardData(record: FormRecord): FormCardData {
    const builder = FormCardDataBuilder.create()
      .setUpdatedAtText(FormTimeUtil.now())
      .setRefreshCount(record.refreshCount);
    try {
      const parsed = JSON.parse(record.data) as Record<string, string>;
      builder.setTitle(parsed['title'] ?? 'RN 服务卡片');
      builder.setContent(parsed['content'] ?? record.data);
    } catch (e) {
      builder.setTitle('RN 服务卡片').setContent(record.data);
    }
    return builder.build();
  }
}

RN 侧(TS):见 §3.4 的 API 表,测试页 FormTestApp.tsx 里做了 6 个按钮覆盖全部能力。


7. 踩坑实录(4 个,全部在真机日志里复现过)

坑 1:C++ 胶水层没写 methodMap_,JS 调所有方法都 “undefined is not a function”(最深)

  • 现象TM created: RNOhosForm 注册成功、FormStore initialized 也打印了,但一点「检查支持」就报:

    [form-test] isFormSupported error: TypeError: undefined is not a function
    
  • 根因:C++ 侧返回的是裸 ArkTSTurboModulestd::make_shared<ArkTSTurboModule>(ctx, name)),它不暴露任何方法。方法可见性完全由 methodMap_ 决定。

  • 修复:定义子类 RNOhosFormArkTSTurboModule,在构造函数里填 methodMap_(见 §5.7)。

  • 经验TM created 只代表注册成功,不代表方法可用。方法型库排查第一顺位永远是 methodMap_

坑 2:assembleHar 的产物不含 src/main/cpp,autolinking 直接构建失败

  • 现象:宿主构建报 CMake 找不到 oh_modules/@rnoh/react-native-ohos-form/src/main/cpp
  • 根因autolinking.cmake无条件 add_subdirectory(<har>/src/main/cpp),而 hvigorw assembleHar 产出的是编译包(modules.abc + .d.ets),不含 native 源码。
  • 修复:写 tools/build-har.sh 从源码组装"源码式 HAR"(含 src/main/cpp + 源 .ets + metadata.sourceRoots)。
  • 经验:RNOH 的 HAR 要的是"源码包"而非"编译包"。判断标准:解包后能看到 src/main/cpp/CMakeLists.txtIndex.ets(而不是 Index.d.ets)。

坑 3:harmony/ 下两个 HAR → 包名被追加 --rn_ohos_form 后缀

  • 现象:编译报 Cannot find module '@rnoh/react-native-ohos-form--rn_ohos_form'
  • 根因:autolinking 在多 HAR 时按 ohPackageName + '--' + har文件名 命名。我当时既有 build/ 里的 assembleHar 产物,又有根目录手打包的 HAR,被判定成两个。
  • 修复rm -rf harmony/rn_ohos_form/build,只保留 harmony/rn_ohos_form.har.gitignore 里忽略 harmony/**/build/ 并显式 !harmony/*.har 保留根 HAR。
  • 经验harmony/ 下只能有一个 .har。打包产物别留在 autolinking 的扫描路径里。

坑 4:ArkData preferences 按进程/VM 实例缓存 → getFormIds() 返回 [](最隐蔽)

  • 现象formAdded 事件收到了、日志里 saveRecord formId=xxx, total=1 也打印了,但紧接着 getFormIds() 返回 []

    # 日志:写入确实成功了
    saveRecord formId=2136455555, total=2
    recordAddedForm formId=2136455555, formName=rn_ohos_form_demo
    # 但查询是空的
    [form-test] formIds: []
    
  • 根因preferences按进程(乃至按 ArkTS VM 实例)缓存的。写入发生在卡片进程(formrenderservice)/ worker 线程 TM 实例,读取发生在应用进程 / 主线程 TM 实例,缓存不共享。磁盘上有数据,但读取方的内存缓存是旧的。

  • 修复(两步):

    1. 查询前先丢弃缓存并重开存储:

      static reload(context: common.Context): void {
        const options: preferences.Options = { name: FORM_PREFERENCES_NAME };
        preferences.removePreferencesFromCacheSync(context, options);  // 关键
        FormStore.store = undefined;
        FormStore.init(context);
      }
      
    2. 应用进程在收到 formAdded自己也落库recordAddedForm),把 addForm 暂存的 payload 重放成一条记录,不依赖卡片进程的写入。

  • 修复后formIds: ["1153404951"]

  • 经验:跨进程共享 preferences 时,“写入成功"不等于"对方能读到”。这类问题的排查手段是同时看两边的日志时间戳,确认不是写入顺序问题后再怀疑缓存。


8. 真机验证(HarmonyOS 6.0.0 模拟器,API 26)

验证环境:HarmonyOS 6.0.0 模拟器(Pura X View,API 26)+ RNOH 0.84.3 + RN 0.84.1,宿主 RNOH084Demo,隔离测试页 FormTestApp

# 构建 + 安装 + 启动隔离测试页
hvigorw --mode module -p product=default -p module=entry@default assembleHap --no-daemon
hdc install -r entry/build/default/outputs/default/entry-default-signed.hap
hdc shell aa start -b com.rnoh084.demo -a EntryAbility --ps rnAppKey FormTestApp

# 观察日志
hdc shell hilog -x | grep -E 'RNOhosForm|form-test|formrenderservice'

8.1 TurboModule 注册

Creating Turbo Module: RNOhosForm
FormStore initialized
TM created: RNOhosForm
subscribeFormEvent success

8.2 加桌 + 首帧渲染

点击「addForm 加桌」→ 拉起卡片管理页 → 用户确认 → 桌面出现卡片:

# 卡片进程渲染(系统 formrenderservice)
Render form,bundleName=com.rnoh084.demo,abilityName=RNOhosFormAbility,
  formName=rn_ohos_form_demo,moduleName=entry,formId=1546810281
InitializeForm: ./ets/pages/RNOhosFormCard
Initialize startUrl: ./ets/pages/RNOhosFormCard

# 卡片记录 + 事件回传
publishFormEvent success: formAdded
[form-test] event=formAdded formId=1546810281 message=
[form-test] addForm 返回 formId=1546810281

卡片实际内容(uitest dumpLayout 抓到的真实文本节点):

'RN 服务卡片'                      ← 标题(RN 下发)
'RN'                             ← 角标
'来自 React Native 的数据 #5'      ← 正文(RN 下发)
'刷新 0'                          ← 刷新计数
'05:40:33'                       ← 更新时间
'刷新'  '点击查看详情'              ← 卡片内按钮

8.3 刷新与查询

# updateForm:主动刷新
updateForm pushed, formId=1153404951
[form-test] updateForm formId=1153404951 ok=true

# getFormIds / getAllFormData:跨进程读取(坑 4 修复后)
[form-test] formIds: ["1153404951"]

# 卡片内点击 → formClick 回传
[form-test] event=formClick formId=1153404951 message=cardClick

# 卡片被移除(用户在桌面长按移除)
removeRecord formId=2136455555, remaining=2
[form-test] event=formRemoved formId=2136455555 message=

8.4 验证结果断言

验证项结果
HAR 构建 / 宿主 HAP 构建✅ 通过
TurboModule 注册Creating Turbo Module: RNOhosForm + TM created: RNOhosForm
Couldn't find Turbo Module / JS TypeError
isFormSupportedtrue
addForm 加桌✅ 桌面出现卡片,resolve 出 formId
卡片首帧渲染✅ 显示 RN 下发的标题/正文/计数/时间
formAdded / formRemoved✅ 携带 formId 回传
updateForm 刷新updateForm pushedok=true
getFormIds / getAllFormData✅ 返回已加桌卡片
进程存活、无崩溃

image-20260920070758073

9. 成果与沉淀

  1. 可运行的三方库oh-react-native/react-native-ohos-formv1.0.0,签名提交 + 签名标签 + Release,HAR 作为 Release 附件);

  2. 对外能力:JS 侧 8 个 API 方法 + 2 个监听辅助方法 + useFormEvent Hook;ArkTS 侧 FormExtensionAbilityBase 基类 + FormStore + FormCommonEvent + FormCardView / FormCardMiniViewtemplates/ 提供可复制的卡片三件套;

  3. 双语文档README.OpenHarmony.md / README.OpenHarmony_CN.md,含三平台能力对比、跨进程架构图、完整 API 表、能力边界声明(requestPublishForm / deleteForm 系统接口限制)与验证清单;

  4. 技能沉淀(已回填记忆/技能集):

    • RNOH HAR 必须是源码式包(含 src/main/cpp),assembleHar 产物不可直接用于 autolinking;
    • harmony/只能有一个 .har,否则包名被追加后缀导致 import 失败;
    • 手动适配必须自实现 TurboModuleFactoryDelegate + methodMap_,否则分别是"找不到 TM"和"undefined is not a function";
    • ArkData preferences 按进程/VM 实例缓存,跨进程读写需 removePreferencesFromCacheSync 重开 + 写入方各自落库;
    • 鸿蒙 Form 的系统接口边界requestPublishFormformHost.deleteForm)决定了"加桌必须用户确认、删除必须用户操作",这类能力差异必须写进文档而不是硬凑。

    image-20260920070811556

与 flutter_ohos_form 的对照

维度flutter_ohos_formreact-native-ohos-form
通信底座MethodChannel + EventChannelTurboModule + device event
跨进程事件自定义公共事件自定义公共事件(同思路)
模板收敛FlutterFormExtensionAbility 基类FormExtensionAbilityBase 基类(同思路)
卡片 UIFlutterFormCard 组件 + 模板FormCardView 组件 + 模板(同思路)
独有难点HAR 源码式打包、methodMap_、C++ 类名推导、preferences 跨进程缓存

可见跨进程这件事在两个框架下是同一套解法(公共事件 + preferences),差别只在于对上层框架的桥接方式。这也说明:适配鸿蒙服务卡片时,真正需要设计的是进程间数据流,而不是框架 API 的映射。

Logo

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

更多推荐