ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

React Native鸿蒙适配实践:鸿组件桥接开发与排坑指南

React Native鸿蒙适配实践:鸿组件桥接开发与排坑指南

最近我们团队接了个新需求:把一套已经跑了大半年的 React Native 商城项目,移植一部分核心业务到鸿蒙设备上。项目规划的时候,很多人的第一反应是“RN 不是跨平台吗?直接打包跑不就完了?”等真动手才发现,鸿蒙生态虽然支持 RN 运行环境,但从 JS 业务层到系统原生能力的调用,中间隔着一层“鸿组件”(这里指 HarmonyOS 原生组件)的适配桥。也就是说,你光会 React 和 JS 不够,还得懂一点 ArkTS、JSBridge 的通信机制,以及 RN 在非安卓、非 iOS 平台上的第三方平台适配逻辑。

这篇文章就把我在实际项目里摸出来的经验整理一遍,从为什么不能直接把 iOS/Android 的 RN 组件拿过来用,到环境搭建、原生鸿组件怎么写、JS 侧怎么桥接、最后怎么调试排坑,全流程讲透。适合两类人看:一是团队里负责跨端基建、想评估鸿蒙适配成本的架构同学,二是马上要上手写第一个鸿组件、但不知道怎么下手的业务开发。

1. 为什么说“鸿组件”不是普通组件:架构定位先行

1.1 从 RN 到鸿蒙的桥接模型

在 iOS 和 Android 上,RN 是官方一等的支持对象,JS 引擎、原生渲染树、事件分发,整套链路被苹果和谷歌的底层系统约束得比较统一。到了鸿蒙这边,情况变了,RN 跑在上面更像是“借宿”:JS 代码由鸿蒙侧的 JavaScript 引擎加载执行,渲染层虽然也有统一的渲染树,但很多原生能力(相机、传感器、分布式文件、系统弹窗等)走的是 HarmonyOS 自家的 API 通道。

这种“双轨制”带来的直接后果是,RN 官方的 Native Module 系统并不能直接操作鸿蒙能力。你写一个CameraModule,在 Android 上拿的是安卓的 Camera API,在鸿蒙上不会自动变成鸿蒙的相机接口。所以需要在鸿蒙原生侧开发一类特殊组件,我们工程里统称“鸿组件”:

  • 它本身是 ArkTS 写的原生组件,运行在鸿蒙的 UI 线程里;
  • 对外暴露一套 JSON 序列化的属性、事件、方法接口;
  • 让 RN 的 JS 层通过桥接协议找到它、更新它的属性、接收它的事件。

换句话说,鸿组件是「RN JS 层」与「HarmonyOS 系统层」之间的翻译官。

1.2 组件拆分的边界:哪些东西该下沉到 ArkTS 层

很多人一开始容易犯一个错误:恨不得把整个页面都写成原生组件,觉得这样性能最好。我见过一个同学把列表、下拉刷新、空态视图全封装成鸿组件,结果维护成本爆炸,JS 侧想改个样式还得重新编译。

正确的拆分原则,我的经验是看三件事:

  • 是否涉及系统独占能力(相机、定位、分布式流转、安全芯片调用);
  • 是否有高频、大数据量的本地计算(图片处理、音视频编解码);
  • 是否与鸿蒙的分布式特性强相关(多设备协同、跨端迁移)。

满足任意一条,才考虑下沉成鸿组件。普通的 UI 布局、业务交互逻辑,尽量留在 RN 的 JS 层。因为这个界限直接决定了你的桥接层要写多厚、后续调试多麻烦。

在我做的一个扫码组件里,核心诉求是扫码识别的底层 SDK 只能调用鸿蒙系统的能力,UI 设计稿却要求完全跟 RN 现有风格统一。所以我的方案是:原生侧只管“识别+回调结果”,摄像头预览的画面用一个占位区域由原生渲染,其余所有遮罩、提示文案、按钮都是 RN 层画的。这样既保证了业务灵活性,又把系统能力隔离在原生侧,后续出问题排查范围会小很多。

