最近好几个做跨端开发的朋友都在问我同一个问题:React Native 应用到底能不能跑在鸿蒙设备上,要是能跑,那些用 ArkTS 写的鸿蒙原生组件又该怎么在 RN 里调用。问的人多了,我发现大家对这件事的认知普遍停在“鸿蒙是个新系统,RN 肯定还八字没一撇”的阶段,其实这个赛道已经悄悄跑起来很久了。我去年开始在一个工业级 App 里做 RN 与鸿蒙混合开发的落地,从环境搭建到组件桥接再到分布式能力调用,踩过的坑比想象中多,但走通之后回头看,这套技术栈的成熟度远比外界感知的要高。
这篇文章我不打算堆概念,就围绕“在 React Native 里开发鸿蒙组件”这条主线,把鸿蒙开发的基础认知、RN 工程怎么和鸿蒙工程互相握手、自定义鸿蒙组件怎么暴露给 JS 调用,以及我实测中遇到的坑和性能调优经验,一次性说清楚。适合三类人看:一是要啃下鸿蒙市场但不想放弃 RN 技术栈的团队,二是正在调研跨端架构选型的技术负责人,三是想搞明白 RN 和鸿蒙到底怎么通信的客户端开发。
1. 被低估的鸿蒙技术底座:先搞懂 ArkTS 和 ArkUI 再谈桥接
很多人一上来就查“RN 怎么集成鸿蒙”,但我建议先把鸿蒙侧的基础概念过一遍,否则后面写组件的时候连参数类型怎么定义、状态怎么管理都会卡壳。鸿蒙开发的核心语言是 ArkTS,它是 TypeScript 的超集,在 TS 基础上加了一套声明式 UI 语法 ArkUI 和状态管理机制。注意这个词“超集”,意味着你在鸿蒙里写的 UI 代码长得和 TSX 很像,但又不是一回事。
1.1 ArkTS 到底和 TypeScript 差在哪
ArkTS 的语法风格接近 SwiftUI 和 Compose 的混合体,用@Component装饰器声明组件,用@State、@Prop、@Link这些装饰器管理状态。比如下面这一段:
@Component export struct Greeter { @Prop message: string = 'Hello'; @State count: number = 0; build() { Column() { Text(this.message) .fontSize(20) Button(`Clicked ${this.count} times`) .onClick(() => { this.count++; }) } } }这段代码在语法上跟 TS 差不多,但关键在于 ArkUI 框架自己有一套 UI 刷新机制,跟 React 的虚拟 DOM 渲染管线完全不同。RN 的 JS 侧通过 Bridge 或 JSI 跟原生层通信,而 ArkUI 是鸿蒙原生渲染引擎直接驱动。所以做 RN 集成时你不能想当然地认为“把 RN 的 View 映射成 ArkUI 的 Column 就行”,两个渲染管线的对接逻辑需要单独处理。
还有一个容易踩的概念坑:状态管理的响应式机制。ArkTS 里@State只有被 UI 直接读取的变量才触发刷新,如果你在非 UI 回调里改了深层嵌套对象,界面不会自动更新。这跟 React 的 setState 不太一样,React 至少帮你做了浅比较,ArkTS 的策略更保守。
1.2 分布式能力不是营销词,而是鸿蒙的底层 IPC 基因
既然标题提到了“分布式操作”,我必须说一下这对 RN 开发者的实际影响。鸿蒙的分布式能力指的不只是多设备协同那种炫酷 Demo,它底层的分布式软总线提供了跨设备的能力调用机制。这意味着你在鸿蒙原生侧开发的组件,天然可以调用@ohos.distributedDeviceManager这类 API 去发现附近的设备、跨端拉起 Ability。
这里给 RN 开发者的建议是:前期如果只是做 UI 组件桥接,可以先不管分布式能力,把它当成普通原生 Module 暴露给 JS 即可。但架构设计上要留好接口位,因为一旦你需要在鸿蒙设备上做多端协同的业务,比如手机采集数据平板展示,这类能力只能在原生侧实现,RN 侧只负责下发指令。
注意:鸿蒙的 Ability 分为 Stage 模型和 FA 模型,新项目用 Stage 模型。Stage 模型的 UIAbility 生命周期跟 RN 的 Activity/Fragment 生命周期怎么协调,是后面的重点问题。
2. RN 与鸿蒙握手:从头理解 OpenHarmony 的 RN 适配层
讲完鸿蒙侧基础,可以进入正题了。RN 想跑在鸿蒙上,靠的是 OpenHarmony 社区的 React Native 适配层,这个项目把 RN 的渲染目标从 Android/iOS 的原生 View 映射到了 ArkUI 组件上。我以当前主流的 RN 0.72 版本为例,讲清楚这套适配层的架构。
2.1 适配层到底适配了什么
RN 原生侧有两块核心需要对接:UIKit 和 NativeModule。UIKit 是 RN 用来创建原生视图的工厂,NativeModule 是 JS 调用原生能力的通道。OpenHarmony 适配层做的主要工作就是实现这两套接口的鸿蒙版本。
从架构图上看是这样的链路:JS 引擎(Hermes)里跑 React 业务代码 → 通过 JSI 调用 C++ 层 → C++ 层通过 NAPI 桥接到 ArkTS 运行时 → ArkTS 创建 ArkUI 组件。这中间相比 Android 版多了一层 NAPI 转换,因为 ArkTS 运行时跟 JSI 不能直接互通。
这也是为什么有些 RN 依赖库在鸿蒙上跑不了的原因:那些库如果直接操作了 Android View 或 iOS UIView 的底层方法,适配层没法帮你翻译成 ArkUI 语法。第三方的 UI 库尤其容易中招,像 react-native-svg 这种直接绘制 Canvas 的库,适配鸿蒙需要单独开发 ArkUI 绘图模块。
2.2 工程结构怎么组织:一套代码还是两套代码
这个问题我被问得最多。答案是:工程结构上要两套,但业务代码层面可以尽量一套。鸿蒙侧的 RN 适配层要求你的鸿蒙工程里有一个专门的 Module 来承载 RN 容器,这个 Module 负责初始化 RN 运行时、加载 JS Bundle、管理 RN 页面生命周期。
我项目的实际结构是这样:
project_root/ ├── harmony/ # 鸿蒙原生工程 │ ├── entry/src/main/ets/ # ArkTS 源码 │ ├── rnmodule/ # RN 容器模块,负责桥接 │ └── build-profile.json5 ├── react_native/ # RN 业务工程 │ ├── index.js │ ├── src/ # JS 业务代码 │ └── package.json └── shared/ # 可共享的构建脚本、配置这样组织的好处是鸿蒙工程师和 RN 工程师可以并行开发,冲突面最小。坏处是如果你的团队没有鸿蒙开发经验,学习曲线会比较陡。但这条路的长期收益是确定的,因为鸿蒙原生侧的业务逻辑(比如分布式能力、硬件调用)迟早要沉淀成原生 Module,工程隔离反而有利于后期维护。
3. 你写的第一个鸿蒙组件:以“自定义日历”为例的完整桥接流程
理论讲完,上实操。我写一个“自定义日历组件”的桥接全流程,从 ArkUI 原生实现到 JS 调用,一步步拆解。选日历这个例子是因为它涉及 UI 渲染、事件回调、参数传递三个最核心的桥接场景。
3.1 先用 ArkTS 写一个鸿蒙原生日历组件
这段代码需要放在鸿蒙工程的entry/src/main/ets/components/目录下:
@Component export struct CalendarView { @Prop selectedDate: string = ''; @State currentMonth: number = new Date().getMonth(); @State currentYear: number = new Date().getFullYear(); onDateChange?: (date: string) => void; build() { Column({ space: 10 }) { // 日历头部 Row() { Button('<') .onClick(() => this.changeMonth(-1)) Text(`${this.currentYear}年${this.currentMonth + 1}月`) .fontSize(18) Button('>') .onClick(() => this.changeMonth(1)) } .width('100%') .justifyContent(FlexAlign.SpaceBetween) // 日期网格简略写法 Grid() { ForEach(this.getDaysArray(), (day: number) => { GridItem() { Text(`${day}`) .width(40).height(40) .textAlign(TextAlign.Center) .backgroundColor(day === parseInt(this.selectedDate.split('-')[2]) ? '#007AFF' : '#F0F0F0') .borderRadius(20) .onClick(() => { const dateStr = `${this.currentYear}-${String(this.currentMonth + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`; this.onDateChange?.(dateStr); }) } }) } .columnsTemplate('1fr 1fr 1fr 1fr 1fr 1fr 1fr') .rowsGap(10) .columnsGap(10) } .padding(16) } changeMonth(delta: number) { const newDate = new Date(this.currentYear, this.currentMonth + delta, 1); this.currentYear = newDate.getFullYear(); this.currentMonth = newDate.getMonth(); } getDaysArray(): number[] { const daysInMonth = new Date(this.currentYear, this.currentMonth + 1, 0).getDate(); const firstDay = new Date(this.currentYear, this.currentMonth, 1).getDay(); const days: number[] = []; for (let i = 0; i < firstDay; i++) days.push(0); for (let i = 1; i <= daysInMonth; i++) days.push(i); return days; } }这段代码里有几个细节我强调一下:@Prop selectedDate是从外部传入的属性,如果用@Link则变成双向绑定。在桥接到 RN 时我们一般用@Prop单向传递,因为 JS 侧本身有自己的状态管理,不需要原生侧反向改数据。onDateChange是回调函数,这个是桥接的关键出口。
3.2 用 NAPI 把组件绑定到 RN 的 ViewManager
ArkUI 组件写好后,还需要包一层 NAPI 实现,让 RN 的 JS 代码能找到这个组件。在rnmodule/src/main/cpp/下创建CalendarViewManager.cpp:
#include "RNOH/arkui/ArkUIComponentInstance.h" #include "RNOH/generated/BaseCalendarViewComponentInstance.h" namespace rnoh { class CalendarViewComponentInstance : public BaseCalendarViewComponentInstance { public: CalendarViewComponentInstance(Context ctx) : BaseCalendarViewComponentInstance(std::move(ctx)) {} void onDateChange(std::string date) { // 调用 RN 侧的事件回调 if (m_eventEmitter != nullptr) { m_eventEmitter->onDateChange(date); } } void onPropsChanged(facebook::react::Props::Shared props) override { auto calendarProps = std::dynamic_pointer_cast<facebook::react::CalendarViewProps const>(props); if (calendarProps != nullptr) { // 将 JS 传入的 selectedDate 同步给 ArkUI 组件 m_arkuiNode->setSelectedDate(calendarProps->selectedDate); } BaseCalendarViewComponentInstance::onPropsChanged(props); } }; } // namespace rnoh这段代码要配合 Codegen 使用。RN 新架构(Fabric)引入的 Codegen 可以根据你的 JS 组件定义自动生成 C++ 模板代码,你只需在生成的基类上填充业务逻辑即可。这也是为什么鸿蒙适配层能够较快跟上 RN 版本迭代的原因——大量模板代码是自动生成的,人工只需要写桥接层的具体逻辑。
3.3 JS 侧最终如何调用
原生侧全部就位后,JS 侧的代码可以做到跟普通 RN 组件几乎无差别:
import React from 'react'; import { requireNativeComponent } from 'react-native'; const CalendarView = requireNativeComponent('CalendarView'); export function AppCalendar(props: { selectedDate: string; onDateChange: (date: string) => void }) { return <CalendarView {...props} />; }这里有个重要提醒:如果你用的是旧架构(Legacy Architecture),要用requireNativeComponent;如果是新架构 Fabric,推荐用codegenNativeComponent。鸿蒙适配层同时支持两种架构,但官方推荐新架构,因为新架构的 JSI 直连性能优于旧架构的异步 Bridge。
4. 从“能用”到“好用”:事件分发与生命周期对齐的实战优化
组件能跑起来只是第一步,真正花时间的是让它在真实业务里稳定运行。这章节我写几个最影响体验的技术细节。
4.1 事件回调的线程切换:一个回调引发的崩溃
RN 的 JS 运行在 JS 线程,ArkTS 组件的点击事件跑在 UI 线程。直接跨线程回调会导致崩溃或数据错乱。你必须在 NAPI 层把事件投递到正确的线程执行。鸿蒙适配层提供了一套EventEmitter机制,建议所有 JS 回调通过它来转发,不要自己创建线程去直接调用 JS 方法。
一个我在生产环境遇到过的经典问题:用户在日历上快速连续点击多个日期,回调事件在 JS 侧被频繁触发,导致 RN 的 setState 批量更新频繁,页面卡顿。解决办法是在鸿蒙侧对回调做节流(throttle),连续点击时只发送最后一次日期给 JS 侧。
4.2 生命周期对齐:为什么你的 RN 页面在鸿蒙上会内存泄漏
RN 页面在 Android 上对应一个 Activity,在鸿蒙上对应一个 UIAbility。UIAbility 在退到后台时可能被系统回收,但 RN 的 JS 引擎不一定知道这事。如果你在 RN 页面里注册了监听器,比如AppState.addEventListener,需要在 UIAbility 的onDestroy里显式调用 RN 容器的销毁方法。
鸿蒙的 UIAbility 生命周期调用顺序跟 Android 有微妙差异。Android 的onPause在鸿蒙对应onBackground,但onBackground之后鸿蒙系统可能不会立刻触发销毁,而是把 Ability 放入缓存。这本身没问题,问题在于 RN 容器如果占用了大量内存,可能会触发鸿蒙的低内存回收机制,导致整个应用被杀掉。
我的处理方案是在鸿蒙侧的 RN 容器模块里拦截onBackground和onForeground,手动同步给 RN 的AppState。这样 JS 侧至少能感知到页面的前后台状态,可以主动释放不必要的图片资源。
4.3 性能调优:减少 NAPI 层的调用频率
跨语言调用是有成本的。每从 RN 调用一次原生方法,都要经过 JSI → C++ → NAPI → ArkTS 的链路。我把一个高频调用的业务场景优化前后的数据列出来:
| 场景 | 优化前 | 优化后 |
|---|---|---|
| 日历组件每月刷新 | 30 次 NAPI 调用 | 1 次 NAPI 调用 |
| 数据传递方式 | 单条数据 JSON 序列化 | 批量数组传递 |
| 事件回调频率 | 每次点击都回调 | 节流后合并回调 |
| UI 刷新触发 | 每次 setState 都更新原生视图 | 后端携带批次号,仅最终态刷新 |
优化思路很简单:把多次调用合并成一次批量操作。鸿蒙侧提供BatchNativeCommand接口,可以把多个 UI 操作打包一起执行。RN 侧用unstable_batchedUpdates合并 JS 的多次 setState,两个机制配合,UI 的渲染效率会有肉眼可见的提升。
5. 启动白屏的深度排查:一场横跨 JS 引擎与鸿蒙渲染层的冒险
说点实际的坑。热搜词里“react native 启动白屏”在鸿蒙上出现的频率远高于 Android/iOS,因为这个平台的 RN 容器涉及三个运行时的初始化:ArkTS 运行时、NAPI 桥接层、JS 引擎。任何一层初始化慢了都表现为白屏。
5.1 从日志看本质:到底卡在哪个阶段
我在排查一个生产问题的记录里看到,从点击 App 图标到首页第一帧渲染,时间线大致是:
- 0~200ms:鸿蒙系统启动 UIAbility
- 200~800ms:初始化 ArkTS 运行时、加载 RN 容器模块
- 800~1500ms:JS Bundle 读取、Hermes 引擎初始化
- 1500~3000ms:解析执行 JS、异步请求接口
- 3000ms+:首次渲染完成(如果卡在这里就是白屏问题)
注意关键点:RN 旧架构的 JS Bundle 读取是异步的,JavaScriptCore/Hermes 引擎初始化又是另一个耗时大头。在鸿蒙上,NAPI的初始化还会额外增加 100~300ms,这是它在 Android 上没有的开销。
如果不做任何优化,白屏时间非常可观。我见过返回快的设备白屏 5 秒,返回慢的老设备要 8 秒以上。这个成绩放在移动端完全没法接受,必须处理。
5.2 白屏排查的完整链路:我是如何一层层剥开的
这部分价值最高,我按实际排查顺序写。
第一步,先确认是不是 JS Bundle 加载过慢。我在index.js的入口文件顶部加了console.time('BundleStart'),又在根组件挂载的useEffect里加了console.timeEnd('BundleStart')。测定后发现入口代码开始执行到根组件挂载完成花了 800ms,这个时间在 Android 上只要 300ms。差别在于鸿蒙侧读取 Bundle 走的文件 IO 路径不同。
第二步,我用鸿蒙的 SmartPerf 工具抓取 CPU 和内存曲线,发现ArkTS 运行时 GC(垃圾回收)在启动阶段出现了多次抖动。原因是 RN 的 JS Bundle 解析会创建大量临时对象,这些对象的生命周期跨了 NAPI 层,导致 ArkTS 的内存管理压力骤增。
第三步,定位到问题在 NAPI 层的类型转换。JS 侧传入的 props 对象在转换成 ArkTS 的 Record 时,如果字段嵌套过深,NAPI 会逐层做类型检查,非常耗时。我看了代码,发现有个同事把路由参数对象原封不动传给了原生组件,里面嵌套了四层,每次渲染都要做完整转换。
最终采取的方案组合如下:
// 1. 在 Metro 打包时开启 Hermes 的预编译,减少运行时解析 // metro.config.js module.exports = { transformer: { hermesParser: true, }, }; // 2. 启动阶段只加载首屏需要的组件,其他用懒加载 const HomeScreen = React.lazy(() => import('./screens/HomeScreen')); // 3. 对传入原生组件的 props 做扁平化处理,避免深层嵌套 // 避免传整个对象,改成传 string 或 number 等基础类型这三招加在一起,白屏从原始的 5 秒压到了 2.3 秒,基本能接受。进一步做原生启动帧优化则是另一个话题了。
5.3 一个容易被忽略的坑:ArkTS 侧变量提升导致 JS 侧拿不到数据
这个问题非常隐蔽。我遇到过的情况是:RN 页面调用了鸿蒙 Module 去读取设备信息,但拿到的是空对象。排查了很久发现是 ArkTS 侧的变量声明时机问题——undefined和null的语义在 ArkTS 和 JS 之间不完全一致,JS 的undefined在传给 ArkTS 后会被转成null,反过来的顺序亦然。如果你的业务逻辑里用=== undefined做判断,在鸿蒙上永远走不进分支。
解决方法是强制统一判空标准,所有跨语言传递的可选值都只用== null判断。虽然 ESLint 可能会提示别用双等号,但这是在跨语言场景下最稳妥的写法。
6. 扩展鸿蒙差异化能力:让 RN 业务吃上分布式红利
既然选择了鸿蒙,只做 UI 适配没有意义,你必须把它差异化的能力用起来。RN 社区的第三方库覆盖不到那些鸿蒙独有的 API,这反而是原生团队的机会。
6.1 把分布式文件服务封装成 RN Module
鸿蒙的分布式文件服务可以让你像访问本地文件一样访问其他设备上的文件。这个能力要桥接到 RN 侧,思路和普通 NativeModule 一样,但要注意权限声明。
// 鸿蒙侧 Module 示例 export class DistributedFileModule { async readRemoteFile(deviceId: string, path: string): Promise<string> { const remoteFile = await distributedFile.getRemoteFile(deviceId, path); return remoteFile.readText(); } } // RN 侧调用 import { NativeModules } from 'react-native'; const { DistributedFileModule } = NativeModules; const content = await DistributedFileModule.readRemoteFile(deviceId, '/data/storage/el2/base/files/a.txt');这个能力用在“手机拍的照片在大屏上直接编辑”的场景非常合适。你把手机端的文件通过分布式文件服务暴露给平板,RN 侧只负责 UI 层的交互,实际文件 IO 全在原生侧。
6.2 多设备协同的标签页:一次跨端调起实验
再举个例子。我之前做过一个实验性的多设备协同功能:手机上的 App 能直接调起平板上的同一个 App 并传输参数。鸿蒙侧用want拉起对端 UIAbility,RN 侧只需暴露一个startRemoteAbility的方法。
实现时最麻烦的是设备选择。鸿蒙要求你首先通过分布式设备管理 API 获取在线设备列表,弹窗让用户选择目标设备,然后才能发起连接。这个流程涉及设备信任关系,第一次连接要在系统设置里手动授权。桥接时有一个细节:deviceId是动态变化的,每次获取在线设备列表都要重新查询,不能缓存固定值。
6.3 原子化服务的轻量体验
鸿蒙的原子化服务可以免安装运行,你可以把 RN 的某个典型场景,比如商品详情页,做成一个原子化服务卡片。注意,服务卡片本身非常轻量,不建议直接把整个 RN 引擎塞进去跑,那样启动就失去意义了。更好的做法是卡片只展示关键信息,点击卡片时才拉起完整的 RN 页面。这里又要回到“RN 容器模块”的设计上:确保它能在运行时按需加载,而不是冷启动时全部初始化。
7. 我踩过的兼容性教训:版本搭配比想象中更重要
最后一部分写版本和生态的兼容性,这部分教训是用真金白银换来的。RN 在鸿蒙上的可用性高度依赖 OpenHarmony 适配层的版本,它不像 Android/iOS 那样有 Google/Apple 官方兜底,社区版本迭代节奏有时跟 RN 官方不同步,适配到一半发现断层是常事。
7.1 版本管理器选型:为什么我放弃了 harmonybrew
之前热搜词提到 harmonyos 7 部署 harmonybrew 失败,我在早期也踩过。harmonybrew 是个类似 Homebrew 的鸿蒙包管理器,但它在实际工程中表现不稳定,尤其处理 RN 适配层的 native 依赖时经常出现符号找不到的问题。后来我改用手动管理,配合鸿蒙的 ohpm 包管理器,虽然多花了一点时间,但可控性高得多,至少出了问题我能确定是自己代码的锅还是配置的锅。
建议团队在立项时就把鸿蒙侧依赖版本冻结,不要跟随 latest。RN 的 minor 版本升级往往伴随着 JSI 接口变化,而鸿蒙适配层要跟上这些变化通常有滞后。我在实践中锁定在这个组合:RN 0.72 + OpenHarmony RN 适配层 0.72.x + DevEco Studio 4.0 Release。在升级 RN 版本前,先去 OpenHarmony 的仓库确认适配层是否支持,再动升级计划。
7.2 社区库在鸿蒙上的“可用性星级”
我整理了一张自己实际测过的社区库兼容性表,注意这是基于我当时的版本,最新情况建议以各库仓库的 issue 为准:
| 社区库 | Android/iOS | 鸿蒙可用性 | 替代方案 |
|---|---|---|---|
| react-native-svg | 稳定 | 需定制 ArkUI 绘图模块 | 自己桥接鸿蒙 Canvas |
| react-native-webview | 基本可控 | 性能极差,JSI 通道开销大 | 使用系统 Web 组件接原生跳转 |
| react-native-gesture-handler | 稳定 | 适配层已完成基础手势 | 复杂手势建议原生实现 |
| @react-navigation/bottom-tabs | 稳定 | 可运行,动画略卡 | 调整动画为无动画或原生 Tab |
| react-native-reanimated | 稳定 | 性能瓶颈明显 | UI 动画尽量用 ArkUI 实现 |
看到这张表不要灰心。我的结论是:业务简单的 App 可以大胆尝试,重度依赖动画或绘图库的应用需要先在鸿蒙上做技术验证再决定切换成本。
7.3 混合团队的分工建议
我们团队从功能稳定后的组织协作方式,可以参考:
- JS 工程师只写 RN 业务代码,不碰鸿蒙工程,降低上手门槛
- 鸿蒙工程师负责把原生能力封装成 NativeModule,并维护桥接层
- 性能问题由鸿蒙侧主导排查,因为大部分瓶颈都在 NAPI 层
- 新增 RN 依赖库之前,必须先在鸿蒙真机上验证,不能只看文档
这个分工模式跑了一个季度,核心指标是:鸿蒙上 Crash 率从早期的 2.1% 降到 0.4%,首屏加载时间从慢一倍追到与 Android 基本持平。说明这套流程只要有效维护,RN 和鸿蒙并不是互斥的选择,反而能各取所长。
个人而言,我越来越觉得跨端开发的下一阶段不是“一码多端”,而是“同一业务逻辑,多端各自渲染,通过桥接层保持体验一致”。RN 开发者现在研究鸿蒙适配,就是在给这个判断提前下注。就写到这里,剩下的细节我建议你手头真有真机项目时,边做边体会。