前阵子把公司的 React Native 应用往鸿蒙(HarmonyOS)上迁,同事第一反应是:RN 不是号称跨平台吗,改改配置不就能跑?真上了手才明白,鸿蒙虽然生态越来越成熟,但它的原生技术栈和 iOS、Android 完全不是一个路子,RN 默认的渲染引擎、桥接层、组件注册机制,到了鸿蒙里全得重新对接。这篇文章不聊虚的,只讲我踩过的坑和验证过的路:鸿蒙开发要补哪些基础、React Native 工程怎么和鸿蒙原生工程放一起、自定义鸿蒙组件怎么桥接给 RN 调用,以及调试和排查问题时的实用技巧。适合正要接鸿蒙的 RN 开发者,也适合想理解“RN 组件在鸿蒙上到底怎么跑”的移动端同事。
1. 先把关系理清:鸿蒙组件不是 React Native 自带技能
1.1 RN 和鸿蒙各自的基本盘
React Native 的通用模型是:JS 引擎执行 JavaScript,通过桥接层调用原生 UI,把声明式的组件树映射到 iOS 的 UIView 或 Android 的 View 上。它之所以能“跨平台”,是因为每个平台都有对应的 C++/Java/Swift 适配层。
鸿蒙系统这边则是另一套体系。应用开发用得最多的是 ArkTS + ArkUI,UI 是声明式写法,架构模型叫 Stage 模型,应用由 Ability(主要是 UIAbility)组成。ArkUI 的组件树有自己的渲染管线。鸿蒙并不是一个“换皮安卓”,底层渲染、任务调度、权限模型都不一样。这意味着 RN 的 Android 适配代码不能直接搬到鸿蒙上,必须有专门的鸿蒙适配层。
所以说得直白一点:RN 在鸿蒙上属于“移植生态”,不是“原生支持生态”。鸿蒙官方这几年提供了 RN 的适配框架,但框架只保证基础组件能跑,你自己的业务组件、第三方原生插件、涉及系统能力的模块,都需要按鸿蒙的规范重新做对接。
1.2 集成前先做方案选型
我和团队讨论时列过三条路线,这里也说给你参考。
第一种是 WebView 套壳,把 RN 页面打包成 H5 塞进 WebView。上线最快,但交互体验、内存、相机等原生能力都会打折,适合临时演示,不建议做成正式方案。
第二种是独立双 App。原生鸿蒙 App 一套,RN App 一套,各自维护。对团队来说管理成本高,两个端逻辑不一致风险也大。
第三种就是我最终采用的:在鸿蒙原生工程里集成 RN,把复杂的业务页面用 React Native 开发,需要鸿蒙系统能力时通过自定义原生组件和原生模块桥接。这也是目前社区验证过最可行的路。鸿蒙组件并不是设计成直接给 RN 用的,它需要经过一层封装,把 ArkUI 的组件暴露成 RN 能识别的原生组件。封装之后,RN 侧代码写起来和其他平台没有区别,该传参数传参数,该收事件收事件。
1.3 明确“鸿组件”的真实含义
你听到“鸿组件”可能会以为鸿蒙自带组件库能直接 npm install,其实不完全是。鸿蒙原生组件是 ArkUI 里的自定义组件,用 @Component 装饰器定义,组件内部由 Column、Row、Text、Button 等基础组件拼装。要在 RN 里使用它,需先在鸿蒙侧把组件包成一个原生组件,再在 RN 侧用 requireNativeComponent 或宿主环境的组件绑定接口注册进去。这样“鸿组件”就成了一等公民,能和其他 RN 组件一样写进 JSX 里。
实操中我一般把组件分成三类:
- 纯展示组件:例如数据分析里的自绘图表,没有原生事件,桥接最简单。
- 带交互组件:例如相机扫码、地图手势,需要处理触摸事件和回调。
- 能力调度组件:例如调用系统扫码、蓝牙、分布式流转,不涉及 UI 或只有轻量 UI,通常走原生模块而不是原生组件。
先搞清楚自己属于哪一类,再决定封装方式,能省掉大量返工。
2. 鸿蒙开发基础,只补 RN 需要的部分
很多 RN 开发者第一次接触鸿蒙文档,会被 Stage 模型、UIAbility、ArkTS、方舟编译器这些概念砸晕。我的经验是:不要全线铺开学,只抓住和 RN 集成强相关的最小知识集。
2.1 必须搞懂的四个概念
先记四个词:Ability、Stage 模型、ArkTS、ArkUI。
Ability 是鸿蒙应用的基本组成单元。UIAbility 负责带界面的任务,可以粗略理解为“一个可以启动的页面容器”。RN 应用跑在鸿蒙上,本质就是一个 UIAbility 持有一个 RN 容器视图。
Stage 模型是鸿蒙 3.0 之后主推的应用开发模型,规定了应用怎么组织模块、怎么申请权限、怎么管理生命周期。RN 集成时,需要在 module.json5 里配置 abilities 和 extensionAbilities,注册好入口。
ArkTS 是鸿蒙的主要开发语言,语法基于 TypeScript,加入了 UI 状态管理和装饰器能力。写 UI 时你用 @Component、@State、@Prop 这类装饰器描述组件状态。对 RN 开发者来说,ArkTS 比原生 Java/Kotlin 更容易上手,因为它本身就带响应式状态管理,思路有点像 React。
ArkUI 是声明式 UI 框架。UI 结构用组件树描述,状态变了组件自动更新。RN 的 View 层级映射到鸿蒙,最终就落在 ArkUI 的组件树上。理解这一点之后,你封装自定义组件时就会自然地思考:RN 组件树中的一个节点,对应 ArkUI 的哪个组件、怎么创建、怎么更新属性。
2.2 生命周期对接是重头
RN 组件的生命周期是 React 那套:mount、update、unmount。鸿蒙 UIAbility 生命周期则是 onCreate、onWindowStageCreate、onForeground、onBackground、onDestroy。
集成时经常遇到一个错位:RN 页面还在前台,鸿蒙容器所在的 UIAbility 却因为系统资源回收走到了后台,甚至被销毁。所以你不能想当然地在 RN 组件的 componentWillUnmount 里释放所有原生资源,要区分“页面卸载”和“Ability 销毁”两个维度。
我习惯在鸿蒙侧组件增加 setup 和 teardown 两个方法,由桥接层显式调用:
- 创建 RN 组件时,调 setup,完成相机预览、传感器订阅等初始化。
- RN 组件卸载时,调 teardown,释放资源。
- UIAbility 进入 onBackground 时,暂停高耗资源,不销毁。
- UIAbility 走到 onDestroy 前,强制释放所有原生会话。
这套“双重释放”机制帮我们避免了很多诡异的相机占用和内存泄漏。
2.3 构建工具和工程配置知道怎么跑就行
鸿蒙开发用官方 IDE,对应鸿蒙 SDK。你需要安装 Node.js、官方 IDE、鸿蒙 SDK,并在 IDE 里配置好签名。新人常在这块卡住,因为鸿蒙应用真机调试需要签名证书。本地联调用模拟器会简单一些,但涉及相机、蓝牙、分布式能力时必须真机。
SDK 的版本要和 RN 适配层版本对应。我用的组合是:React Native 0.7x 版本配鸿蒙适配层最新稳定版,官方 IDE 版本保持稳定渠道更新。不同大版本之间的 API 变更明显,建议先锁定一套组合,跑通 Demo 再升。
3. 工程组织:把 RN 和鸿蒙工程摆在同一个仓库
3.1 最顺手的目录结构
一个工程如果既包含 RN 代码又包含鸿蒙原生代码,不好好组织目录会很痛苦。我目前用的结构简单直接:
mobile_app/ ├── rn/ # RN 工程 │ ├── src/ │ ├── package.json │ └── index.js ├── harmony/ # 鸿蒙原生工程 │ ├── entry/ │ ├── oh-package.json5 │ └── hvigorfile.ts └── docs/rn 目录放纯 JS/TS 代码,harmony 目录放鸿蒙壳工程。两边通过相对路径的脚本通信。这里有一个细节:不要在 harmony 的目录里放 node_modules,也不要让 RN 工程依赖鸿蒙工程的产物。两边职责越清晰,后期打包和 CI 配置越省事。
3.2 创建鸿蒙壳工程的步骤
第一步:用官方 IDE 新建一个 Empty Ability 工程,包名按公司的域名规则起。
第二步:在 harmony 工程的依赖里加上 RN 鸿蒙适配框架的包。具体包名和版本以你用的官方文档为准,这里不过多展开。
第三步:在 entry 的 UIAbility 里加载 RN 容器。大致思路是,在 onWindowStageCreate 回调里创建 RNHost,加载 RN 入口文件,然后把 RN 根视图挂到窗口的 RootNode 上。加载入口文件时可以加载本地 bundle,也可以连接 Metro 开发服务器。开发阶段我建议连 Metro,热更新省时间;发布之前再改成加载打包产物。
第四步:配置 module.json5,声明必要的权限。比如相机扫码组件要申请 camera 权限,定位组件要申请 location 权限。
3.3 RN bundle 的加载方式
调试时 exec 开发服务器,生产时加载本地 bundle。本地 bundle 生成后放到 harmony 工程的 resources/rawfile 目录下。需要注意的是,bundle 文件名要和你加载代码里写的名字一致,Android 那边通常叫 index.android.bundle,鸿蒙这边为了区分可以起名叫 index.harmony.bundle。别小看这个命名,我见过好几个同事因为沿用旧文件名,改了代码不生效,排查半天才发现加载文件指向错误。
另外,JS 引擎的选择也影响打包体积和启动速度。鸿蒙适配层对 Hermes 引擎的支持一直在完善,你如果追求启动速度可以上 Hermes,但要注意第三方原生模块是否兼容;如果求稳,先用系统默认的 JSC 跑通逻辑,再考虑换引擎。引擎是全局配置,建议尽早决定,晚换成本更高。
4. 桥接层的设计与通信协议
4.1 JSI 和 TurboModule 简化认知
RN 的桥接在历史上经历过两代。老一代是 AsyncBridge,对象逐层拷贝,性能一般。新一代引入了 JSI,允许 JS 直接持有 C++ 对象的引用,调用原生方法更高效。鸿蒙适配层也逐步跟进 JSI 方案。
不过做业务集成时,你不一定要手写 C++ 层。适配层已经把你的自定义原生组件和原生模块注册好了,你只需要按约定去实现接口。我建议你至少要理解三件事:
第一,JS 调用鸿蒙原生模块是双向的,不是单向通知。你可以在 JS 里调用鸿蒙方法,鸿蒙侧也能把事件主动发给 JS。 第二,跨语言调用要序列化,简单类型没问题,复杂对象要小心。 第三,异步任务默认回到 JS 线程,RN 侧 setState 才能安全执行。不要在原生回调里直接操作 UI,有崩溃风险。
4.2 数据契约:统一用 JSON 说话
两个端通信最烦的是类型不一致。ArkTS 的 API 有时返回的是 Map、Set 或自定义类对象,RN 侧拿到的是纯 JavaScript 对象。你强行直接返回自定义类,桥接层容易报序列化异常。
我在项目里定了一条铁律:所有桥接接口的入参和出参统一用 JSON 对象。比如原生扫码模块,返回的不直接是 Bitmap 或 PixelMap,而是:
{ "width": 720, "height": 1280, "exif": {}, "path": "file://..." }这样 RN 侧拿到的是一个无歧义的普通对象,后续要展示图片就传 path,要做分析就传 buffer 路径。实践下来,90% 的桥接异常都和“类型不符合契约”有关,统一 JSON 之后这类问题几乎清零。
RN 侧调用原生方法的代码大概长这样:
import { NativeModules } from 'react-native'; const { HmsScanModule } = NativeModules; const scanResult = await HmsScanModule.startScan({ scanType: 'qr', timeout: 10000, }); // scanResult 就是 JSON 对象 console.log('扫码结果', scanResult.code);鸿蒙侧对应实现一个类,导出指定名称,桥接层会自动绑定。方法内部用 TaskPool 或异步线程做耗时操作,不要阻塞 UI 线程。
4.3 事件回传机制
原生事件回传 RN 有两种常见路径。一种是 Promise/回调,适合一次性结果,比如扫码完成。另一种是持续事件流,适合相机预览帧率变化、定位坐标回调、蓝牙设备状态变化。
持续事件流如果频繁地从鸿蒙侧往 JS 侧发送,要注意节流。我在项目里遇到过一个问题:定位组件每秒回调一次坐标,JS 侧每次回调都触发 setState,页面明显卡顿。后来在原生侧加了滤波,只有位移超过阈值才上报,流畅度立刻改善。
RN 侧订阅事件时还要记得在组件卸载时取消订阅。常见的写法是 useEffect 里返回清理函数,把 removeSubscription 执行掉。漏这一步的话,页面销毁后还会收到事件,引发“对已卸载组件执行状态更新”的警告。
5. 实操:把一个鸿蒙摄像头组件桥接给 RN
前面原理讲再多,不如跑通一个完整案例。这里假设业务需求是:RN 页面里嵌一个“扫码取件”模块,扫描成功后把二维码内容回传给 JS,前端拿去做业务请求。
5.1 鸿蒙侧组件封装
鸿蒙侧的组件是一个 ArkTS 自定义组件。最外层是一个 Column 容器,内部放一个相机预览区 XComponent,下面放一个“开始扫码”按钮。为了完整性,我把核心结构写出来,真实项目里会再封装相机管理逻辑。
@Component export struct ScanBox { @Prop width: number = 280 @Prop height: number = 360 @State scanStatus: string = 'idle' private onScanResult?: (code: string) => void build() { Column({ space: 12 }) { Column() .width(this.width) .height(this.height) .backgroundColor('#000') .borderRadius(12) Text(this.scanStatus === 'scanning' ? '正在扫描...' : '点击开始') .fontSize(16) .fontColor('#333') Button('开始扫码') .onClick(() => { this.scanStatus = 'scanning' // 调起相机扫描,得到结果后回传 }) } .width('100%') .padding(16) } }真实环境下,你还要申请相机权限、处理预览生命周期、在组件销毁时释放相机资源。注意代码里有一个 onScanResult 回调属性,这是给桥接层用的,用来把扫描结果抛到 RN 侧。
5.2 注册原生组件和原生模块
自定义组件要想被 RN 使用,得在鸿蒙侧注册。注册原生组件时,指定组件名,比如 ScanBoxView,RN 侧用 requireNativeComponent('ScanBoxView') 来引用。注册原生模块时,指定模块名,比如 HmsScanModule。RN 侧通过 NativeModules.HmsScanModule 调用。
注册完成后,鸿蒙侧需要实现组件属性的映射。比如 RN 侧传过来的 width、height,要更新到组件 @Prop 上。事件则通过 UIManager 的回调接口发送给 JS 侧。
实际操作中我碰到最多的坑是把“原生模块”和“原生组件”搞混。如果一个功能只是调用系统能力,不需要内嵌 UI,就用原生模块;如果一个功能要嵌进页面里作为 UI 的一部分,就用原生组件。扫码场景我两个都用了:原生组件负责展示 UI,原生模块负责调起扫码能力和返回结果。
5.3 RN 侧封装组件
RN 侧封装一个组件很直接。引入 requireNativeComponent,创建一个包装组件,处理属性和事件。
import React from 'react'; import { requireNativeComponent, ViewPropTypes } from 'react-native'; const ScanBoxNative = requireNativeComponent('ScanBoxView'); export function ScanBox({ width, height, onScanResult }) { return ( <ScanBoxNative style={{ width, height }} width={width} height={height} onScanResult={(e) => { if (typeof onScanResult === 'function') { onScanResult(e.nativeEvent.code); } }} /> ); }调用原生模块的代码:
import { NativeModules } from 'react-native'; const { HmsScanModule } = NativeModules; export async function startScan() { const result = await HmsScanModule.startScan(); return result; }这里有两个实践要点。第一,事件回调的命名不能随意,要跟鸿蒙侧注册的事件名一致,否则事件发不出来。第二,组件属性名和鸿蒙侧的 @Prop 变量名要统一。我建议列一张映射表,摆在代码库的 docs 目录里,后续新增字段时按表维护,少踩很多坑。
5.4 参数与事件对照表
| 方向 | 名称 | 类型 | 说明 |
|---|---|---|---|
| RN 到鸿蒙 | width | number | 组件展示宽度 |
| RN 到鸿蒙 | height | number | 组件展示高度 |
| RN 到鸿蒙 | scanType | string | 扫码类型:qr / barcode |
| 鸿蒙到 RN | onScanResult | event | 扫码成功回调,携带 code |
| 鸿蒙到 RN | onScanError | event | 扫码失败回调,携带 message |
| 原生模块 | startScan | Promise | 调起原生扫码页面,返回结果 |
表格第一行到第三行是属性,从 RN 传到鸿蒙原生组件;第四、五行是事件,从鸿蒙原生组件发回 RN;第六行是原生模块方法。按这个结构去设计,后面增加新需求就清楚多了。
6. 调试、兼容性与高频问题排查
6.1 调试手段与日志分析
热更新和日志是集成调试的关键。开发环境连上 Metro 后,RN 的 console.log 能直接在终端看到,应用内报错也会显示红屏。鸿蒙侧的原生日志要注意一件事:它默认不是输出到 RN 的 Metro 终端,而是进系统日志。你要先在 IDE 里看 Har 日志,再对照 RN 侧 console 日志,两边时间戳对齐才能排查跨端问题。
我调试时会先确认一条链路通不通:JS 调原生模块,原生模块日志打印 “called”,JS 收到返回,console 打印结果。如果原生日志出现,JS 没收到返回,大概率是 Promise 没 resolve 或者回调方法名不对。如果原生日志都没出现,那就是注册失败或模块名不匹配,去检查注册配置。
还有一个很实用的排查开关:把适配层的详细日志打开,能打印出组件注册、事件绑定、方法调用整个链路。虽然日志量大,但定位问题非常有用,排查完关掉就行。
6.2 生命周期错位是隐性大坑
RN 的组件树是动态的。页面 A 切换到页面 B,A 的组件不一定销毁;快速滑动列表时,列表项组件反复 mount/unmount。鸿蒙原生组件如果不好好处理生命周期,会遇到几种现象:
- 相机预览偶发黑屏,重新进页面就好了。
- 定位模块连接泄漏,连续进出页面后 CPU 占用越来越高。
- 自定义组件销毁时 UI 线程崩溃,报 “Component has been removed”。
这些都是生命周期错位导致的。解决思路是:把鸿蒙组件的资源初始化放在 setup,资源释放放在 teardown,不要依赖 RN 侧的 componentWillUnmount,也不要依赖鸿蒙侧的 onDestroy。桥接层负责在两端之间同步状态,类似于一个中间协调者。
另外,RN 侧在列表里高频使用自定义组件时,要确保组件是轻量的。我在 RecyclerListView 里加过一个带实时预览的自绘图表组件,滑动时帧率掉到 40 以下。后来把预览拆成独立子组件,只在可见区域内挂载,帧率恢复稳定。
6.3 高频问题排查速查表
| 现象 | 可能原因 | 处理建议 |
|---|---|---|
| RN 报 “ScanBoxView not found” | 组件未注册或模块名不一致 | 检查鸿蒙侧注册表与 JS 名称是否一致 |
| 事件收不到 | 事件名不一致或未绑定 | 核对映射表,确认桥接层回调已绑定 |
| 相机黑屏 | 权限未申请或预览生命周期错位 | 检查 module.json5 权限,调整 setup/teardown |
| 调用原生模块超时 | 蓝牙/定位等系统服务未就绪 | 原生侧 catch 错误并 reject Promise |
| 页面切换后卡顿 | 资源未释放 | 检查 teardown 是否执行 |
| JS 拿到 undefined | 序列化失败 | 统一返回 JSON 对象 |
| 启动时白屏时间过长 | bundle 加载路径不对 | 确认产物文件名和加载逻辑匹配 |
| 真机崩溃且本地不重现 | 设备系统版本差异 | 分级适配 API,排查原生调用 |
这张表是我每次攻坚问题时的起点。把现象、原因、处理建议统一列出来,团队协作时沟通成本低很多。
6.4 兼容性检查清单
鸿蒙系统更新频繁,新的 SDK 版本可能调整组件接口。我的做法是每季度维护一份兼容性清单:
- 锁定 RN 版本和鸿蒙适配层版本,不轻易同时升。
- 用真机矩阵测试:模拟器 vs 真机、大屏平板 vs 手机。
- 检查所有自定义组件的 setup/teardown 是否幂等,重复调用不崩溃。
- 检查权限用到了哪些,配置文件里是否都声明齐全。
- 检查事件订阅是否在组件卸载时全部取消。
- 检查 bundle 是否同时支持开发服务器和本地文件两种加载方式。
这套清单看起来繁琐,但真正上线前能避免大半线上问题。移动端开发最怕的不是新功能,而是“本地跑得好好的,线上崩了一大片”。
7. 从实践里沉淀的几条经验
最后分享几个从项目里总结出来的小经验,不是空泛建议,是每次返工后真正记进备忘录的东西。
第一,先跑通一个最小 Demo,再往业务扩展。RN 接鸿蒙这件事,影响最大的不是编码,而是整个调试链路。环境变量、签名、加载方式、真机日志,任何一环不对都会让你怀疑人生。先把一个只有一个原生组件的项目从创建到真机跑通,后面再加需求速度会快很多。
第二,设计和维护好两端的数据契约。项目初期我们没意识到这个问题,桥接方法返回随意类型,结果出现一堆“undefined”“NaN”问题。后来只在契约表里维护,每次改动经过评审,问题立刻少了。
第三,善用自定义状态管理来隔离降级。有些鸿蒙原生能力在低版本系统上不支持,我会在 JS 侧做降级逻辑:支持原生模块就走原生,不支持就跳 H5 页面或者禁用按钮。这样不会因为系统差异影响整个应用体验。
第四,把常用的封装沉淀成内部组件库。我们封装过一次相机预览之后,后面地图选点、蓝牙扫描、分布式文件选择,全部复用同一套桥接模式。到一个稳定阶段后,你基本只需要写 ArkTS 组件体量小很多,主要精力都在 RN 侧的 props 和 events 设计上,效率提升非常明显。
这套流程走下来,我对“ React Native 开发鸿组件”这件事的判断是:它不是不可逾越的高墙,但确实需要两套知识体系协同。跨平台开发的优势依然存在,只是跨到鸿蒙时,你要先承认它的原生世界和 Android 不完全一样,然后用一套严谨的桥接思路把它拢进 RN 的轨道里。只要前期的工程组织和数据契约做扎实,后续业务扩展会顺很多。我也还在持续观察鸿蒙生态的迭代,之后有新坑、新解法,再回来同步。