React Native 三方库鸿蒙适配实战:react-native-emoji-popup(Fabric 自定义组件)从 0 到 1
React Native 三方库鸿蒙适配实战:react-native-emoji-popup(Fabric 自定义组件)从 0 到 1
配套仓库:https://atomgit.com/oh-react-native/react-native-emoji-popup(v0.4.0,RNOH 0.84)
引言
随着 HarmonyOS / OpenHarmony 生态的发展,React Native 开发者社区通过 RNOH(React Native OpenHarmony) 把海量 RN 三方库迁移到鸿蒙平台。但三方库的鸿蒙适配并非"照着 TurboModule 模板抄"那么简单——Fabric 自定义组件(自绘原生视图)的适配路径与事件驱动型 TurboModule 完全不同,且踩坑点高度隐蔽(编译能过、构建能成、真机却是旧 UI)。
本文以 react-native-emoji-popup(表情选择弹窗组件)为例,完整复盘一个 Fabric 自定义 View 组件从接口分析、平台能力调研、代码实现、宿主接入到真机验证的鸿蒙适配全过程,并沉淀 9 个真实踩坑点,供适配同类库(弹窗、跑马灯、自绘图表等)参考。
目录
- 库背景与适配目标
- 适配前置:接口规格分析
- 平台能力调研:三平台实现对比
- 适配形态判断:这是哪种库?
- 核心实现:ArkTS 自定义组件 + codegen 胶水
- 宿主工程接入:autolinking 与 .har 更新循环
- 真机验证实战:dumpLayout + 模拟点击
- 踩坑实录(9 连)
- 成果与沉淀
1. 库背景与适配目标
react-native-emoji-popup(v0.3.3)是一个表情选择弹窗组件:用 <EmojiPopup> 包裹触发区域(children),点击后弹出原生 emoji 选择面板,选中 emoji 后通过 onEmojiSelected 回调把 emoji 字符串回传给 JS 侧。
- iOS 实现:第三方 Swift 库
MCEmojiPicker弹出系统级 emoji 面板 - Android 实现:
androidx.emoji2.emojipicker.EmojiPickerView(Fabric 自定义 View 内嵌系统 emoji 面板) - 代码生成配置:
codegenConfig.name = "RNEmojiPopupViewSpec"、type = "all"、组件名EmojiPopupView
适配目标:在鸿蒙侧提供与 iOS/Android 一致的公开契约(组件名、事件名、payload、回调时序),JS 层零改动。
2. 适配前置:接口规格分析
动手前先用 rnoh-lib-interface-analyzer 的思路梳理对外接口,这是所有后续工作的"契约基线":
| 接口 | 类型 | 说明 |
|---|---|---|
EmojiPopup | React 组件 | 命名导出 + 默认导出 |
EmojiPopupProps.children | React.ReactNode | 触发区域 |
EmojiPopupProps.onEmojiSelected | (emoji: string) => void | 选中回调 |
EmojiPopupProps.closeButton | 函数组件 | 仅 Android(JS Modal 封装) |
EmojiPopupProps.contentContainerStyle | StyleProp<ViewStyle> | 仅 Android(JS Modal 封装) |
EmojiPopupView | Fabric 原生组件 | codegenNativeComponent('EmojiPopupView') |
| 事件 | DirectEventHandler<{ emoji: string }> | payload { emoji: string } |
关键结论:核心契约 = children + onEmojiSelected;closeButton/contentContainerStyle 是 Android 专属 JS 封装能力,鸿蒙侧与 iOS 一致忽略即可。
3. 平台能力调研:三平台实现对比
鸿蒙侧没有系统级 emoji 选择面板 API(@ohos.* 无对应能力),这是本次适配最大的平台差异,必须在动手前确认并文档化:
| 维度 | iOS | Android | HarmonyOS(本适配) |
|---|---|---|---|
| 触发方式 | 点击 EmojiPopupViewImpl(UIView) | 点击 JS Modal 中的 EmojiPickerView | 点击 ArkTS 自定义组件 |
| emoji 面板 | MCEmojiPicker(系统级) | androidx.emoji2(系统组件) | ArkUI 自实现(Grid + 内置数据源) |
| 事件传递 | eventEmitter->onEmojiSelected | UIManager dispatchEvent | emitComponentEvent(tag, 'emojiSelected', {emoji}) |
| 原生侧 | Swift + ObjC | Kotlin | ArkTS + C++ codegen 胶水 |
closeButton | ✗ | ✓ | ✗(与 iOS 一致) |
contentContainerStyle | ✗ | ✓ | ✗(与 iOS 一致) |
调研结论:功能上"点击弹出面板 → 选中 emoji 回传"是跨平台一致的语义;面板内容(表情集规模、分类导航)存在平台差异,需要自实现并声明语义差异。这个判断决定了后续"ArkUI 自实现 + 数据源可扩展"的设计方向。
4. 适配形态判断:这是哪种库?
RNOH 只支持 New Architecture,但"要不要写 harmony/ 原生模块"取决于库的架构,先分类再动手:
| 库的架构特征 | 鸿蒙适配形态 |
|---|---|
| 纯 TurboModule(事件/方法驱动) | harmony/ 原生模块:ArkTS TurboModule + C++ 胶水 |
| Fabric 自定义视图(自绘组件、独立 props/命令) | harmony/ 原生模块:组件描述符 + ArkTS 组件实例(本文主角) |
| 依赖 worklets / 拦截 RN 核心组件 | JS 平台变体(*.harmony.tsx)回退,不建原生模块 |
判断方法:查 package.json 的 codegenConfig(含 fabric 声明 + codegenNativeComponent)→ 本库是 Fabric 自定义 View,走"组件描述符 + ArkTS 组件"路径,与 TurboModule 模板完全不同。
5. 核心实现:ArkTS 自定义组件 + codegen 胶水
5.1 用 codegen-lib-harmony 生成 C++ 胶水
在库根目录执行(react-native-harmony-cli ≥ 0.0.27):
react-native codegen-lib-harmony \
--no-safety-check \
--npm-package-name react-native-emoji-popup \
--generate-type fabric \
--arkts-components-spec-paths ./src \
--cpp-output-path ../harmony/emoji_popup/src/main/cpp/generated \
--ets-output-path ../harmony/emoji_popup/src/main/ets/generated
生成物(C++ 侧全部自动完成):
react/renderer/components/react_native_emoji_popup/:ComponentDescriptors / EventEmitters / Props / ShadowNodes / States(.h + .cpp)RNOH/generated/BaseReactNativeEmojiPopupPackage.h:已实现createComponentDescriptorProviders、createComponentJSIBinderByName、createEventEmitRequestHandlers——C++ Package 只需继承它
5.2 ArkTS 自定义组件(@Builder + @Component + 手写 Descriptor)
组件实现分三个文件:
① 手写 Descriptor(EmojiPopupViewDescriptor.ets)——注意:不要 import codegen 生成的 ETS .ts 文件(见坑 5):
import { Descriptor as ComponentDescriptor, ViewBaseProps } from '@rnoh/react-native-openharmony/ts';
export interface EmojiPopupViewProps extends ViewBaseProps {}
export type EmojiPopupViewDescriptor = ComponentDescriptor<'EmojiPopupView', EmojiPopupViewProps, {}, {}>;
② 组件本体(EmojiPopupView.ets)——@Builder 入口 + @Component struct:
@Builder
export function buildEmojiPopupView(ctx: ComponentBuilderContext) {
EmojiPopupViewComponent({ ctx: ctx.rnComponentContext, tag: ctx.tag }); // rnohContext 已废弃
}
@Component
export struct EmojiPopupViewComponent {
public static readonly NAME = 'EmojiPopupView';
public ctx!: RNComponentContext;
public tag: number = 0;
@State descriptor: EmojiPopupViewDescriptor = {} as EmojiPopupViewDescriptor;
@State isPickerVisible: boolean = false;
private cleanUpCallbacks: (() => void)[] = [];
aboutToAppear() {
this.descriptor = this.ctx.descriptorRegistry.getDescriptor<EmojiPopupViewDescriptor>(this.tag);
this.cleanUpCallbacks.push(this.ctx.descriptorRegistry.subscribeToDescriptorChanges(this.tag, () => {
this.descriptor = this.ctx.descriptorRegistry.getDescriptor<EmojiPopupViewDescriptor>(this.tag);
}));
}
aboutToDisappear() { this.cleanUpCallbacks.forEach(cb => cb()); }
build() {
Stack() {
// 渲染 RN children(触发区域)
if (this.descriptor.childrenTags.length > 0) {
this.ctx.wrappedRNComponentBuilder.builder(this.ctx, this.descriptor.childrenTags[0]);
}
// emoji 面板(点击触发,Grid 网格)
if (this.isPickerVisible) {
Column() {
Grid() {
ForEach(EMOJI_LIST, (emoji: string) => {
GridItem() {
Text(emoji).fontSize(28).onClick(() => {
this.onEmojiSelected(emoji);
this.isPickerVisible = false;
})
}
}, (emoji: string, index: number) => emoji + index) // 坑 4:key 必须唯一
}
.columnsTemplate('1fr 1fr 1fr 1fr 1fr 1fr')
Button('Close').onClick(() => { this.isPickerVisible = false; })
}
.width('100%').height('100%').backgroundColor(Color.White)
}
}
.width('100%').height('100%')
.onClick(() => { this.isPickerVisible = true; })
}
private onEmojiSelected(emoji: string) {
// 事件名用 RN 内部事件名(无 on 前缀),JSIBinder 已注册 topEmojiSelected → onEmojiSelected
this.ctx.rnInstance.emitComponentEvent(this.tag, 'emojiSelected', { emoji });
}
}
③ ArkTS Package 注册(ReactNativeEmojiPopupPackage.ets)——Fabric 组件走 createWrappedCustomRNComponentBuilderByComponentNameMap,不是 TurboModule 的 factory map:
export class ReactNativeEmojiPopupPackage extends RNOHPackage {
override createWrappedCustomRNComponentBuilderByComponentNameMap(): Map<string, WrappedBuilder<[ComponentBuilderContext]>> {
return new Map([['EmojiPopupView', wrapBuilder(buildEmojiPopupView)]]); // WrappedBuilder 为全局类型,勿 import
}
}
5.3 C++ 侧:继承 codegen 生成的 Base 包
src/main/cpp/ReactNativeEmojiPopupPackage.h——Base 包已提供 descriptor/JSIBinder/事件路由,无需手写 delegate:
#pragma once
#include "generated/RNOH/generated/BaseReactNativeEmojiPopupPackage.h" // 坑 6:相对路径
namespace rnoh {
class ReactNativeEmojiPopupPackage : public BaseReactNativeEmojiPopupPackage {
using Super = BaseReactNativeEmojiPopupPackage;
public:
using Super::Super;
};
} // namespace rnoh
5.4 CMakeLists:必须递归包含 generated
src/main/cpp/CMakeLists.txt——file(GLOB ...) 只匹配根目录会漏掉 generated 下的 .cpp(坑 7):
file(GLOB_RECURSE emoji_popup_SRC CONFIGURE_DEPENDS *.cpp) # 含 generated/ 的 Props/EventEmitters/ShadowNodes .cpp
add_library(rnoh__react_native_emoji_popup SHARED ${emoji_popup_SRC})
target_include_directories(rnoh__react_native_emoji_popup PUBLIC
${CMAKE_CURRENT_SOURCE_DIR}
${CMAKE_CURRENT_SOURCE_DIR}/generated) # 坑 6:否则找不到 RNOH/generated/ 头文件
target_link_libraries(rnoh__react_native_emoji_popup PUBLIC rnoh)
5.5 库根配置:autolinking
// package.json
{
"files": ["src", "lib", "android", "ios", "cpp", "harmony"],
"harmony": {
"autolinking": {
"ohPackageName": "@rnoh/react-native-emoji-popup",
"etsPackageClassName": "ReactNativeEmojiPopupPackage"
}
}
}
6. 宿主工程接入:autolinking 与 .har 更新循环
6.1 接入步骤
- 宿主以
file:依赖安装库,构建适配模块产出.har,并在宿主harmony/oh-package.json5引用:
"@rnoh/react-native-emoji-popup": "file:../node_modules/react-native-emoji-popup/harmony/emoji_popup.har"
- 安装 + 自动链接 + 构建:
ohpm install --all # 安装 ohpm 依赖
npx react-native link-harmony # 生成 RNOHPackagesFactory / autolinking.cmake
hvigorw --mode module -p product=default assembleHap --no-daemon
注意:RNOH hvigor 插件在构建时会自动重跑 link-harmony,包名按
harmony/下.har数量动态计算——这正是坑 8 的根源。
6.2 .har 更新循环(每次改模块后必走)
# 1. 宿主 harmony 下构建模块产出 .har
hvigorw --mode module -p product=default -p module=emoji_popup@default assembleHar --no-daemon
# 2. 拷贝到 harmony/ 根(保持单一 .har!)
cp emoji_popup/build/default/outputs/default/emoji_popup.har ../harmony/emoji_popup.har
# 3. 删除模块 build 产物
rm -rf emoji_popup/build emoji_popup/.hvigor
# 4. 宿主重新安装 + 构建
ohpm install --all
hvigorw --mode module -p product=default assembleHap --no-daemon
.gitignore 相应忽略构建产物(保留根 .har):
.hvigor/
oh_modules/
oh-package-lock.json5
**/BuildProfile.ets
!harmony/emoji_popup.har
7. 真机验证实战:dumpLayout + 模拟点击
无 GUI 环境下做交互级验证,核心手段是 uitest dumpLayout(UI 树)+ uinput(模拟触摸),配合 hilog 日志——这比截图更精确(能拿到每个节点的文本与坐标):
7.1 定位按钮
hdc shell uitest dumpLayout # 生成 /data/local/tmp/layout_*.json
hdc shell "cat <layout文件>" > ui.json
# 解析 json 找到 'Open Emoji Picker' 按钮的 bounds 坐标
7.2 模拟点击验证面板弹出
hdc shell "uinput -T -d 630 1585" # touch down
hdc shell "uinput -T -u 630 1585" # touch up
hdc shell uitest dumpLayout # 再次 dump,应出现 emoji Grid + Close
7.3 验证回调链路
点击面板中的 emoji 后 dump:面板关闭、TextInput 的 value 从 🫡 变为 😀——证明 emitComponentEvent('emojiSelected') → onEmojiSelected → setEmoji 全链路生效。同时 hilog 确认无崩溃、无应用级 ERROR。
7.4 真机验证发现的真实问题
交互验证两次立功,发现两个无法靠编译/构建发现的问题:
- ForEach key 冲突(ArkUI 应用错误):日志报
FIX THIS APPLICATION ERROR: ... Ids generated by the ForEach id gen function must be unique. Duplicated index: [60,62,68,69]——EMOJI_LIST有 4 个重复 emoji。修复:去重 + ForEach key 改为emoji + index双保险。 - release bundle 缺库代码(真机 UI 是旧版):HAP 构建安装一切正常,但真机界面没有新功能——解包 HAP 查
bundle.harmony.js,grep 不到库名。根因是 metro 解析不到file:symlink 依赖(详见坑 9)。
8. 踩坑实录(9 连)
以下 9 个坑全部来自本次适配实测,按"发现顺序"排列;前 4 个偏 TurboModule 通用,后 5 个是 Fabric 组件与构建链路的专属深坑:
| # | 坑 | 现象 | 修复 |
|---|---|---|---|
| 1 | C++ 侧缺 TurboModuleFactoryDelegate | 运行时找不到 TurboModule | 实现 FactoryDelegate 返回 ArkTSTurboModule |
| 2 | TS 类型声明缺失 | JS 侧类型报错 | 手写 *.d.ts 补齐导出类型 |
| 3 | 事件名/模块名不一致 | JS 收不到事件 | NAME/EVENT_NAME 与 JS 侧严格一致 |
| 4 | ForEach key 重复 | 日志 Duplicated index: [60,62,68,69](ArkUI 应用错误) | 数据源去重 + key 用 emoji + index |
| 5 | codegen 生成的 ETS .ts 不能 import | HarCompileArkTS 报 Cannot find module;改名 .ets 后报 arkts-no-as-const 等一堆规则错误 | 生成的 .ts 只是类型声明,组件实现手写 Descriptor(Descriptor<'X', Props, {}, {}>) |
| 6 | C++ include 路径 | fatal error: 'RNOH/generated/BaseXxxPackage.h' file not found | 头文件内用相对路径 generated/RNOH/generated/... 或 CMake 加 generated include dir |
| 7 | 链接 undefined symbol | ld.lld: undefined symbol: facebook::react::XxxViewComponentName | file(GLOB ...) 改 file(GLOB_RECURSE ... *.cpp),把 generated 的 .cpp 编进来 |
| 8 | .har 包名后缀 | RNOHPackagesFactory.ets import @rnoh/react-native-emoji-popup--emoji_popup(带后缀)编译失败 | harmony/ 下只保留一个 .har(删模块 build/),走固定更新循环 |
| 9 | metro 无法解析 file: 依赖 | release bundle 缺库代码,真机 UI 是旧版 | metro.config.js 的 watchFolders 加库真实目录 + resolver nodeModulesPaths/extraNodeModules |
其中坑 4/5/6/7/8/9 在功能与构建链路层面都具有很强的共性,适配任何 Fabric 自定义组件库几乎都会命中,建议直接对照检查。
9. 成果与沉淀
9.1 适配产物
| 产物 | 说明 |
|---|---|
harmony/emoji_popup/ 模块 | codegen C++ 胶水 + ArkTS 组件 + C++ Package(24 文件) |
harmony/emoji_popup.har | 预构建 HAR(autolinking 直接引用) |
package.json | harmony.autolinking 配置 |
README.OpenHarmony.md / _CN.md | 双语适配文档(含能力差异声明) |
| 接口规格说明 / 代码检查报告 | 适配前后置文档 |
| 远程发布 | oh-react-native/react-native-emoji-popup v0.4.0 + Release |
9.2 验证记录
- 构建:
assembleHar+ 宿主assembleHap(release)通过 - 真机:HarmonyOS 6.0.0(API 20)——安装/启动/点击链路全通过(面板弹出 → 选中 emoji →
onEmojiSelected回调生效) - 日志:ForEach key 冲突修复后无应用级 ERROR,进程稳定无崩溃
9.3 经验沉淀(回馈社区)
本次适配的完整流程与踩坑实录已沉淀到 oh-react-native/rnoh-skills 技能集:
rnoh-lib-adaptation:新增「步骤 2b:Fabric 自定义组件(ArkTS View 组件)适配」完整流程references/EXAMPLES.md:新增案例三(react-native-emoji-popup)+ 坑 5-9 实录references/TEMPLATE.md:新增 Fabric 组件完整代码模板与 .har 更新循环
9.4 结语
鸿蒙生态的 RN 三方库适配,最深的坑往往不在"怎么写原生代码",而在"编译与构建链路的隐形假设"——生成的 .ts 不能被 ArkTS 编译、CMake 递归编译、单 .har 约束、metro symlink 解析……这些坑单看文档很难预料,只有真机验证才会显形。希望本文的 9 连坑清单能帮你把同类库的适配周期从"周"压缩到"天"。
9.5 相关组织与链接
| 组织/仓库 | 链接 | 说明 |
|---|---|---|
| RNOH 官方主仓(react-native-openharmony) | https://atomgit.com/CPF-RN/ohos_react_native | RNOH 框架源码、官方文档(含「自定义组件」开发指南) |
| RNOH 官方文档站 | https://react-native-ohos.tiktokv.com/cn/ | RNOH 在线文档(中文) |
| oh-react-native 组织 | https://atomgit.com/oh-react-native | 鸿蒙适配三方库 + RNOH 技能集组织 |
| CPF-RN 组织 | https://atomgit.com/CPF-RN | RN 开发相关仓库与技能集(RNOH 主仓所在组织) |
| RNOH 技能集 | https://atomgit.com/oh-react-native/rnoh-skills | 本文适配流程的沉淀地(rnoh-lib-adaptation 等) |
| RN 开发技能集 | https://atomgit.com/CPF-RN/skills | rnoh-shared-skill-author 等编写规范 |
| 同类适配案例(TurboModule) | https://atomgit.com/oh-react-native/react-native-screenshot-aware | 事件驱动型库的鸿蒙适配参考 |
| 本库适配版 | https://atomgit.com/oh-react-native/react-native-emoji-popup | v0.4.0(本文主角) |
本文配套仓库:https://atomgit.com/oh-react-native/react-native-emoji-popup(v0.4.0)| 技能集:https://atomgit.com/oh-react-native/rnoh-skills
更多推荐


所有评论(0)