做移动端开发的人,最近应该都躲不开一个问题:手里的React Native项目到底要不要适配鸿蒙。鸿蒙OS的版本一直在往前赶,市场上的设备数量也在涨,可让团队用一套全新语言把现有功能重写一遍,这个成本绝大多数公司都扛不住。于是“在React Native中开发鸿组件”这条路线冒了出来:保留RN的跨端代码,把鸿蒙的系统能力和UI组件封装成RN能直接调用的模块,一套代码同时覆盖安卓、iOS和鸿蒙三端。
我前后在几个项目里做过这件事。一开始以为只是像Android那样换一个底层实现,深入进去才发现,鸿蒙对RN来说不是“另一个平台”那么简单,它的应用模型、UI框架和跨设备能力都自成一套。这篇文章把我从零到能跑通的完整过程写下来,包括鸿蒙开发的基础认知、集成架构怎么选、桥接层怎么建,以及文档里不会写的坑。如果你也是RN背景,准备让项目支持鸿蒙,这篇能帮你少走不少弯路。里面提到的具体API以你们使用的SDK版本为准,但思路和排查方法可以直接照搬。
1. 为什么React Native需要趟鸿蒙这趟水
1.1 跨端红利保不住,就得重写
先聊一个很多人都明白但没细想的事:RN项目一旦不能在三端共用,它的价值就悬了。现在很多公司的App,功能逻辑、UI组件、业务通道路都沉淀在RN层,原生壳只承担启动、推送、登录这类系统级能力。如果鸿蒙成为第三个主流平台,而RN不支持,摆在前面的路只有两条:要么用ArkTS把业务重写一遍,要么用WebView套壳应付。前者工作量巨大,后者体验掉档。相比之下,让RN跑进鸿蒙,业务代码复用率能保住七八成。
这笔账算清楚之后,做不做就只是时间问题。我们团队当时做技术预研,对比了三种路线,结论是:鸿蒙原生重写只适合产品形态比较简单的App,像那种几十上百个页面的业务型App,重写的代价根本不是开发成本,而是研发节奏一下子被打乱,新产品迭代全都得停下来等适配。所以对大多数已经重度使用RN的团队来说,适配鸿蒙的核心诉求不是“赶时髦”,而是保住已有的跨端资产。
1.2 动态更新能力,是RN留在鸿蒙阵营的重要筹码
除了代码复用,还有一个隐藏优势是动态更新。RN的bundle可以在App不发版的情况下远程下发,这是原生开发很难做到的。鸿蒙体系虽然也有一些动态化框架的尝试,但从可控性、生态成熟度和团队熟悉程度来看,RN依然是最顺手的那个。运营活动页、临时促销页、紧急Bug修复,这些场景在原生体系里要等审核、等发版,在RN里只要把bundle推到CDN上就行,分钟级生效。
所以对做RN的人来说,现在的任务不是争论“鸿蒙会不会取代安卓”,而是尽早把鸿蒙纳入RN的编译目标。早一步踩完坑,后面就是多维护一个平台而已;晚一步,等业务方提需求时再开始,你的排期表大概率会被挤爆。另外,鸿蒙的设备形态比手机更多,平板、折叠屏、车机、智能家居都算一个目标场景。RN的声明式UI思想和组件生态在这里有优势,但也意味着你在适配时要考虑的不只是“能跑”,还有“在不同屏上跑得像不像样”。提前把这些因素放进组件设计里,能省掉后面大把返工时间。
2. 鸿蒙开发的基础认知:ArkTS、ArkUI、Ability,它们在说什么
2.1 ArkTS不是套了个名字的TypeScript
第一次打开鸿蒙工程的时候,我一度以为ArkTS就是TypeScript,毕竟文件后缀写着.ets,写起来也很像。真正开始写才发现,ArkTS对TypeScript做了裁剪和增强,它保留了类型系统和大部分语法,但会限制一些动态特性,比如不允许任意对象在运行时加属性、不允许使用某些动态导入方式。这些都意味着,从RN转过来的代码块不能原样粘进鸿蒙侧,需要先过一遍编译器的脾气。
不过反过来讲,RN开发者学ArkTS的起点已经很高了。TS类型、泛型、装饰器这些概念本来就熟,鸿蒙侧的很多API设计也吸收了前端经验,特别是UI框架那部分,几乎可以用React的思维直接迁移。我记得第一次在ArkUI里写状态管理时,脑子里自动就把 @State 对应到了 useState,把 @Prop 对应到了组件入参,整个理解过程非常顺。真正需要花精力的,反而是鸿蒙特有的生命周期和跨设备API。
2.2 ArkUI声明式UI,和React是同一个思路
ArkUI采用声明式UI,用 @State、@Prop、@Link 这些装饰器管理状态,状态变了界面就自动刷新。这不就是React的state和props换个马甲吗。你在React里怎么写组件,在ArkUI里就怎么写页面,只是渲染引擎从虚拟DOM换成了鸿蒙自绘的渲染引擎。所以在学习路径上,RN开发者不需要重新理解“单向数据流”这类概念,真正要花时间的是掌握ArkUI的容器组件、属性写法,以及它和React组件之间怎么互相嵌套。
比如在鸿蒙侧写一个自定义组件,接收RN传来的参数并渲染,这个边界如果你没理清,后面第一次混合开发会非常混乱。最简单的做法是让鸿蒙组件保持“哑组件”状态:只负责根据传入参数渲染,不自己发网络请求,不直接写系统状态,所有业务逻辑还是放在RN侧。这样鸿蒙侧代码量最少,调试时问题也最好定位。
2.3 Ability、module.json5、分布式能力,构成鸿蒙的骨架
鸿蒙应用的基本构成单元叫Ability,可以理解成“能力入口”。页面Ability负责展示界面,服务类型的Ability负责后台任务。一个鸿蒙App的入口、页面配置、权限声明都写在module.json5里,它的地位类似安卓的Manifest加上iOS的Info.plist。刚开始接触时,我习惯性地去代码里找入口方法,结果发现大部分配置都在这个JSON文件里,包括应用图标、页面路由、权限申请等,理解了这个文件,才算是摸到了鸿蒙开发的门。
分布式是鸿蒙真正和其他操作系统区分开来的地方。它不是简单地把数据云同步,而是通过分布式软总线把附近的多台设备组网,让应用能发现可信设备、跨设备调用能力、迁移任务。比如手机上正在播放的视频,可以流转到平板上继续播——这个能力在RN世界里是空的,需要我们从鸿蒙侧暴露给JS层。这是后面写鸿组件时最值得做深的方向。为了方便对照,我把RN开发者熟悉的概念和鸿蒙里的对应物列了个表:
| 你已经熟悉的 | 鸿蒙里的对应物 |
|---|---|
| React函数组件 | ArkUI自定义组件 |
| useState | @State |
| props | @Prop / @Param |
| AppDelegate / MainActivity | EntryAbility |
| AndroidManifest / Info.plist | module.json5 |
| npm | ohpm |
| 热更新bundle | 沙箱或assets里的bundle加载 |
3. 先定架构:RN做壳还是鸿蒙做壳
3.1 方案一:鸿蒙工程做宿主,RN页面当成模块嵌进去
这种方案可以想象成:鸿蒙App是房子,RN是其中一个房间。鸿蒙原生负责导航、账号、支付这类能力,RN负责承载某些动态页面。社区里流传较广的RN鸿蒙适配基础包,基本都是按这个方向设计的,它把RN运行时编译成鸿蒙可加载的库,再在ArkUI里提供一个容器组件,用来挂载RN页面。我在预研时跑通的第一版就是这个结构,鸿蒙工程的EntryAbility里直接放了一个RN容器组件,加载本地bundle,几秒后RN页面就出现了。
好处是鸿蒙的系统能力调用最直接,想用分布式、后台任务都能原生写;坏处是“房”是鸿蒙的,RN页面不能独立发布,还得跟着鸿蒙应用的整体生命周期走。如果你的App已经用鸿蒙原生开发了核心功能,只是想在首页运营位或二级页里引入RN做动态更新,优先考虑这一种。
3.2 方案二:RN工程做宿主,鸿蒙组件当作高级模块来调用
另一种做法是把鸿蒙当成类似iOS/Android的原生层,RN负责主业务,鸿蒙只提供系统能力封装。比如在RN里通过NativeModules调用鸿蒙的设备发现、分布式文件读取等能力。这样对RN团队最友好,业务代码完全不动,只需要在原生层增加一个鸿蒙平台的实现。目前社区里的适配包在这条路上也做得越来越完整,桥接机制、UI映射、模块注册都已经有对应的鸿蒙侧封装,RN侧几乎不用担心平台差异。
但这种方案对鸿蒙的UI优势利用得少,如果你想在鸿蒙上做出很有系统感的界面,或者大量适配折叠屏、车机场景,纯靠RN去实现可能心有余而力不足。所以它更适合“业务逻辑复杂、页面形态相对常规、追求三端一致”的典型业务型App。
3.3 怎么选:看谁是业务主体
判断标准其实很简单:谁掌握主流程,谁当宿主。你的业务逻辑绝大部分还在RN里,就想办法让鸿蒙当壳;你的App本来就以鸿蒙原生体验为主,只是某些运营页面想用RN做动态更新,那就让鸿蒙当宿主。还有第三种常见组合:鸿蒙当宿主,RN只承载首页的运营位和部分二级页,这也是我见过最多的一种,兼顾了体验和发布节奏。我把关键差异整理成一张表,方便你们内部讨论时直接用:
| 对比点 | 鸿蒙做宿主 | RN做宿主 |
|---|---|---|
| 主业务归属 | 鸿蒙原生为主 | RN跨端为主 |
| RN动态化能力 | 仅限嵌入页面 | 全App可热更新 |
| 鸿蒙系统能力 | 原生直调 | 封装成NativeModule |
| UI统一性 | 鸿蒙原生UI为主 | 三端UI一致 |
| 适合场景 | 重体验、多设备形态的App | 典型业务型App |
4. 从零搭建:让鸿蒙工程跑起第一个RN页面
4.1 环境准备阶段最容易漏的事
先把环境列清楚:Node环境、鸿蒙开发IDE、鸿蒙SDK和模拟器。IDE装好后,SDK管理器里要勾选对应版本的SDK,这个版本要和后面引入的RN适配包对齐。最常遇到的坑是:IDE升级之后SDK路径变了,构建脚本还按老路径找,报一堆莫名其妙的错。建议第一次就统一环境版本,别用最新尝鲜版,用稳定版。另外,很多RN项目依赖Node版本,鸿蒙构建工具链也对Node有要求。最好在项目根目录放一个.nvmrc,把Node版本固定下来,否则不同成员的电脑上构建结果可能不一致。
4.2 创建鸿蒙工程并引入RN运行时
用IDE创建一个空工程,模板选“Empty Ability”,语言选ArkTS。创建完你会看到entry模块和module.json5。接下来通过ohpm把社区适配RN的包安装进来,这些包通常分成几类:RN核心运行时(包含JS引擎和桥接层)、ArkUI渲染映射层、以及原生模块的基类。装完之后,鸿蒙工程就具备加载RN bundle的能力了。核心操作就一步:在Ability里放一个RN容器,容器组件会负责创建JS执行环境、加载bundle、把RN视图树映射成ArkUI组件。启动时传入bundle的路径或URL,同时可以传initialProps给JS层。首次跑通的最小配置大概是这样。
@Entry @Component struct RNHostPage { build() { Column() { // 社区适配包提供的RN容器组件 ReactNativeContainer({ componentName: 'App', bundlePath: this.getBundlePath(), initialProps: { language: 'zh-CN' } }) .width('100%') .height('100%') } } private getBundlePath(): string { // 开发阶段可以先加载本地bundle // 正式环境可以改成沙箱缓存路径或远程URL return 'bundle://main.jsbundle'; } }注意componentName对应你RN入口注册的组件名,别把默认的App改掉后忘了同步。我第一次就是改了入口注册名,结果容器一直报告找不到组件,排查了半天才发现两边名字没对上。
4.3 权限、签名与bundle,三个必踩点
鸿蒙的权限声明和安卓类似,要在module.json5的requestPermissions里提前声明,代码里只是再次请求用户授权。如果要调用分布式相关能力,提前把网络、分布式数据同步这几项声明好,不然调试时会发现API调用直接失败。签名是另一个让新手很头疼的点,鸿蒙应用没有签名配置,真机是装不上去的。IDE里通常会生成调试签名,但要注意调试签名的类型必须和构建类型匹配,否则安装时会报错。遇到安装失败,先别急着怀疑代码,八成是签名或版本不对。
bundle加载方式也要提前想清楚:开发期建议直接读本地assets里的bundle,调试最省事;发布前把bundle打包进HAP,或者放到应用沙箱里做增量更新。社区适配包一般会提供加载本地文件、加载沙箱文件、加载远程URL几种API,具体按文档配就行。需要特别提醒的是,远程bundle一定要做版本管理和回退机制,否则线上一旦下发一个坏包,用户连启动页都过不去的时候,只能靠热更来拯救热更。
5. 桥接鸿蒙原生能力:从设备信息到分布式设备发现
5.1 鸿蒙侧写一个NativeModule
跑通的流程和RN在iOS/Android上完全一致:鸿蒙侧实现原生模块,注册给JS层,JS通过NativeModules调用。区别只是鸿蒙模块类的底座来自社区适配包。我拿设备信息模块举例,这个模块能拿到本机设备信息,还能拉取组网内的可信设备列表,正好把“普通方法调用”和“异步方法调用”都覆盖到。
import { RNBaseModule, Method } from 'react-native-oh'; export class DeviceInfoModule extends RNBaseModule { getName(): string { return 'DeviceInfoModule'; } @Method getLocalDeviceInfo(): DeviceInfoData { // 通过设备管理服务获取本机信息 const device = deviceManager.getLocalDeviceInfoSync(); return { name: device.name, type: device.type, version: device.version, }; } @Method async getTrustedDeviceList(): Promise<DeviceInfoData[]> { // 获取组网内的受信设备列表 return deviceManager.getTrustedDeviceList(); } }这个类做的事情很简单:挂一个名字,给两个可以被JS调用的方法。注意返回Promise的方法会自动被RN桥接成异步调用,能够在返回前拿到结果。这里的deviceManager不是RN适配包的一部分,而是鸿蒙的分布式设备管理服务,API名称在不同SDK版本略有变化,以你们IDE里的声明为准。写的时候不要硬记API名字,先去翻一下鸿蒙官方SDK里设备管理的接口声明,再对照示例代码调。
5.2 注册模块并让JS侧能调用
模块写了之后,要在鸿蒙工程的桥接注册表里登记。不同适配包的登记方式不一样,有的是在初始化时传一个模块列表,有的是用反射自动扫描。最稳妥的做法是模仿已有的内置模块,在注册入口加一行。JS侧调用是所有RN开发者最熟的环节,NativeModules对象里直接出现模块名。
import { NativeModules } from 'react-native'; const DeviceInfoModule = NativeModules.DeviceInfoModule; export async function fetchLocalDeviceInfo() { try { const device = await DeviceInfoModule.getLocalDeviceInfo(); return device; } catch (error) { console.warn('[device-info] 获取设备信息失败', error); return null; } } export async function fetchTrustedDevices() { return DeviceInfoModule.getTrustedDeviceList(); }这里有个小建议:JS侧不要直接散落一堆NativeModules调用,包一层业务函数,方便后面mock和做兼容处理。万一鸿蒙真机调试不了,你还能在本地跑一个假实现继续开发UI。同时给整个组件定一套统一的日志前缀,比如[device-info]、[file-storage],后续看日志时能按前缀过滤,效率会高很多。
5.3 事件回调:把系统通知变成RN事件
很多鸿蒙能力是主动通知型的,比如新设备上线、文件被修改,这类场景没法用简单返回值处理,需要事件通道。具体做法是:鸿蒙侧把监听函数传给设备管理服务,JS那一侧通过NativeEventEmitter订阅同名事件。我封装设备上线通知的代码就是这样。
// 鸿蒙侧 @Method subscribeDeviceOnline(listener: EventCallback): void { deviceManager.on('deviceOnline', listener); }// JS侧 import { NativeEventEmitter } from 'react-native'; const emitter = new NativeEventEmitter(NativeModules.DeviceInfoModule); export function watchDeviceOnline(callback) { const subscription = emitter.addListener('deviceOnline', callback); return () => subscription.remove(); }到这里,其实你已经做成了一个完整的鸿组件。它能在RN里被调用、能返回数据、能推送事件。剩下的事情就是照着这个模式,把设备文件、应用流转、分布式数据等能力陆续封装进去。桥接连通之后,RN侧的代码几乎不需要针对鸿蒙写分支。
6. 把鸿组件做扎实:边界、状态与布局的设计建议
6.1 组件粒度怎么切
封装鸿组件最容易犯的错,是一上来就想着做一个大而全的“分布式能力组件”,把设备发现、文件传输、数据同步全塞进同一个模块。结果就是方法参数一大堆,状态机复杂到没人敢动。我建议按能力粒度来切:一个原生模块只对应一个系统能力,比如DeviceInfo只负责设备信息,FileTransfer只管跨端文件,DeviceDiscovery只管设备列表。需要组合时,在JS侧再封装一个业务层把它们串起来,这样单个组件可以独立测试、独立迭代,出问题也只用看一个模块的代码。
6.2 状态放哪侧,有一条很实用的分界线
判断一个数据到底放RN侧还是鸿蒙侧,我一般问两个问题。第一,这个数据离系统能力近不近?设备token、低频连接状态、跨端会话ID这类东西,必须留在鸿蒙侧;如果放到RN侧,每次要使用时都得跨桥拿,不仅慢,还容易遇到生命周期错位。第二,这个数据在iOS/Android两端是不是也存在?如果存在,就放RN侧,保持三端行为一致。比如用户昵称这种纯业务数据,就不该因为鸿蒙的分布式能力而单独存一份。跨端文件的句柄、设备列表这些鸿蒙特有的东西,则不要往JS层拷贝,留在鸿蒙侧最安全。
6.3 布局与样式:不要把RN的习惯直接搬过来
鸿蒙里有一个专门的长度单位vp,它负责在不同屏幕密度下做自适应缩放,类似iOS的pt和Android的dp。RN工程里的样式用的是dp类单位,在鸿蒙容器里加载时,适配层会做一次换算,但两边完全混用还是会出现某些元素尺寸偏差。折叠屏设备上,鸿蒙原生会提供断点或窗口尺寸变化事件来调整布局,RN侧要知道怎么响应这些事件,否则页面在展开态和折叠态之间切换时会错乱。我的做法是:RN侧统一用flex布局和相对单位,尽量避免写死px,同时把鸿蒙窗口尺寸变化事件桥接成RN事件,让页面能重新计算布局。
6.4 生命周期不同步的处理
鸿蒙Ability的可见性和RN页面的生命周期不是一一对应的。比如鸿蒙的onPageShow触发时,RN里可能还没有对应的事件通知。如果页面在后台一段时间后回来,需要手动触发数据刷新。我的做法是在鸿蒙侧把周期事件桥接给JS侧一个订阅函数,然后在RN页面里统一处理,不要在鸿蒙侧偷偷改UI状态,否则两侧状态很容易脱节。特别是涉及分布式设备的场景,设备从离线到重新在线时,鸿蒙侧的连接状态变了,RN侧的界面如果没有收到事件,用户看到的就还是“设备离线”,体验非常差。
7. 实战踩坑与调试技巧
7.1 签名与安装失败
鸿蒙App安装到真机有一套签名校验,调试阶段用IDE的自动签名能解决大部分问题,但如果你同时开多个项目,很容易出现签名缓存串了的情况。症状就是命令行安装或IDE一键运行时提示安装失败。排查顺序不要乱:先看证书链和profile文件是否匹配当前工程,再看构建类型是debug还是release,最后把系统里残留的旧包卸载重试。千万别一上来就重装IDE,那是最后一个手段,耗时且不一定有用。
7.2 线程模型导致的卡顿
RN的JS线程天生是异步的,但鸿蒙侧很多系统API的回调也默认在某条线程上。如果你在一个@Method里直接做耗时的文件拷贝或设备扫描,会发现RN页面卡顿明显。正确的做法是所有可能耗时的系统调用丢到异步线程,返回Promise而不是同步结果。桥接层的性能损耗虽然不大,但一旦涉及跨端反复调用,要警惕把高频率的逻辑放在桥接两段之间来回跑。比如列表滚动时逐条去鸿蒙侧查状态,这种设计从一开始就不该出现。
7.3 监听器泄漏
事件订阅是最容易泄漏的地方。RN页面销毁后,如果鸿蒙侧还保留着监听回调,设备管理服务会继续向一个已经不存在的界面推消息。轻则日志刷屏,重则内存泄漏、页面无法释放。写订阅代码时,一定把取消订阅函数返回出来,在RN的useEffect清理函数里调用,跟网上所有RN事件订阅的最佳实践一样。我在封装watchDeviceOnline时,返回值就是取消订阅函数,业务侧只要拿到它并在组件卸载时执行即可。
7.4 JSCore引擎差异
有些在iOS和Android上跑得好好的RN代码,在鸿蒙上会出问题,多数是引擎差异导致的。比如某些新语法、Array的新API、Promise的细节行为,在不同JS引擎下表现不完全一致。应对办法是把ES语法控制在适配包文档推荐的范围内,别过分依赖最新ES特性。真遇到语法兼容问题,加个babel插件转译,比在运行时代码里打补丁干净。团队里如果有人喜欢用很新的语法特性,建议在CI流程里加一道语法检查,避免问题流到真机上才暴露。
7.5 调试手段:两个日志口子要分清
鸿蒙侧日志用IDE自带的日志面板或命令行工具看,RN侧的console.log会走JS运行时转发,两边不要混在一起排查。实际调试时,我会在鸿蒙侧加一个统一的tag,比如RN_HARMONY,RN侧的业务日志用一个统一前缀,这样日志过滤特别方便。远程调试工具在鸿蒙环境不一定能用,别依赖断点调试,多用日志打印定位问题。性能调优时别只看JS侧渲染帧数,系统性能工具和IDE的CPU Profiler能定位到鸿蒙侧原生代码的瓶颈,很多时候卡顿的根源在桥接层而不是JS代码。
最后说一条我实测下来最有用的经验:第一次做RN鸿蒙化,别急着把分布式能力全搬过来,先做一个获取设备信息的小组件,把签名、工程、桥接、事件这条链路走通。链路通了,后面就是往这个框架里不断加模块的事。我记得第一个组件跑通的时候,团队里最惊讶的不是功能本身,而是发现RN项目几乎没有动,鸿蒙侧也就加了不到两百行代码。这个收益比,才是大家愿意继续做下去的原因。等你们把设备列表、跨端唤醒、文件传输这些组件一个个接完,再回头看最开始那个设备信息组件,会发现自己已经摸清鸿蒙开发的整个底子,后续再接触新模块就只是看API文档的事了。