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 个真实踩坑点,供适配同类库(弹窗、跑马灯、自绘图表等)参考。

目录

  1. 库背景与适配目标
  2. 适配前置:接口规格分析
  3. 平台能力调研:三平台实现对比
  4. 适配形态判断:这是哪种库?
  5. 核心实现:ArkTS 自定义组件 + codegen 胶水
  6. 宿主工程接入:autolinking 与 .har 更新循环
  7. 真机验证实战:dumpLayout + 模拟点击
  8. 踩坑实录(9 连)
  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 的思路梳理对外接口,这是所有后续工作的"契约基线":

接口类型说明
EmojiPopupReact 组件命名导出 + 默认导出
EmojiPopupProps.childrenReact.ReactNode触发区域
EmojiPopupProps.onEmojiSelected(emoji: string) => void选中回调
EmojiPopupProps.closeButton函数组件仅 Android(JS Modal 封装)
EmojiPopupProps.contentContainerStyleStyleProp<ViewStyle>仅 Android(JS Modal 封装)
EmojiPopupViewFabric 原生组件codegenNativeComponent('EmojiPopupView')
事件DirectEventHandler<{ emoji: string }>payload { emoji: string }

关键结论:核心契约 = children + onEmojiSelectedcloseButton/contentContainerStyle 是 Android 专属 JS 封装能力,鸿蒙侧与 iOS 一致忽略即可。


3. 平台能力调研:三平台实现对比

鸿蒙侧没有系统级 emoji 选择面板 API(@ohos.* 无对应能力),这是本次适配最大的平台差异,必须在动手前确认并文档化:

维度iOSAndroidHarmonyOS(本适配)
触发方式点击 EmojiPopupViewImpl(UIView)点击 JS Modal 中的 EmojiPickerView点击 ArkTS 自定义组件
emoji 面板MCEmojiPicker(系统级)androidx.emoji2(系统组件)ArkUI 自实现(Grid + 内置数据源)
事件传递eventEmitter->onEmojiSelectedUIManager dispatchEventemitComponentEvent(tag, 'emojiSelected', {emoji})
原生侧Swift + ObjCKotlinArkTS + 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.jsoncodegenConfig(含 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已实现 createComponentDescriptorProviderscreateComponentJSIBinderByNamecreateEventEmitRequestHandlers——C++ Package 只需继承它

5.2 ArkTS 自定义组件(@Builder + @Component + 手写 Descriptor)

组件实现分三个文件:

① 手写 DescriptorEmojiPopupViewDescriptor.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 接入步骤

  1. 宿主以 file: 依赖安装库,构建适配模块产出 .har,并在宿主 harmony/oh-package.json5 引用:
"@rnoh/react-native-emoji-popup": "file:../node_modules/react-native-emoji-popup/harmony/emoji_popup.har"
  1. 安装 + 自动链接 + 构建:
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 真机验证发现的真实问题

交互验证两次立功,发现两个无法靠编译/构建发现的问题:

  1. 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 双保险。
  2. release bundle 缺库代码(真机 UI 是旧版):HAP 构建安装一切正常,但真机界面没有新功能——解包 HAP 查 bundle.harmony.js,grep 不到库名。根因是 metro 解析不到 file: symlink 依赖(详见坑 9)。

8. 踩坑实录(9 连)

以下 9 个坑全部来自本次适配实测,按"发现顺序"排列;前 4 个偏 TurboModule 通用,后 5 个是 Fabric 组件与构建链路的专属深坑:

#现象修复
1C++ 侧缺 TurboModuleFactoryDelegate运行时找不到 TurboModule实现 FactoryDelegate 返回 ArkTSTurboModule
2TS 类型声明缺失JS 侧类型报错手写 *.d.ts 补齐导出类型
3事件名/模块名不一致JS 收不到事件NAME/EVENT_NAME 与 JS 侧严格一致
4ForEach key 重复日志 Duplicated index: [60,62,68,69](ArkUI 应用错误)数据源去重 + key 用 emoji + index
5codegen 生成的 ETS .ts 不能 importHarCompileArkTSCannot find module;改名 .ets 后报 arkts-no-as-const 等一堆规则错误生成的 .ts 只是类型声明,组件实现手写 DescriptorDescriptor<'X', Props, {}, {}>
6C++ include 路径fatal error: 'RNOH/generated/BaseXxxPackage.h' file not found头文件内用相对路径 generated/RNOH/generated/... 或 CMake 加 generated include dir
7链接 undefined symbolld.lld: undefined symbol: facebook::react::XxxViewComponentNamefile(GLOB ...)file(GLOB_RECURSE ... *.cpp),把 generated 的 .cpp 编进来
8.har 包名后缀RNOHPackagesFactory.ets import @rnoh/react-native-emoji-popup--emoji_popup(带后缀)编译失败harmony/只保留一个 .har(删模块 build/),走固定更新循环
9metro 无法解析 file: 依赖release bundle 缺库代码,真机 UI 是旧版metro.config.jswatchFolders 加库真实目录 + 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.jsonharmony.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_nativeRNOH 框架源码、官方文档(含「自定义组件」开发指南)
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-RNRN 开发相关仓库与技能集(RNOH 主仓所在组织)
RNOH 技能集https://atomgit.com/oh-react-native/rnoh-skills本文适配流程的沉淀地(rnoh-lib-adaptation 等)
RN 开发技能集https://atomgit.com/CPF-RN/skillsrnoh-shared-skill-author 等编写规范
同类适配案例(TurboModule)https://atomgit.com/oh-react-native/react-native-screenshot-aware事件驱动型库的鸿蒙适配参考
本库适配版https://atomgit.com/oh-react-native/react-native-emoji-popupv0.4.0(本文主角)

本文配套仓库:https://atomgit.com/oh-react-native/react-native-emoji-popup(v0.4.0)| 技能集:https://atomgit.com/oh-react-native/rnoh-skills

Logo

开源鸿蒙跨平台开发社区汇聚开发者与厂商,共建“一次开发,多端部署”的开源生态,致力于降低跨端开发门槛,推动万物智联创新。

更多推荐