RNOH适配-从0到1-react-native-ohos-form
让 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(同为服务卡片能力封装,设计思路可借鉴:基类收敛模板代码 + 公共事件打通跨进程)。
适配目标:
-
RN 侧用几个 Promise API 就能加桌、刷新、查询、监听桌面卡片;
-
ArkTS 侧提供
FormExtensionAbility基类与现成卡片 UI,使用方只关心"卡片显示什么";
1. 三平台服务卡片机制对比(决定架构的起点)
做适配前必须先搞清楚:鸿蒙的卡片和 iOS/Android 到底差在哪。
| 维度 | iOS(WidgetKit) | Android(AppWidget) | HarmonyOS(Form Kit) |
|---|---|---|---|
| 卡片 UI 渲染进程 | 独立 Extension 进程 | Launcher 进程 + RemoteViews | 独立 formrenderservice 进程 |
| UI 技术 | SwiftUI | RemoteViews(受限布局) | ArkTS(@Entry + ArkUI) |
| 能否渲染宿主框架视图 | ❌ | ❌ | ❌ |
| 加桌入口 | 系统/用户操作 | 系统/用户操作 | formProvider.openFormManager(API 18+) |
| 数据下发 | WidgetCenter.reloadTimelines | AppWidgetManager.updateAppWidget | formProvider.updateForm / setFormNextRefreshTime |
| 卡片生命周期回调 | Timeline Provider | onUpdate 广播 | FormExtensionAbility(onAddForm/onUpdateForm/onRemoveForm…) |
| 卡片 → 宿主通信 | widgetURL / Link 深链 | PendingIntent | 卡片内 postCardAction → onFormEvent |
| 卡片数据载体 | Timeline Entry | Bundle | formBindingData(仅基础类型) |
三条结论,直接决定了本库的架构:
- 卡片 UI 无法渲染 RN 视图 → 卡片 UI 必须用 ArkTS 写,库能提供的是"现成组件 + 基类",而不是"RN 组件桥接"。这也是为什么本库在 ArkTS 侧提供
FormCardView组件、在宿主侧要求复制一份卡片入口页。 - 卡片进程与 RN 应用进程隔离 → 两者不能直接函数调用,必须走跨进程通路。鸿蒙这里可用的手段是
commonEventManager(自定义公共事件),这也是 flutter_ohos_form 的选择。 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",真正的移除必须由用户在桌面长按卡片 → 移除,该操作会触发onRemoveForm与formRemoved事件。
其余能力的对应关系:
| 能力 | 鸿蒙 API | 结论 |
|---|---|---|
| 主动刷新卡片 | formProvider.updateForm | ✅ 完整实现 |
| 定时刷新 | formProvider.setFormNextRefreshTime | ✅ 完整实现 |
| 查询已加桌卡片 | preferences 自维护记录 + getPublishedRunningFormInfos | ✅ 库内记录为准(跨进程可见性见 §7 坑 4) |
| 卡片点击回传 | postCardAction → onFormEvent → 公共事件 | ✅ 完整实现 |
| 卡片可见性变化 | 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 了。两者对不上,所以设计成:
addForm先把 payload 写进preferences的pending_data(按formName索引);onAddForm同步读出pending_data[formName],据此构造首帧并返回;- 同时把这条记录写进
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)。这带来两个硬性要求:
- 纯 ArkTS 库也必须提供
src/main/cpp(哪怕只有一个空.cpp),否则 CMake 直接报目录不存在; - 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.json5 的 metadata:
{
"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可能先到,等待者后挂,就会白等到超时。 - 超时即
null:setTimeout60s 后 resolvenull并清掉等待者,避免 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_data | FormRecord[](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.txt、empty.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.ets 与 autolinking.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.json | entry/src/main/resources/base/profile/form_config.json |
RNOhosFormCard.ets | entry/src/main/ets/pages/RNOhosFormCard.ets |
RNOhosFormAbility.ets | entry/src/main/ets/formability/RNOhosFormAbility.ets |
为什么卡片页不能放在 HAR 里:form_config.json 的 src 是相对声明了 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++ 侧返回的是裸
ArkTSTurboModule(std::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.txt和Index.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 实例,缓存不共享。磁盘上有数据,但读取方的内存缓存是旧的。 -
修复(两步):
-
查询前先丢弃缓存并重开存储:
static reload(context: common.Context): void { const options: preferences.Options = { name: FORM_PREFERENCES_NAME }; preferences.removePreferencesFromCacheSync(context, options); // 关键 FormStore.store = undefined; FormStore.init(context); } -
应用进程在收到
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 | ✅ |
isFormSupported | ✅ true |
addForm 加桌 | ✅ 桌面出现卡片,resolve 出 formId |
| 卡片首帧渲染 | ✅ 显示 RN 下发的标题/正文/计数/时间 |
formAdded / formRemoved | ✅ 携带 formId 回传 |
updateForm 刷新 | ✅ updateForm pushed,ok=true |
getFormIds / getAllFormData | ✅ 返回已加桌卡片 |
| 进程存活、无崩溃 | ✅ |

9. 成果与沉淀
-
可运行的三方库:
oh-react-native/react-native-ohos-form(v1.0.0,签名提交 + 签名标签 + Release,HAR 作为 Release 附件); -
对外能力:JS 侧 8 个 API 方法 + 2 个监听辅助方法 +
useFormEventHook;ArkTS 侧FormExtensionAbilityBase基类 +FormStore+FormCommonEvent+FormCardView/FormCardMiniView;templates/提供可复制的卡片三件套; -
双语文档:
README.OpenHarmony.md/README.OpenHarmony_CN.md,含三平台能力对比、跨进程架构图、完整 API 表、能力边界声明(requestPublishForm/deleteForm系统接口限制)与验证清单; -
技能沉淀(已回填记忆/技能集):
- RNOH HAR 必须是源码式包(含
src/main/cpp),assembleHar产物不可直接用于 autolinking; harmony/下只能有一个.har,否则包名被追加后缀导致 import 失败;- 手动适配必须自实现
TurboModuleFactoryDelegate+methodMap_,否则分别是"找不到 TM"和"undefined is not a function"; - ArkData
preferences按进程/VM 实例缓存,跨进程读写需removePreferencesFromCacheSync重开 + 写入方各自落库; - 鸿蒙 Form 的系统接口边界(
requestPublishForm、formHost.deleteForm)决定了"加桌必须用户确认、删除必须用户操作",这类能力差异必须写进文档而不是硬凑。

- RNOH HAR 必须是源码式包(含
与 flutter_ohos_form 的对照
| 维度 | flutter_ohos_form | react-native-ohos-form |
|---|---|---|
| 通信底座 | MethodChannel + EventChannel | TurboModule + device event |
| 跨进程事件 | 自定义公共事件 | 自定义公共事件(同思路) |
| 模板收敛 | FlutterFormExtensionAbility 基类 | FormExtensionAbilityBase 基类(同思路) |
| 卡片 UI | FlutterFormCard 组件 + 模板 | FormCardView 组件 + 模板(同思路) |
| 独有难点 | — | HAR 源码式打包、methodMap_、C++ 类名推导、preferences 跨进程缓存 |
可见跨进程这件事在两个框架下是同一套解法(公共事件 + preferences),差别只在于对上层框架的桥接方式。这也说明:适配鸿蒙服务卡片时,真正需要设计的是进程间数据流,而不是框架 API 的映射。
更多推荐



所有评论(0)