去年团队接到一个需求:App要上鸿蒙设备,同时还得调用几个系统级能力。当时摆在面前的三条路都让我们头疼——用ArkTS把现有业务重写一遍,工程量大到不敢想;把RN代码整体迁到别的跨端框架,等于推翻重来;最后剩下的折中方案,是让React Native和鸿蒙原生组件共存,把需要系统能力的部分拆出来做成鸿组件(也就是鸿蒙组件),其余业务继续跑RN。
真正做下来才发现,这个"折中方案"的门道比预想多得多。如果你也是RN团队,接下来要适配HarmonyOS,或者正在纠结"鸿蒙开发"这摊子事怎么落地,这篇东西应该能帮你省掉不少弯路。我会把从架构原理到环境搭建、从最小Demo到埋坑解决的全过程拆开讲,有些内容是API文档里不会写的。
1. 为什么非要在RN里写鸿蒙组件
1.1 你迟早会遇到的跨端现实问题
先说说我为什么坚持这个方案。现在不少团队的App都是React Native为主体,一套代码跑Android和iOS,效率确实高。但鸿蒙设备出来后,问题来了:RN官方并不直接支持HarmonyOS,它靠的是社区和厂商做的适配层。如果团队里没有人熟悉鸿蒙开发,要把整个RN工程迁移过去,几乎等于重新做一个App。
更好的处理方式,是把"需要鸿蒙原生能力"的部分拆成原生组件,嵌入到RN工程里。业务页面继续用RN写,凡是碰到系统API——比如分布式文件、设备信息、传感器、系统设置——就通过鸿蒙组件去调。前端团队不用全员学ArkTS,只需要少数人懂原生封装,其他同学依然写JS/TS业务。这个思路和当年在RN里接Android/iOS原生模块一模一样,只不过换成了HarmonyOS那一侧而已。
1.2 搞清楚"鸿组件"到底解决什么问题
标题里那个"鸿组件",本质上就是"运行在鸿蒙系统上的原生模块/原生组件"。它解决的问题有几类:
- 需要调用鸿蒙系统独有能力,比如分布式软总线、跨设备流转。
- 需要高性能的原生UI控件,不想用RN重新绘制。
- 需要对接鸿蒙生态的SDK,比如系统服务、统一扫码、地图能力。
RN侧通过桥接层把JS的调用转给ArkTS原生实现,原生再把结果回传。整个过程对上层业务是透明的:RN代码里看起来只是在调用一个普通JS模块,背后真正干活的却是鸿蒙原生代码。
在动手之前,一定要想清楚一个边界:什么东西该放RN层,什么东西该放鸿蒙层。我的判断标准很简单——凡是鸿蒙设备独有的、需要和系统交互的、对性能敏感的逻辑,都下沉到鸿组件;凡是纯UI和业务状态,留在RN层。边界划清楚了,后面写代码才不会乱。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 先把"桥"的原理摸清楚:RN与鸿蒙的通信骨架
2.1 RN在鸿蒙设备上到底怎么跑
很多人以为RN在鸿蒙上跑,是把整个React渲染引擎搬过去,其实不是。HarmonyOS上运行的RN,靠的是官方和社区协作的适配层(目前主要是OpenHarmony/NEXT方向的项目),它的整体结构可以理解成三层:
- JS/TS业务层:你写的RN页面和组件逻辑,运行在鸿蒙侧的JS运行时里。
- 桥接层:负责JS与原生之间的双向通信,包括模块调用、事件回调、方法参数序列化。
- 鸿蒙原生层:ArkTS实现的TurboModule,以及基于ArkUI封装的宿主容器。
RN的UI部分到鸿蒙上,是通过ArkUI的渲染能力映射过去的。也就是说,你写的React组件最终会转换成ArkUI的组件树来渲染。这就解释了为什么有些RN样式在鸿蒙上会有细微差异——底层渲染引擎变了,出现像素级不一致太正常了。
2.2 TurboModule机制和Codegen的作用
在鸿蒙的RN适配里,JS调用原生模块走的是TurboModule这套机制(RN新架构下的标准做法,在鸿蒙上同样适用)。每个原生模块在JS侧有一个对应的Spec接口描述,Codegen会在编译期自动生成JS和原生两侧的胶水代码。
这样做的好处是明显的:方法签名和参数类型在编译期就能对齐,省掉了运行时猜测的开销;同时也让新增原生模块变成"写一个Spec + 实现一个模块"两件事,不再需要手工维护一长串映射。
我建议你在做鸿组件时,一定要把接口定义文件单独抽出来。比如在项目里建一个 specs/ 目录,专门放各模块的TurboModule类型声明。团队里任何人都能通过读Spec知道这个鸿组件提供哪些方法、接收什么参数、返回什么类型,比到处翻原生代码舒服多了。
2.3 和Android/iOS桥接的相同与不同
做过RN原生模块开发的同学,到鸿蒙这边会感觉很亲切,但有几个显著差异需要注意:
- 语言栈不同:Android侧写Kotlin/Java,iOS侧写Objective-C/Swift,鸿蒙侧写ArkTS。ArkTS是TS的超集,有静态类型约束,写法上比Kotlin更接近前端思维。
- 生命周期宿主不同:鸿蒙的UIAbility承担了类似于Activity/ViewController的角色,原生模块的生命周期要跟着Ability走。
- SDK能力不同:鸿蒙的API分布在不同Kit中,引入方式比Android的Gradle依赖更直观,直接在
oh-package.json5里声明即可。 - 调试工具不同:鸿蒙用DevEco Studio,配合hdc命令行工具,日志输出走HiLog,不再是logcat。
这些差异决定了团队的技能储备和学习重点:如果你本来就懂RN原生模块开发,转到鸿蒙差不多只需要一到两周的学习曲线,核心的桥接思路基本是通的。
3. 环境准备与最小桥接:从新建工程到第一个原生模块
3.1 开发环境清单与版本选择
工欲善其事,必先利其器。鸿蒙RN开发的环境搭建比普通RN项目要多几步,我列一下我们团队最终沉淀下来的推荐组合:
| 组件 | 推荐版本/工具 | 说明 |
|---|---|---|
| Node.js | 18 LTS或20 LTS | RN脚手架和构建工具依赖 |
| DevEco Studio | 5.x以上(API 12+) | 鸿蒙原生代码的IDE,自带SDK Manager |
| HarmonyOS SDK | API 12及以上 | 根据目标设备系统版本选择 |
| 鸿蒙RN适配包 | @react-native-oh/react-native-harmony |
RN在鸿蒙上的核心依赖,版本需与RN版本对应 |
| hvigor | DevEco内置 | 鸿蒙工程构建工具,类似Gradle的角色 |
| hdc | DevEco内置 | 设备调试命令行工具,类似adb |
这里最容易被忽略的是版本对应关系。RN版本、鸿蒙适配包版本、HarmonyOS SDK API级别之间是绑定的,不是随便挑最新就能跑。我们一开始图省事,装了各种最新版,结果编译期报了一堆莫名其妙的错误,最后老老实实按官方兼容矩阵锁版本,一次性通过。
3.2 在现有RN工程里挂载鸿蒙平台
如果你是从零开始,用RN CLI创建一个新工程,然后执行鸿蒙适配包的初始化命令,它会自动生成harmony/目录。如果和我一样是已有RN工程,同样可以执行初始化逻辑来补全鸿蒙工程目录。
生成出来的鸿蒙工程结构大概长这样:
code复制harmony/
├── entry/
│ ├── src/main/
│ │ ├── ets/
│ │ ├── resources/
│ │ └── module.json5
│ ├── build-profile.json5
│ └── oh-package.json5
└── build-profile.json5
最关键的是module.json5里的配置:需要声明RN容器所需的Ability,并指定入口页面。这个Ability就是承载RN页面的宿主,RN的JS代码跑在它上面。对应的,工程里要依赖RN的鸿蒙实现库,在oh-package.json5里加上:
json5复制{
"dependencies": {
"@rnoh/react-native-openharmony": "版本号"
}
}
3.3 跑通一个最小桥接Demo
环境搭好后,先别急着写业务,跑通一个HealthCheck式的原生模块比什么都重要。我在原生侧写一个最简单的模块,提供两个字符串方法:
typescript复制// entry/src/main/ets/Modules/DemoModule.ts
import { TurboModule } from '@rnoh/react-native-openharmony';
export class DemoModule extends TurboModule {
hello(): string {
return 'hello from harmony';
}
add(a: number, b: number): number {
return a + b;
}
}
然后在模块注册的入口文件里把它加进去:
typescript复制// entry/src/main/ets/entryability/EntryAbility.ts
this.rnInstance = await rnInstanceFactory.createAndStart(
{
...
onCreateModule: (turboModuleName: string) => {
if (turboModuleName === 'DemoModule') {
return new DemoModule(this.ctx);
}
return undefined;
}
}
);
RN侧调用就很简单:
typescript复制import { NativeModules } from 'react-native';
const { DemoModule } = NativeModules;
console.log(DemoModule.hello()); // hello from harmony
console.log(DemoModule.add(1, 2)); // 3
这里有个小细节值得注意:鸿蒙适配层对TurboModule的名字做了规范映射,通常要求模块名和注册名一致,避免驼峰和下划线混用。我们一开始把模块名写成了Demo_Module,结果JS侧调了半天都是undefined,后来对对齐了命名才通。
4. 实战:一个能读取设备信息的鸿蒙组件
4.1 需求拆解与接口设计
环境跑通后,我用一个真实需求来演示完整流程:做一个设备信息鸿组件,让RN业务侧能拿到设备型号、系统版本、屏幕分辨率。这些信息在鸿蒙系统API里都有现成接口,适合做示例,又不涉及复杂UI。
接口设计遵循"按需暴露,越薄越好"原则,我不希望JS侧关心太多原生细节。最终Spec定义长这样:
typescript复制// specs/DeviceInfoModule.ts
import type { TurboModule } from 'react-native';
import { TurboModuleRegistry } from 'react-native';
export interface Spec extends TurboModule {
getDeviceModel(): string;
getSystemVersion(): string;
getScreenInfo(): { width: number; height: number; density: number };
}
export default TurboModuleRegistry.getEnforcing<Spec>('DeviceInfoModule');
为什么用getEnforcing而不是get?因为DeviceInfoModule是设备必备模块,如果拿不到说明桥接失败了,不如直接抛错暴露问题,而不是让业务侧拿到undefined后懵圈。
4.2 鸿蒙原生侧实现
接下来在ArkTS里实现这个Spec。用到的系统API来自@kit.BasicServicesKit(设备信息)和@kit.ArkUI(屏幕参数):
typescript复制// entry/src/main/ets/Modules/DeviceInfoModule.ts
import { TurboModule } from '@rnoh/react-native-openharmony';
import { deviceInfo } from '@kit.BasicServicesKit';
import { window } from '@kit.ArkUI';
export class DeviceInfoModule extends TurboModule {
getDeviceModel(): string {
return deviceInfo.marketName;
}
getSystemVersion(): string {
return deviceInfo.displayVersion;
}
getScreenInfo(): { width: number; height: number; density: number } {
const win = window.getLastWindow(this.ctx);
const rect = win.getWindowProperties().windowRect;
return {
width: rect.width,
height: rect.height,
density: win.getWindowProperties().densityRatio
};
}
}
注意这里的this.ctx是TurboModule基类注入的Context,用来获取窗口实例。鸿蒙API是基于Promise的异步风格,但TurboModule的同步方法要求直接返回。如果需要异步操作,得用Promise或Callback,在Spec里声明异步签名。
然后注册模块,方式和前面DemoModule一样,在EntryAbility的onCreateModule里按名返回。
4.3 RN侧封装与业务调用
原生模块注册好之后,我习惯在RN工程里包一层自定义Hook,避免业务代码直接操作NativeModules:
typescript复制// harmony-device/useDeviceInfo.ts
import { useCallback, useEffect, useState } from 'react';
import { Platform } from 'react-native';
import DeviceInfoModule from '../specs/DeviceInfoModule';
export function useDeviceInfo() {
const [info, setInfo] = useState(null);
const refresh = useCallback(() => {
if (Platform.OS !== 'harmony' || !DeviceInfoModule) {
return;
}
setInfo({
model: DeviceInfoModule.getDeviceModel(),
version: DeviceInfoModule.getSystemVersion(),
screen: DeviceInfoModule.getScreenInfo(),
});
}, []);
useEffect(() => {
refresh();
}, [refresh]);
return { info, refresh };
}
这样业务侧使用就非常干净:
typescript复制const { info } = useDeviceInfo();
if (info) {
Text标签里展示 `${info.model} / ${info.version}`
}
在真机上跑通后,你会发现这个组件的调用链路非常短:JS方法调用 -> 桥接层序列化 -> ArkTS方法执行 -> 返回结果。相比在Android/iOS上接原生模块,体感几乎一样。
5. 启动白屏、工具链失败与生命周期坑:踩坑实录
5.1 启动白屏的根因排查
搜索热词里有"react native 启动白屏",我猜八成的人都踩过。我们在鸿蒙适配过程中也遇到了,白屏持续两三秒才出首屏。这个问题在鸿蒙上有两个叠加因素:
第一个原因是JSBundle加载。鸿蒙设备的RN启动流程是从UIAbility创建、RN引擎初始化、JSBundle读取、执行到首帧渲染,链路比Android更长。我们的Bundle体积做到3MB左右,在低端鸿蒙设备上解析执行明显变慢。
第二个原因是首帧页面在等原生模块就绪。我一开始写的启动页会在useEffect里去拿设备信息,拿不到就显示空白loading,看起来就是白屏。
解决办法分几步走:
- 启动页不再等设备信息,先用本地默认值渲染框架,再异步刷新。
- 把JSBundle从本地assets读取方式换成预加载,在Ability的
onWindowStageCreate阶段提前初始化RN引擎。 - 在原生侧设置HarmonyOS启动屏(SplashScreen),让用户从点击图标到RN第一帧之间始终有画面,视觉上消灭白屏。
5.2 工具链安装失败的几个真凶
"基础工具链部署失败"这类问题,本身是个宽泛的报错,往往是多个原因叠加。我在配置开发环境时遇到过几次,逐个写一下:
- ohpm install超时或失败:鸿蒙包管理器从远端仓库拉依赖,网络不好或者镜像源不稳定就容易失败。处理方式是把依赖源切到可用镜像,同时清理本地缓存目录后重试。如果公司有内网npm/ohpm代理,优先走内网。
- hvigor构建偶发失败:这个和我们Java版本、内存分配都有关系。DevEco Studio自带的JBR偶发堆内存不足,需要在
hvigor-config.json5里适当调大构建参数。 - SDK版本与工程API不匹配:报错信息里经常出现"compileSdkVersion"之类的提示。建议升级DevEco时顺手看一下工程配置的compileSdkVersion,而不是直接沿用老工程的版本。
这类问题的共性特点是:错误提示往往不直接指向根因。我的建议是先检查版本矩阵,再检查网络环境,最后才看代码配置,按这个顺序排查能省不少时间。
5.3 生命周期管理:模块释放和内存泄漏
鸿蒙的UIAbility和RN的JS虚拟机生命周期绑定在一起。如果原生模块持有了不该持有的长生命周期对象,在页面销毁时没有释放,就会出现内存泄漏,严重时应用被系统杀掉。
我踩过的一个典型案例:设备信息模块在构造时绑定了窗口大小变化监听,用来实时返回屏幕宽度。结果页面销毁后监听没解除,窗口旋转几次内存就明显上涨。
正确的做法是在模块里提供清理方法,并在UIAbility销毁时调用:
typescript复制// 原生侧
export class DeviceInfoModule extends TurboModule {
private onWindowSizeChange?: () => void;
startListening() {
this.onWindowSizeChange = () => { /* 回调JS */ };
// 注册监听
}
stopListening() {
// 移除监听
this.onWindowSizeChange = undefined;
}
}
JS侧不用手动调用,但原生侧一定要在Ability的onDestroy里调用所有活跃模块的清理逻辑。我们后来封装了一个模块注册表,统一管理每个鸿组件的启动和销毁,不再让每个模块自己乱搞。
6. 组件从"能跑"到"好用":性能与工程化建议
6.1 减少桥接调用的性能损耗
桥接通信是有代价的。每调用一次原生方法,都要经过参数序列化、跨运行时传递、结果反序列化。高频调用会让性能明显劣化。
我实际优化过的几个点:
- 批量数据传递:不要一次传一个字段,把多个相关字段打包成对象或数组一次性返回。
- 长列表数据走事件流:如果原生侧要不断推送数据(比如传感器读数),不要让JS侧轮询,改成原生主动发事件,JS侧监听。
- 大对象避免同步返回:同步方法会阻塞JS线程,数据量大了会造成卡顿。大数据量用Promise或Callback异步方式。
6.2 错误处理与降级策略
鸿组件在鸿蒙设备上运行,但你的RN App可能还需要兼容Android和iOS。这时候就要考虑降级:当Platform.OS不是harmony时,组件得有一份兜底逻辑。我习惯在封装层做能力检测,设备信息在非鸿蒙平台就使用别的已有库,而不是让业务侧到处判断。
原生侧的方法调用也要做好异常捕获。鸿蒙API不比Android稳定,某些接口在部分设备上可能抛出BusinessError。我在每个原生方法里统一包了try-catch,出错时返回可读的Error对象,同时通过事件通知JS侧做埋点上报。
6.3 团队协作与发布规范
最后聊点工程化的问题。鸿组件不是一个孤立的原生仓库,它和RN工程是共生关系。我们团队沉淀的经验是:
- 原生模块代码独立成目录:在
harmony/entry/src/main/ets/Modules下按业务模块分子目录,不允许什么都往一个文件里塞。 - Spec作为唯一契约:任何原生接口变更,必须同步收敛到Spec文件,走Code review。谁改Spec谁负责更新JS侧和原生侧两侧实现。
- 自动化构建模板:鸿蒙工程接入CI,打Release包时自动同步版本号,避免手改配置出错。
组件数量从一两个涨到十几个之后,这些规范会帮你省掉大量"这个模块为什么找不到""签名怎么又变了"的沟通成本。
写到这里,我个人最大的体会是:在React Native里做鸿组件,本质上还是在做跨端基建的活,只是把"原生"这两个字从Android/iOS扩展到了HarmonyOS。真正的难点不在于单个API怎么写,而在于你怎么设计模块边界、怎么管理生命周期、怎么让前端团队和鸿蒙开发之间协作顺畅。如果你正准备起步,我建议不要一上来就追求把整个工程跑起来,先挑一个最小的系统能力,从写Spec开始,完整走一遍桥接-调试-打包的流程,建立起手感之后,再逐步扩展组件库,后面会越走越顺。