2. 环境与依赖准备:把地基打牢

2.1 必要工具链清单

这部分是很多人第一步就卡住的地方,因为环境配置跟 iOS/Android 不太一样,光装两个 IDE 还不够,还得理解“设备侧 SDK”和“工程侧 SDK”的关系。我整理了一份我当时用的工具链清单,版本不一定要跟我完全一致,但思路可以照抄:

工具/依赖作用注意事项
DevEco Studio鸿蒙应用开发 IDE,用来写 ArkTS 鸿组件及打包建议装最新稳定版,旧版本对 RN 的第三方平台支持不全
HarmonyOS SDK系统 API 库,提供 ArkTS 组件和系统能力接口API 版本要跟目标设备系统版本匹配,不能只看 IDE 默认值
Node.js / npmRN 侧依赖管理建议用 18+ LTS 版本,避免老版本兼容性问题
React Native 第三方平台适配包让 RN 能识别鸿蒙为合法目标平台社区或公司内部维护,需要手动指定平台标识
鸿蒙真机或模拟器运行和调试建议至少备一台 API 9 以上的真机,模拟器踩坑概率低

这里特别强调一下 API 级别的匹配问题。鸿蒙系统版本跟芯片、设备厂商的关系比较紧密,同一个 API 接口在不同版本上行为可能不完全一致。我见过有人用的 SDK API 12,但真机系统只有 API 10,结果扫码组件在 preview 阶段就崩。后来统一把 targetSdkVersion 和真机系统版本对齐到 API 11,问题就消失了。

2.2 工程目录规划

工程结构方面,强烈建议在项目根目录下单独开一个harmony子目录,把鸿组件相关代码跟 RN 的src目录隔离。这么做的好处有两个:

  • 编译时可以独立控制原生模块的构建范围;
  • 出问题排查时,能清晰判断问题出在“鸿组件侧”还是“RN 业务侧”。

我最终采用的目录结构大概是这样的:

project-root/ ├── src/ # RN 业务代码 │ ├── components/ │ └── screens/ ├── harmony/ # 鸿组件工程目录 │ ├── entry/src/main/ │ │ ├── ets/ │ │ │ ├── components/ # ArkTS 原生组件 │ │ │ └── pages/ │ │ └── resources/ │ └── build-profile.json5 ├── package.json └── react-native.config.js # RN 原生模块注册配置

这个结构的核心思想是:harmony目录里既能被 DevEco Studio 单独识别为一个鸿蒙模块工程,又能通过项目根目录的构建脚本纳入 RN 的整体打包流程。我当时花了半天时间把目录结构和构建脚本调顺,后面写代码的效率明显提升。

3. 从零实现一个鸿组件:完整链路拆解

3.1 原生侧:用 ArkTS 写一个真正的鸿组件

这一步需要真正用到鸿蒙开发基础了。我拿一个最简单的例子讲:实现一个“获取设备唯一标识”的鸿组件,你可以把它理解成一个没有 UI 的纯逻辑模块,专门给 RN 业务层提供设备信息。这比 UI 组件更能把通信链路讲清楚。

在鸿组件侧,首先要定义一个 ArkTS 类,继承自原生组件基类,然后实现生命周期和对外方法。核心代码如下:

// DeviceInfoComponent.ets import { Component } from '@ohos.ace_component'; export class DeviceInfoComponent extends Component { private deviceId: string = ''; aboutToAppear() { // 组件在 UI 线程上创建时触发,这里获取鸿蒙系统能力 this.deviceId = this.getSystemDeviceId(); } getSystemDeviceId(): string { // 调用系统 API 获取设备唯一标识 // 不同 API 版本方法名有差异,建议封装成统一方法 return this.context.deviceInfo.getUniqueId(); } // 暴露给 RN 层调用的方法 getDeviceInfo(): Record<string, string> { return { deviceId: this.deviceId, platform: 'HarmonyOS', apiLevel: this.context.deviceInfo.getApiVersion() }; } }

