最近总有同行来问我:“你们的React Native项目到底是怎么跑上鸿蒙的?是不是把安卓代码搬过去重新编译一遍就行?” 说实话,第一次听到这个需求时我也是这么想的,但真正动手做了才发现,React Native要跑到HarmonyOS上,远不是改个包名、换个SDK的事儿。HarmonyOS虽然兼容过APK,但到了纯血鸿蒙,它的应用模型、页面生命周期、原生UI体系和安卓/iOS都完全不同,RN那套“JS驱动原生渲染”的架构必须重新做一次原生桥接,才能让JavaScript代码最终渲染成ArkUI的原生组件。
这篇文章我想把“在React Native中开发鸿蒙组件(也就是鸿蒙(HarmonyOS)原生组件)”这条路完整梳理一遍,包括鸿蒙开发的基础认知、RN工程怎么接入鸿蒙、自定义组件怎么从ArkUI活到React层、以及组件跑起来之后常见的通信和启动问题。适合两类人看:一是已经在做RN但被要求适配鸿蒙的客户端开发,二是第一次接触HarmonyOS但想搞清楚RN到底怎么能直接掉原有Android业务代码的团队负责人。
1. 为什么说RN开发鸿蒙组件,不是“换个安卓模拟器”这么简单
1.1 先从HarmonyOS的应用模型说起
很多RN开发者对鸿蒙的第一印象是“能装APK”,但这个印象只适用于早期的双框架版本。现在HarmonyOS NEXT走的是纯血路线,应用包后缀是.hap,不再兼容安卓APK,整个系统的应用模型是基于Stage模型设计的——每个应用由一个或多个Ability组成,页面由@Entry组件作为入口,页面路由由router或Navigation管理。这和Android里Activity+Fragment+Intent的体系完全是两套东西。
RN能跨平台,靠的从来不是“一份代码到处编译”,而是“一层JS逻辑加一套原生桥接”。iOS上有自绘引擎也有原生映射,Android上有View映射,鸿蒙上同样需要一个映射层,把React的Component树映射成ArkUI的组件树。这个映射层就是RN鸿蒙化适配的核心工作。
React Native从诞生那天起就不是一个纯粹的自绘引擎,它依赖原生平台的能力去渲染真正的原生控件。在Android上对应的是View体系,在iOS上对应的是UIView体系,在鸿蒙上对应的就是ArkUI的Component体系。所以你要开发一个鸿蒙组件,本质上是给RN加一套新的“原生渲染后端”,你可以把它理解为RN在HarmonyOS上的一个专门适配层。
| 平台 | JS引擎环境 | 原生UI体系 | RN原生桥接 |
|---|---|---|---|
| Android | Hermes/JSC | Android View | ViewManager |
| iOS | Hermes/JSC | UIKit | RCTViewManager |
| HarmonyOS | ArkTS运行时 | ArkUI Component | 社区适配层的ComponentManager |
这张表的最后一行,就是我们这次讨论的核心。搞明白这一层,你才真正知道自己在做什么。
1.2 分布式能力对组件设计意味着什么
鸿蒙OS强调“分布式”,一套代码可以跑在手机、平板、车机、手表上,并且支持跨端流转。这个特性对RN开发者来说,最直接的影响是:你在鸿蒙上写的原生组件,不能默认它是“单窗口、单设备”的。
我举个具体例子。你在Android上写一个自定义View,只要管好onMeasure、onDraw、onTouchEvent就够了。但鸿蒙里的组件要考虑WindowStage的多个窗口场景,比如在折叠屏上从竖屏切成横屏、在平板上的分屏模式,甚至跨设备迁移时Ability重新创建、页面栈状态恢复的情况。如果你在RN侧硬编码了手机屏幕宽度,或者组件里持有Ability对象的强引用,跨端流转时大概率会出问题。
所以做RN鸿蒙组件之前,要先补一个“鸿蒙开发的基础课”:Ability的生命周期、ArkUI的状态管理装饰器(@State、@Prop、@Observed)、UIAbility和元服务的区别。这些概念不用背得很深,但至少要理解它们和Android/iOS原生的对应关系,后面写桥接代码时才不会踩坑。
1.3 RN开发者的心智模型需要做什么转换
RN开发者习惯了“写JS/TS,组件样式用StyleSheet,原生模块通过NativeModules调用”,这套心智模型在iOS和Android上是一致的。但在鸿蒙上,有几个关键认知要转过来。
第一,鸿蒙原生侧的语言是ArkTS,它不是TypeScript的简单改版,而是在TS基础上加了一套UI语法的声明式框架。你在鸿蒙侧写组件时,布局方式用的是Column、Row、Stack、List这种容器组件,样式用的是链式写法,比如.width(100).height(200).backgroundColor('#fff'),和React的StyleSheet完全不是一个套路。
第二,状态管理逻辑不同。RN组件里用useState管理状态,而ArkUI里用@State装饰器声明响应式变量,当你把这些变量用在build()里时,框架会自动追踪依赖并刷新UI。这个机制和React很像,但表达方式不一样。写桥接组件时,原生侧的状态要不要通知RN侧、什么时候通知,都需要你重新设计。
第三,导航体系不同。RN生态里常用react-navigation做页面跳转,而鸿蒙原生侧有自己的Navigation和router体系。如果RN应用要嵌入到鸿蒙的Ability里,你需要决定页面栈由谁管理。做RN鸿蒙组件时,RN深色模式、安全区适配这类问题,也需要重新对齐鸿蒙的规格。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 把鸿蒙“塞”进RN工程:环境准备、目录结构与三项必改配置
2.1 工具链与版本选择,决定了你后面会不会哭
这里先说一条血泪经验:RN鸿蒙化目前高度依赖适配层的版本兼容关系,不要随便在RN工程里装一个最新版就开跑。以社区比较成熟的react-native-harmony适配方案为例,它针对不同RN版本维护了不同的分支和构建产物。我的建议是,先查你项目当前的RN版本支持情况,再回头定鸿蒙SDK和DevEco Studio的版本。
我这边最终采用的组合是:RN 0.72 + HarmonyOS SDK API 12 + DevEco Studio 5.x。这个组合在社区里适配案例最多,问题也容易被搜索到。如果你的RN版本更高,比如0.73、0.74,需要先确认适配层的release notes里是否支持,不要盲目升级。鸿蒙侧也不是版本越高越好,API 12和API 13在部分组件属性上有差异,社区桥接层往往是滞后适配的。
环境准备清单大致是这么几项:
- 安装Node.js(推荐18或20的LTS版本,不建议用奇数版本,RN生态对Node版本比较敏感)
- 安装DevEco Studio(鸿蒙官方IDE,从华为开发者官网下载)
- 在DevEco Studio里配置HarmonyOS SDK,并勾选对应API版本的platform
- 使用ohpm(鸿蒙的包管理工具,类似于npm)安装鸿蒙侧依赖
- 保持项目原有Android/iOS代码不受影响,鸿蒙模块以独立目录形式存在
2.2 鸿蒙模块在RN工程里的目录组织
一开始我走了弯路,试图把鸿蒙工程嵌到node_modules里,结果每次装依赖都会把鸿蒙原生代码冲掉。正确做法是让鸿蒙工程成为RN项目根目录下的独立一等公民,和android/、ios/平级,这样RN业务代码、Android工程、iOS工程、鸿蒙工程可以在同一个仓库里管理。
我的目录结构大致是这样:
text复制project-root/
├── android/
├── ios/
├── harmony/
│ ├── entry/
│ │ └── src/main/
│ │ ├── ets/
│ │ ├── resources/
│ │ └── module.json5
│ ├── build-profile.json5
│ ├── hvigorfile.ts
│ └── oh-package.json5
├── src/
│ ├── App.tsx
│ └── components/
├── package.json
└── metro.config.js
harmony/entry对应的是鸿蒙的App模块入口,ets目录里是所有ArkTS代码,包括UIAbility入口文件、页面文件、自定义组件、桥接逻辑。Commendable的地方在于,这种组织方式下,HarmonyOS应用壳负责启动RN运行时,而RN的JS业务代码依然位于src/,打包时由Metro把JS Bundle打包到鸿蒙的hap资源目录里。
2.3 首次编译前必须改掉的三个默认配置
把鸿蒙工程放进RN项目之后,先不要急着写组件,先把三个配置改对,否则编译能过,运行必崩。
第一,鸿蒙工程根目录下build-profile.json5里的signingConfigs。DevEco Studio默认的是自动签名,但CI环境下没有登录华为账号的话构建会失败。建议在本地生成.p12和.cer签名文件,并配置好storePassword、keyAlias这几个字段,让构建不再依赖IDE的账号状态。
第二,entry/src/main/module.json5里Ability的metadata配置。RN运行时需要在应用启动时知道自己应该在哪个Ability里加载,适配层通常要求在入口Ability的metadata里声明一个类似ReactNativeHarmony的配置项,值指向RN入口页面的路由。我没有配置之前,应用冷启动后直接白屏,日志里一直报找不到RootView。
第三,网络权限和本地调试配置。如果RN开发模式要从MetroServer拉取JS Bundle,鸿蒙应用必须声明ohos.permission.INTERNET权限。同时,DevEco的模拟器访问本地Metro时,一般用10.0.2.2这类宿主机地址,要把这个地址加到允许访问的网络白名单里,否则开发模式永远连不上Metro。
3. 写一个真正的鸿蒙自定义组件:从ArkUI界面到RN可见
3.1 一个具体需求:原生渐变进度环
讲原理之前,我用一个实际做过的组件来说更清楚。当时业务方要一个会旋转的渐变进度环,中间显示当前百分比,环的动效要求60fps不掉帧。这个需求用RN纯JS动画去做也不是不行,但在低端鸿蒙设备上,JS线程的动画计算和UI渲染线程竞争严重,很容易白屏或卡顿,所以我们决定在鸿蒙侧写一个原生组件。
这个组件的原生侧核心是一个ArkUI自定义组件,用@Component声明:
typescript复制// 鸿蒙侧的GradientRing组件
@Component
export struct GradientRing {
@Prop progress: number = 0;
@State ringColor: string = '#007DFF';
private interactive: CustomInteraction | null = null;
aboutToAppear(): void {
this.interactive = new CustomInteraction(this);
}
aboutToDisappear(): void {
this.interactive?.release();
this.interactive = null;
}
build() {
Column({ space: 8 }) {
Canvas(this.interactive ?? undefined)
.width('100%')
.aspectRatio(1)
.onReady(() => {
// 在Canvas上绘制渐变圆环
this.interactive?.drawRing(this.progress, this.ringColor);
})
Text(`${Math.round(this.progress * 100)}%`)
.fontSize(20)
.fontColor('#333')
}
.width('100%')
.padding(12)
}
}
这里有几个鸿蒙开发的基础点值得说明一下。@Prop表示属性跟随外部传入值更新,类似于React的props更新。@State是组件内部状态,状态变化会触发对应UI刷新,这个和React的useState心智接近。aboutToAppear和aboutToDisappear是组件的生命周期钩子,对应了组件创建和销毁时机。Canvas是ArkUI提供的画布组件,类似前端Canvas,适合做这种自定义绘制需求。
3.2 通过Bridge把原生视图暴露给React
写好ArkUI组件只是第一步,现在React侧的JS代码还看不到它。我们需要一个桥接层,这是RN鸿蒙组件开发最核心的一块。
在Android上,RN通过ViewManager把原生View注册给React,鸿蒙侧的思路类似。RN的鸿蒙适配层会提供一个ComponentManager或等价物,你实现一个自定义Manager,告诉RN“有一个组件叫GradientRing,它的实例对象来自这个工厂”。
typescript复制// 桥接层示意代码(不同适配层的API命名略有差异)
import { ComponentManager } from 'react-native-harmony/ComponentManager';
import { GradientRing } from './GradientRing';
export class GradientRingManager extends ComponentManager {
createInstance(ctx: ComponentContentContext): GradientRing {
return new GradientRing(ctx);
}
getName(): string {
return 'GradientRing';
}
}
然后把这个Manager注册到RN运行时的原生组件注册表里,让React侧可以用同样的名字找到它。
JS侧,通过requireNativeComponent拿到这个原生组件的引用:
tsx复制import { requireNativeComponent } from 'react-native';
// 声明原生组件,并指定属性名
const GradientRing = requireNativeComponent('GradientRing');
export function App() {
return (
<GradientRing
style={{ width: 160, height: 160 }}
progress={0.75}
ringColor="#007DFF"
/>
);
}
这里要注意,requireNativeComponent在Android上是RN自带的能力,鸿蒙适配层也实现了同名API,但在某些自定义事件、方法调用的场景下,需要走另一套TurboModule或命令调用的能力。我后面会细说。
3.3 JS侧调用原生方法、回传事件的标准写法
有些组件不能只靠传入属性,比如点击组件后要触发一个JS回调,或者JS侧要主动让原生组件执行某个动画。这两个场景分别是“原生事件回传JS”和“JS调用原生方法”,鸿蒙组件跑起来后这两件事必须都要能通。
原生事件回传JS,先定义事件名:
typescript复制// 鸿蒙侧,组件内部在用户点击时触发回调
this.eventEmitter?.emit('onProgressChange', {
value: this.progress,
});
JS侧:
tsx复制const GradientRing = requireNativeComponent<any>('GradientRing');
<GradientRing
progress={0.75}
onProgressChange={(e) => {
const { value } = e.nativeEvent;
console.log('用户点了进度环,当前进度是:', value);
}}
/>
JS调用原生方法,则要通过UIManager.dispatchViewManagerCommand或鸿蒙适配层提供的方式,向指定的原生组件实例发送指令。这种“命令式”调用适合一次性动作,比如startAnimation()、resetProgress()。这个方法默认的调用频率不高,但如果频繁调用,要格外注意跨桥开销。
4. 组件跑起来之后:JS与ArkTS的通信细节、生命周期与白屏排查
4.1 属性下发与事件上报,高频更新时别掉进性能坑
原生组件桥接好之后,很多人会忽略一个问题:从JS到ArkUI的属性同步,本质上是一次“跨桥数据拷贝”。RN侧每次重渲染时,progress、ringColor这些属性都会序列化后传到鸿蒙侧,鸿蒙侧再触发@Prop更新。
低频更新完全无所谓,但如果你的业务逻辑是每秒更新几十次进度值,比如下载进度条、录音分贝动画,你就会发现鸿蒙侧的原生组件有一种“跟不上JS”的感觉。解决思路是不要把每个进度值都作为prop传下去,而是通过组件内部的绘制逻辑做插值,或者由鸿蒙侧自己启动一个定时器去驱动动画,JS只需要把最终状态告诉它。
比如我们这个渐变进度环,动画过程中就让鸿蒙侧自己维护动画状态,JS只传入目标值。这和Android上自定义View里用ValueAnimator的思路是一致的。我实测下来,把动画驱动放到鸿蒙侧后,帧率表现比从JS侧高频传prop好了一个量级。
4.2 生命周期对齐与内存泄漏,最容易翻车的地方
RN组件和鸿蒙原生组件的生命周期,虽然名字相近,但并不是一一对应的。RN组件卸载时,鸿蒙侧的原生组件不一定会立刻销毁,因为RN的跨桥层为了性能会做复用和缓存。这导致一个高发问题:组件销毁了,但内部的事件订阅、定时器还在,内存泄漏甚至崩溃。
我的做法是,在鸿蒙组件里把所有资源申请都绑定在aboutToDisappear里释放,具体包括:
- 清空定时器
- 解绑事件监听
- 释放Canvas上下文引用
- 把可能持久的Context引用置空
同时,在RN侧卸载组件时,通过命令显式调用一个cleanup方法,让原生侧主动做一次资源回收。双保险,谁先触发都兜得住。
4.3 启动白屏的排查链路,别一上来就怀疑代码写错了
热词里出现的“react native 启动白屏”确实太常见了,尤其是在鸿蒙上,因为鸿蒙适配层的日志又少又容易被系统日志淹没。我分享一个自己的排查顺序,按这个顺序来,基本能在半小时内定位大多数白屏问题。
第一步,先看鸿蒙侧系统日志里有没有ReactNative相关的错误输出。之前在DevEco的Log窗口里搜关键词ReactNative或JSException,能直接看到JS引擎有没有抛异常。如果JS引擎压根没启动,那问题大概率出在桥接层或Bundle加载,而不是业务代码。
第二步,检查打包进hap里的Bundle路径是否配置正确。RN开发模式走Metro,生产模式则要把index.android.bundle或其他命名的Bundle文件塞进鸿蒙资源目录。很多白屏就是路径没配置对,运行时根本找不到JS代码。
第三步,用DevEco的调试器连接模拟器,确认RN运行时确确实实加载成功,并且组件Manager有注册成功。在桥接代码里加几行日志,把GradientRingManager的实例化过程打出来,确保RN容器在创建时找到了对应的Manager。
第四步,检查网络与权限。如果生产包一切正常、只有开发模式白屏,那基本就是Metro连接问题。确认鸿蒙模拟器可以访问宿主机地址,且INTERNET权限已经声明。
5. 这个方案能走多远:性能边界、动画取舍与团队落地建议
5.1 什么适合用RN鸿蒙组件做,什么最好直接用ArkUI写
RN鸿蒙组件并不是万能的,它有非常明显的适用边界。我的判断标准很简单:看这个功能是“业务逻辑重”还是“原生交互重”。
像业务列表、表单校验、数据展示、后端接口对接这类的页面,RN组件完全够用,而且跨端复用的价值非常大。因为这类功能的核心在JS层,原生层只是普通容器。但如果是高频的手势交互、复杂动画、音视频解码、后台长任务,或者要和鸿蒙系统能力深度绑定(比如分布式数据管理、跨设备流转),那就别硬套RN,直接在鸿蒙侧用ArkUI写原生页面,再用RN提供入口跳转,反而省心。
我之前做过一个拍照组件,原方案想用RN封装相机能力,结果发现鸿蒙的相机API走的是CameraManager加XComponent承载预览流,这套能力和RN的组件模型绑定起来非常别扭。最后我们改成:鸿蒙原生页面负责拍照预览,RN通过路由跳转唤起,把拍摄结果路径回传给RN页面,整个复杂度瞬间降下来。所以关键不是“RN什么都能做”,而是“什么适合RN做”。
5.2 性能开销的量化认知:一次跨桥通信到底贵在哪
很多朋友会问,RN鸿蒙组件会不会比纯原生组件慢。这里面要分开看。UI一级渲染的开销,RN和纯原生的差距主要在跨桥通信上,而跨桥通信贵在序列化和线程切换。
数据从JS线程传到ArkTS线程,要经过一个序列化/反序列化的过程。传递一个字符串、一个数字成本很低,但传递一个大对象、一个高频更新的值,成本就不可忽略了。所以我的原则是:跨桥的数据越简单越好,能用基本类型绝不用复杂对象,能低频传递绝不高频推送。组件内部的高频逻辑在鸿蒙侧就地解决。
设置属性时,也尽量避免每帧去更新多个@Prop。比如进度环,一次只传一个progress,颜色这种静态属性只在初始化时传一次,后续通过命令去改。
5.3 团队落地时的分工、CI与文档沉淀
最后聊聊团队落地。RN鸿蒙组件这一套东西,最怕的不是技术难度,而是知识散落在各人脑子里。我们团队最后定了三条规矩,实践下来很有用。
第一,代码分层要明确。鸿蒙原生层只放组件实现和桥接逻辑,不写业务;RN侧只写业务组件,不碰鸿蒙API。两层之间用明确定义的属性和事件作为契约,类似前后端接口文档。谁改契约,谁就要同步更新另一侧的类型定义。
第二,CI里把鸿蒙构建接进去。DevEco Studio提供了命令行构建工具hvigor,可以在流水线里跑hvigorw assembleHap,把鸿蒙包构建和RN的JS打包合成一个产物。不要让人工在IDE里点按钮,否则版本一多必出问题。
第三,版本对应关系一定要写成文档。RN版本、鸿蒙适配层版本、DevEco版本、SDK API版本这四个之间的兼容矩阵,至少要在团队Wiki里维护清楚。这个项目踩过的坑,很多都来自版本错配。
在我个人实际体验里,RN开发鸿蒙组件最前期是最痛苦的,因为资料少、边界模糊、报错信息又不直观。但一旦把第一个桥接组件跑通,后面再做第二个、第三个就会顺很多,因为核心机制都是同一套:ArkUI写组件、桥接层注册、JS侧声明类型、事件回调打通。这个路径跑通之后,你再回头看鸿蒙适配这件事,会发现它其实没有一开始想得那么神秘,只是需要你同时具备RN和鸿蒙两端的视角。
最后分享一个小技巧,调试RN鸿蒙组件时,在DevEco的Log过滤器里单独加一个ReactNative标签,能过滤掉大量系统噪声,直接看到RN运行时自己的日志。这个习惯帮我省下了大量翻日志的时间。