这段代码里的Component基类非常重要,它负责把组件挂载进鸿蒙的渲染树,同时提供与 RN JSBridge 通信的基本能力。你不需要太关心它内部的实现,但一定要知道:只有继承了它,你的组件才能被 RN 侧发现和调用。

如果是要写带 UI 的组件,比如我那个扫码组件,就多一个渲染函数:

build() { Column() { // 这里是原生渲染的内容 // 一般放 CameraPreview 之类的系统组件 } .width('100%') .height('100%') }

UI 组件的生命周期比纯逻辑组件复杂一点,需要在aboutToReuse、onPageShow等钩子里处理页面切换和组件复用的情况。我踩过的一个坑是:扫完码返回上一页再进来,摄像头没有重新打开,后来排查发现是组件复用时没有在aboutToReuse里重置 Camera 状态。这个知识点属于鸿蒙原生开发的细节,不懂的话真想不到。

3.2 JS 侧封装:让 RN 业务侧无感调用

鸿组件写完了,接下来需要让 RN 的 JS 层能调到它。这里不直接推荐大家手写复杂的 JSBridge 通信,因为每个适配包暴露的方式不一样,但核心模式很像,都是“注册模块 + 调用方法”的模式。

我在项目中封装了一层 JS API,让业务侧调用体验跟普通 Promise 函数差不多:

// DeviceInfo.ts import { NativeModules } from 'react-native'; const { DeviceInfoBridge } = NativeModules; export async function fetchDeviceInfo(): Promise<DeviceInfoResult> { try { const info = await DeviceInfoBridge.getDeviceInfo(); return { deviceId: info.deviceId, platform: info.platform, apiLevel: Number(info.apiLevel), }; } catch (err) { // 降级处理:如果鸿组件不可用,返回空设备信息 return { deviceId: '', platform: 'unknown', apiLevel: 0, }; } } interface DeviceInfoResult { deviceId: string; platform: string; apiLevel: number; }

这段代码表面上看是在调用DeviceInfoBridge,但关键在于:NativeModules.DeviceInfoBridge这个名字,必须跟鸿组件侧注册时的模块名完全一致。如果鸿组件侧叫DeviceInfoBridge,JS 侧写成DeviceInfoModule,就会得到一个 undefined 错误,而且这个错误不会在编译期暴露,典型运行时才崩。

有 UI 的组件,JS 侧封装又多一层,需要处理属性同步和事件回调。比如扫码组件:

// ScannerView.tsx import React, { useRef, useEffect } from 'react'; import { requireNativeComponent, View, StyleSheet } from 'react-native'; const NativeScanner = requireNativeComponent('ScanModuleView'); type Props = { onResult: (data: string) => void; onError: (msg: string) => void; }; export const ScannerView = (props: Props) => { const scannerRef = useRef(null); // 事件绑定必须通过 onNativeEvent 之类的统一下发,不能在原生侧自定义事件名 const handleNativeEvent = (event: any) => { if (event.nativeEvent.type === 'scanResult') { props.onResult(event.nativeEvent.data); } else if (event.nativeEvent.type === 'scanError') { props.onError(event.nativeEvent.message); } }; return ( <View style={styles.container}> <NativeScanner style={styles.nativeView} onNativeEvent={handleNativeEvent} /> </View> ); };

这里要注意 React Native 的组件视图必须套一层View再嵌套到业务页面里,直接把这个原生标签放到页面根部容易出现测量错误。这是我实际遇到的一个坑:第一次集成时直接把<NativeScanner>放到了页面最外层,结果真机上预览区域高度少了一截,后来追加了外层 View 并显式设置 flex 才正常。

3.3 注册与构建配置

鸿组件写好了,JS 封装写好了,中间还差最关键的一步:注册。RN 侧工程需要在原生模块注册表里声明鸿组件,鸿组件侧也需要在模块配置里把自己暴露出来。

RN 侧的配置,通常在项目的react-native.config.js里加上:

module.exports = { dependency: { platforms: { harmony: { sourceDir: './harmony/entry/src/main/ets', components: [ 'DeviceInfoBridge', 'ScanModuleView' ] } } } };

鸿组件侧的暴露,不同适配方案的写法不太一样,但核心步骤都是:把组件类添加到一个导出的声明文件里,同时注册为“可在 RN 层访问的原生模块”。

构建打包这一步,我最开始一直试图用同一个命令行把所有平台都打了,后面发现行不通。鸿蒙的打包流程依赖 DevEco Studio 的构建链,最稳定的做法是:

  1. 先在 DevEco Studio 里手动打包鸿组件的产物(生成 .har 或 .hap 文件);
  2. 再把产物路径配置到 RN 打包脚本里,让最终的应用产物包含鸿组件原生库;
  3. 用 RN 命令打包 JS bundle,放进鸿蒙应用的资源目录。

这个流程看着麻烦,但一旦跑通,后续每次改代码只需要增量处理对应模块,不用整套重新来。我当时把这些步骤写成了一个组合 npm 脚本,一条命令完成鸿组件编译 + JS bundle 打包,极大节省了 CI 时间。

4. 调试排错:我在真实项目里踩过的坑

4.1 日志定位方法论

跨端调试最痛苦的点在于:错误可能出现在 JS 层,也可能出现在原生层,还可能出在桥接层。如果日志打得不全,一个简单的问题可能会耗费你半天时间。

我在项目中总结了一套三层日志法:

  • JS 层日志:用console.log打业务侧数据,确认 JS 是否收到正确的属性/事件;
  • 桥接层日志:在鸿组件的生命周期、getDeviceInfo等方法里加日志,确认 JS 的调用是否到达原生侧;
  • 系统层日志:用 DevEco Studio 的 log 工具,确认系统 API 是否执行成功、是否有崩溃栈。

一个实用的技巧:在桥接层每个公开方法的第一行打印入参参数,在返回前打印出参参数。这样一旦出现问题,你能立刻判断是哪一层断链。

比如我遇到过一种情况:JS 侧调fetchDeviceInfo()返回了空对象,React Native 侧没有任何报错,看起来整个调用是成功的。后来在鸿组件原生侧打日志,发现getUniqId()这个系统方法在 API 10 以下根本不存在,异常被桥接层自动吞掉,返回了一个不完全的对象。如果当时没有在桥接层打日志,这个问题还会一直触发但要排查很久。

4.2 常见问题速查表

这里整理一份我实际开发中遇到的典型问题,对应的排查手段不一定能覆盖所有场景,但至少能帮你少绕弯:

问题现象可能原因排查与解决
JS 侧调用原生方法返回 undefined模块未注册或注册名拼写不一致检查 react-native.config.js 与鸿组件导出的模块名
事件回调多次触发事件通道未注销或多次 bind确认页面卸载时有 unbind 逻辑
原生组件不显示在页面上组件视图未被 requireNativeComponent 正确引用检查自定义组件标签是否放到 View 内部
UI 组件尺寸异常未设置 flex 或缺少外层 View给容器加 flex:1,并在原生层设置宽高 100%
系统 API 无法调用API 版本与真机系统不匹配核对 API 级别,必要时做降级分支
扫码画面黑屏相机权限未授权或组件复用时未释放前一个实例检查权限申请时机,在 aboutToReuse 重置相机状态
鸿组件改动无效打包时仍引用旧的 .har 文件强制重新编译鸿组件产物并更新引用路径

这张表里的问题,几乎每一个我都花了至少半天才定位到根因。印象最深的是 “事件回调多次触发” 那个问题,原因是我们在页面切换时没有销毁上一个扫码页对应的原生组件,导致同一个事件通道绑定了多个实例。这个问题光看 JS 代码根本发现不了,因为它不是 JS 层的逻辑问题,而是鸿组件生命周期管理的问题。

5. 性能与工程化建议:别等上线了才发现

5.1 渲染与通信开销

鸿组件跟普通 RN 组件的本质区别,是每一次属性更新、事件回调都要跨过 JS Bridge 这层“旋转门”。虽然我听开发过的同事说过,性能和稳定性已经做得不错了,但你要是把高频事件一股脑全从鸿组件往 JS 层传,照样会有卡顿。

举一个真实的优化案例:我们一开始做扫码识别预览时,把相机每帧的图像数据都回调给 JS 层做实时计算,结果识别速度没有提高多少,JS 线程倒是被打满,页面掉帧严重。后来改成三帧回调一次,并且只回调“已识别到内容”的有效帧,性能立刻好了很多。

这类优化的核心原则是:尽量减少跨层通信次数,尽量把数据聚合在原生侧处理完了再一次性返回。比如设备信息这种只读数据,可以在组件创建时缓存一次,后续 JS 直接从常量池取,不再发起桥接调用。

5.2 多端复用与 CI

既然是跨平台项目,不能只服务鸿蒙一个端。我当时的做法是:在 JS 侧把鸿组件封装成带平台判断的模块,iOS/Android 继续走 RN 的官方组件,只在Platform.OS === 'harmony'时走鸿组件的封装。

import { Platform } from 'react-native'; import { ScannerView as HarmonyScanner } from './harmony/ScannerView'; import { ScannerView as DefaultScanner } from './default/ScannerView'; export const ScannerView = Platform.OS === 'harmony' ? HarmonyScanner : DefaultScanner;

这样业务层调用的入口完全一致,后续新增鸿组件时不需要改动页面的业务代码,只改这个路由层就可以。

CI 方面,我可以分享一个教训:不要把鸿蒙构建和 iOS/Android 构建混在同一个 job 里。鸿蒙的构建链比较吃系统资源,而且依赖 DevEco Studio 的 SDK 环境变量,混排经常因为环境串扰导致莫名其妙的失败。我把鸿蒙构建拆成独立流水线,只在有新鸿组件代码合并时触发,稳定了一个多月没再出过问题。

5.3 版本兼容管理

最后是版本管理,这可能是整个集成里最不起眼但最容易翻车的部分。鸿蒙的 API 升级节奏不算慢,不同系统版本的接口行为有差异。我的建议是把鸿组件侧的 API 调用统一封装成策略类,每个接口按系统版本分发到不同的实现分支,而不是在一个类里写满 if-else。

还有一种情况是 RN 版本更新后,鸿组件桥的原理可能变化,导致旧模块在新架构下失效。我经历过一次 RN 升级后,所有鸿组件事件回调都没有响应。排查后发现是新的桥接架构用了一套新的名称引用规则,要改注册声明才能兼容。如果你们团队长期维护这个项目,建议专门盯住“RN 版本升级”这条线,别让版本脱节成为隐患。

最后的体会

说实话,在 React Native 项目里接入鸿组件,并不是一条被主流文档铺平的路,很多细节都需要边踩坑边摸索。但如果能先把桥接模型想清楚、把目录结构和版本策略规划好,后面的开发效率不会比写普通 RN 组件慢多少。我个人的体会是:越是跨端混合的场景,越要克制把太多逻辑下沉到原生侧。鸿组件应该是系统的“能力插件”,而不是业务“万能兜底”。最后再分享一个小技巧:每次改动鸿组件后,别急着跑完整应用,先在 DevEco Studio 里单独预览一下原生模块的效果,确认正常再集成到 RN 里,这样能省掉大量来回打包的时间。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进