ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

React Native鸿蒙化实战:从原生Module到性能优化

2026/9/19 4:26:27 拓冰建站 浏览量
React Native鸿蒙化实战:从原生Module到性能优化 去年做鸿蒙适配的时候团队里最让我头疼的一个需求是在已有的React Native页面里嵌入一个鸿蒙原生小组件用户点按钮后要调起鸿蒙的分布式文件选择能力再把选中结果传回JS层。需求听起来不复杂真正动手才发现React Native官方根本没有鸿蒙支持网上资料又碎又旧很多还停留在鸿蒙能装APK所以直接套壳的年代放到HarmonyOS NEXT上完全不适用。这半年我从鸿蒙原生Module写到自定义UI组件再到启动白屏排查和性能优化把RN的鸿蒙化这条路从头走了一遍。这篇就把整条链路完整梳理出来标题里说的鸿组件用开发术语讲就是HarmonyOS Native Module和Native Component。不管你是刚在评估RN鸿蒙化可行性还是已经接需求准备下手这篇文章应该能帮你少踩一些我踩过的坑。1. 为什么React Native项目会需要鸿蒙原生组件1.1 纯血鸿蒙不再兼容APKRN不能继续套壳很多团队早期让RN应用跑在鸿蒙设备上靠的是鸿蒙系统兼容Android APK这个特性。那时候鸿蒙系统底层带了一个安卓兼容层RN打包出来的APK装上去就能跑业务方也不会提额外要求。但HarmonyOS NEXT开始走纯血路线不再兼容安卓应用这个老办法直接失效。RN要用在鸿蒙设备上只有一条路让RN的运行时真正跑在鸿蒙系统上。这里就要提到OpenHarmony SIG维护的ohos_react_native项目对应的npm包叫react-native-harmony。它做的事情相当于把React Native的C运行时和渲染层移植到鸿蒙OS上让RN的JS bundle可以在鸿蒙环境里被加载和执行。我用的适配版本是基于RN 0.72.x的现在社区也在跟进更高版本但整体思路没变。集成之后RN的基础组件、布局逻辑、事件系统都能工作但也有一些边界凡是JS层没有对应实现的系统能力都必须自己写原生模块桥接。1.2 业务倒逼技术选型JS侧搞不定的事既然RN能在鸿蒙上跑了为什么还要写鸿蒙原生组件直接说我在实际项目里遇到的场景。第一个场景是系统级能力调用。鸿蒙的宣传重点是分布式能力比如跨设备流转、分布式数据协同、碰一碰拉起服务这些能力在RN的JS API里根本没有。RN官方库只覆盖iOS和Android鸿蒙原生API再丰富JS层也够不着。想让RN页面用上这些能力唯一的办法就是写鸿蒙原生Module把能力封装成一个接口暴露给JS调用。第二个场景是性能敏感或者交互复杂的UI。RN页面里有些区域不适合用JS渲染比如高频刷新的图表、自定义手势动画、还有地图这类原生控制很强的组件。社区里经常讨论的鸿蒙自定义动画全局navigation开发本质上就是这类需求——导航框架的自定义转场动画在RN里做很吃力放在鸿蒙ArkUI侧做反而流畅得多。把这类组件写成鸿蒙原生UI组件嵌入RN页面是性能和体验上更合理的选择。第三个场景更实际很多公司之前已经用ArkTS写过一部分鸿蒙原生页面现在要把这些能力复用到RN业务里。重新用JS实现一遍成本太高直接通过桥接把现有原生模块暴露给RN是最省事的方式。2. 动手前必须吃透的鸿蒙开发底座2.1 ArkTS、ArkUI和Stage模型三个绕不开的概念在RN工程里写鸿蒙原生组件前提是能看懂鸿蒙原生代码。鸿蒙开发有三个基础概念必须搞明白ArkTS、ArkUI、Stage模型。ArkTS是鸿蒙的主力开发语言听名字像TypeScript实际使用却有不少差别。ArkTS在TS基础上加了装饰器和UI语法同时收紧了类型系统比如不允许随意使用any对象字面量必须对应具体接口这让我这种写惯了JS的人一开始很不适应。RN工程里JS是动态语言思路但鸿蒙侧的原生代码必须按照ArkTS的类型规范来写否则编译直接报错。ArkUI是鸿蒙的声明式UI框架写法上跟SwiftUI、Flutter比较接近用Component和build描述界面。在RN里写UI是组件加StyleSheet在ArkUI里是Column、Row、Stack这些布局容器加拉链式属性。如果你写过Flutter上手会快很多。关键是理解ArkUI的State、Prop这些装饰器它们和React的state、props在思路上类似但更新机制不一样后面我写原生组件时会经常用到。Stage模型是鸿蒙应用当前的推荐模型用来管理应用生命周期和组件。它把应用拆成UIAbility界面能力、ExtensionAbility扩展能力等模块每个可交互页面通常由一个UIAbility承载。RN鸿蒙化方案里JS页面其实就是放在一个专门的UIAbility里加载的。这个模型跟Android的Activity、iOS的ViewController有相似之处但生命周期更多、启动模式也更复杂。写桥接代码时生命周期处理一定要跟着Stage模型的逻辑走否则很容易出现进入页面白屏、退出页面泄漏的问题。2.2 认识鸿蒙工程结构与构建工具鸿蒙原生开发用的是华为官方的DevEco Studio工程结构和Android Studio不太一样。一个鸿蒙工程里可以有多个Module每个Module有自己的module.json5配置文件里面声明了Module的能力、页面入口、权限等。RN鸿蒙化之后应用入口通常是一个名为Entry的Module它负责加载RN的bundle并启动RN运行时。构建工具是hvigor可以理解成鸿蒙版的Gradle。你需要在build-profile.json5里配编译参数在oh-package.json5里配依赖。RN鸿蒙化工程需要把这些依赖加进去react-native-harmony的运行时包、鸿蒙侧的RN适配代码以及你自己的原生Module。还有一个容易忽略的点权限声明。RN工程切到鸿蒙后很多原生能力都涉及系统权限比如读文件、联网、定位。在HarmonyOS里权限是分requestPermissions声明和动态授权两步走的而且在Stage模型里动态授权还要在页面启动时主动调用相关API。我第一次跑通Module但拿不到文件数据排查半天发现就是权限没在module.json5里声明。这个坑后面联调时还会遇到。2.3 鸿蒙侧线程模型和Android/iOS的差异写桥接组件之前必须先理解鸿蒙的线程机制因为线程用错了轻则卡顿重则死锁闪退。鸿蒙的UI线程负责ArkUI渲染和组件状态更新类似iOS的Main Thread。RN运行时有自己独立的JS线程负责执行JS逻辑。此外还有Worker和TaskPool两种并发方式。TaskPool是鸿蒙推荐的后台任务方案语法更简单能自动管理系统线程池适合CPU密集型任务。Worker则更像在子线程里开一个独立脚本环境适合处理有状态的长任务。理解这个模型后你就知道原生Module里不能直接在主线程做耗时操作否则会卡ArkUI渲染也不能在子线程里直接操作UI组件必须切回UI线程。这与Android的网络操作不能在主线程、UI更新必须回主线程是类似的但鸿蒙的TaskPool有自己的并发限制和线程复用策略不能拿Android的线程池思路硬套。3. 在RN工程里搭建鸿蒙桥接层3.1 组件工程的两条路线选择RN鸿蒙化工程有两种搭建方式需要先想清楚走哪条。第一种是RN工程为主动方你有一个成熟的React Native项目希望它能在鸿蒙设备上运行。此时要做的是把RN工程接入react-native-harmony适配层把它编译成鸿蒙应用。这条路线适合业务逻辑已经全部在JS层、只需要增量补充鸿蒙原生能力的场景。缺点是改造点比较多项目构建链路要从Metro打包一直梳理到hvigor编译。第二种是鸿蒙工程为主方鸿蒙原生应用里嵌入一个RN页面或者反过来在RN页面里嵌入鸿蒙原生组件。这种模式下鸿蒙工程负责应用生命周期和原生能力RN只负责局部业务页面。搞混合开发时这个思路更常见。我这次做的会议应用就是第二种主体还是鸿蒙原生会议列表和数据分析页面用RN图表组件用鸿蒙原生UI组件两边互相嵌入。不管走哪种路线核心的桥接层设计是一致的在鸿蒙侧实现Module在RN侧通过NativeModules或codegen生成的接口调用。建议先把最小的Demo跑通再往里面加业务。3.2 原生Module把鸿蒙能力暴露给JS我以最经典的原生Toast弹提示为例展示一个原生Module从鸿蒙侧到RN侧的完整链路。别小看这个例子它能让你验证整个桥接通道是否打通。鸿蒙侧在Entry模块里新建一个继承TurboModule的类。不同的react-native-harmony版本基类路径和注册方式可能略有差异但整体骨架是这样的// ToastModule.ets import { TurboModule } from react-native-harmony; import { promptAction } from kit.ArkUI; export class ToastModule extends TurboModule { show(message: string, duration: number): void { promptAction.showToast({ message: message, duration: duration }); } }写完这个类后还需要在鸿蒙侧的模块注册表里把它注册进去。不同版本框架的注册点不一样但都在初始化RN引擎的地方。注册完之后RN侧这样调用import { NativeModules } from react-native; const { ToastModule } NativeModules; ToastModule.show(来自RN的调用, 3000);这里有个经验如果你在RN侧拿到的是undefined先别怀疑RN代码优先检查鸿蒙侧注册路径。我一开始就是因为注册文件名写错导致JS侧拿不到模块。调试时可以打日志确认模块是否成功注入比反复改JS代码高效得多。3.3 原生UI组件把ArkUI视图嵌进RN页面原生Module解决的是JS调能力的问题但有时候你需要把整块鸿蒙原生UI嵌到RN页面里比如自定义动画容器、相机预览、实时图表。这就需要开发原生UI组件。鸿蒙侧的思路是先写一个基于ArkUI的组件然后用适配框架提供的组件注册机制把这个组件注册成RN可以识别的原生视图。// CustomChartView.ets Component export struct CustomChartView { Prop chartData: string ; build() { Column() { // 这里用ArkUI的Canvas绘制图表 } .width(100%) .height(100%) } }在RN侧通过requireNativeComponent或者codegen拿到对应的组件标签import { requireNativeComponent } from react-native; const CustomChartView requireNativeComponent(CustomChartView); // 使用时直接渲染 CustomChartView chartData{JSON.stringify(data)} style{{ width: 300, height: 200 }} /这里最容易踩的坑是属性类型映射。RN的style会转为鸿蒙侧的布局参数但自定义字符串、数字属性不一定能自动转换。传对象时最好先JSON.stringify成字符串再传在鸿蒙侧解析避免类型不匹配导致组件不渲染。组件命名也有讲究requireNativeComponent的字符串必须和鸿蒙侧注册的名称完全一致大小写都不能错。4. 数据双向通信JS调用原生与原生回调JS4.1 Promise和Callback两种调用签名鸿蒙侧写Module方法时不只是能直接返回void。RN桥接支持两种异步返回方式Callback和Promise。在鸿蒙侧实现时需要按照适配框架约定的签名来写方法。Callback方式适合结果一次性的场景export class FileSelectModule extends TurboModule { selectFile(callback: (result: string) void): void { // 异步选择文件 const filePath doSelect(); callback(filePath); } }Promise方式则更符合现代异步编程习惯export class FileSelectModule extends TurboModule { selectFile(): Promisestring { return new Promise((resolve, reject) { const filePath doSelect(); if (filePath) { resolve(filePath); } else { reject(new Error(user canceled)); } }); } }RN侧配合await使用代码会清爽很多。我个人更推荐Promise原因有两个一是新架构TurboModule对Promise支持更友好二是调用方的错误处理天然统一不用层层嵌套回调。4.2 原生侧事件推送从DeviceEventEmitter说起有些通信不是JS主动请求、原生返回结果这种一问一答而是原生侧有状态变化需要主动通知JS层。比如文件选择器在后台完成了一个耗时解析或者系统来电打断了当前页面都需要原生主动推送事件给JS。最常用的方式是DeviceEventEmitter。鸿蒙侧在模块方法里主动emit一个事件// 在鸿蒙侧Module内 this.context.emitDeviceEvent(onFileParsed, { filePath: xxx, status: success });RN侧在JS逻辑里监听这个事件import { DeviceEventEmitter } from react-native; useEffect(() { const subscription DeviceEventEmitter.addListener( onFileParsed, (event) { console.log(解析结果, event.filePath, event.status); } ); return () subscription.remove(); }, []);事件名要全局统一最好抽成常量文件避免拼错字符串导致的诡异bug。另外建议在页面卸载时及时移除监听尤其是做了跨页面跳转的场景。否则事件可能在下一个页面里被重复触发甚至出现内存泄漏。我在一个数据大屏页面就遇到过事件重复回调的问题后来排查下来就是多个页面实例都监听了同一个事件移除时机又不对造成回调被触发了好几轮。4.3 生命周期管理实战双向通信还有一个经常被忽略的问题原生Module和RN页面的生命周期不同步。RN页面可以随时卸载但如果原生Module在页面销毁后还在跑异步任务甚至继续持有页面相关的引用就会出现崩溃或泄漏。在鸿蒙Stage模型里这个矛盾更明显因为UIAbility的销毁时机不一定和RN组件卸载完全一致。我的处理思路是在一个页面级Module里维护一个当前调用是否有效的标记。页面卸载时调用一个cancelAll方法把正在进行的异步任务全部标记为取消等任务真正结束时先检查标记再决定要不要push事件。不要依赖纯前端React的componentWillUnmount去清理原生任务有时候JS已经卸载了原生侧任务还在飞。5. 联调白屏、线程卡顿与性能优化实录5.1 启动白屏排查从bundle加载时序入手react native 启动白屏一直是社区热词在鸿蒙上这个问题尤甚。RN在鸿蒙上的启动时序大致是应用初始化RN运行时 - 读取JS bundle - JS引擎执行bundle - 渲染首帧。这个链路比Android和iOS多了一层鸿蒙适配任何一个环节卡住屏幕上就是一片白。我在鸿蒙上遇到的白屏主要有三类。第一类是bundle路径配错RN加载不到JS代码。这时屏幕上会一直停在原生容器页没有任何报错排查方法是看鸿蒙侧日志里bundle加载路径是否正确。第二类是JS bundle太大白屏时间被拉长。我们的首包一度超过8MB在部分低端鸿蒙设备上白屏接近三秒后来通过拆包和延迟加载才压下来。第三类是原生组件注册和JS侧组件名对不上页面渲染到那个原生组件时直接抛错整页白掉。针对白屏有三件性价比很高的事在原生容器加载完成前先显示一个和App风格一致的原生占位页面避免用户面对纯白屏幕把bundle放到本地资源目录而不是网络加载减少IO时间如果适配版本支持Hermes引擎优先开启Hermes字节码执行速度比JavaScriptCore有明显提升。5.2 线程与死锁TaskPool正确用法有一次在鸿蒙原生Module里做图片批量压缩处理时间大约两秒而RN页面在压缩期间完全卡死连加载动画都动不了。定位后发现Module方法直接在主线程执行了压缩逻辑阻塞了ArkUI渲染。正确的做法是把耗时操作放到TaskPool里import { taskpool } from kit.ArkTS; Concurrent function compressImages(data: string): string { // 耗时压缩逻辑 return compressedResult; } // Module方法里 const task new taskpool.Task(compressImages, inputData); taskpool.execute(task).then((result) { this.callback(result); });TaskPool还有个细节传进去的函数和参数都必须是被序列化的不能直接传一个闭包进去捕获外部对象。遇到复杂参数时先转成JSON字符串再传在子线程里解出来。另外一个容易出问题的地方是UI更新。TaskPool里执行的任务结束后回到Module方法所在的线程但如果要在结果里直接操作ArkUI组件还是必须切回UI线程。不要假设TaskPool回调里能安全更新UI很多次看起来莫名其妙的卡死都是线程切来切去造成的。我建议在处理完数据之后只把结果回传到JS层让RN侧决定是否更新UI尽量避免在原生侧直接操作界面状态。5.3 降低桥接开销的几个有效手段桥接调用不是免费的RN和鸿蒙之间的每一次通信都有序列化和线程切换开销。在业务组件多、数据流动快的场景里性能问题会特别明显。第一个经验是高频数据聚合。之前做一个设备状态监控页面鸿蒙侧每50毫秒推送一次传感器数据用事件方式一条条发给JS结果JS线程被打满页面掉帧严重。后来改成在鸿蒙侧聚合一秒内的数据批量推送给JS帧率立刻恢复。事件推送的频率尽量控制在JS处理得过来的范围内宁可延迟一点也不要让JS线程排队处理。第二个经验是减少大对象跨桥传输。传JavaScrip对象时会经历序列化和反序列化大对象尤其是嵌套层级深的对象耗时指数级增长。能用字符串就用字符串能用基础类型就用基础类型。传文件时传文件URI而不是整个文件内容在鸿蒙侧再读取可以明显降低内存峰值。第三个经验是对象复用。不要在每次JS调用时都在鸿蒙侧频繁创建新对象Module尽量设计成无状态或者轻状态需要复用的重对象用单例持有但要注意释放时机。鸿蒙侧持有RN的Context和JS引用时一定在页面销毁时主动置空这是我踩过最多次的坑——页面反复进入退出后内存占用一路飙升。最后顺便说一句处理这类桥接性能问题定位时一定要先在鸿蒙侧打点计时把原生方法本身的耗时和桥接通信的耗时分开统计。我见过不少团队花大力气优化JS代码最后发现瓶颈其实是原生方法里一次多余的IO方向错了就白费功夫。打开DevEco Studio的Profiler配合鸿蒙侧的日志往往几分钟就能定位到问题区域。我自己的习惯是每次新写一个鸿蒙原生Module都先跑一次最小Demo确认桥接通、事件通、性能可接受再往里面加业务逻辑。现在回看那半年最耽误时间的不是写代码而是早期对鸿蒙开发模型的陌生感。等把Stage模型、线程模型、生命周期这些基本功补齐之后再写RN和鸿蒙的桥接就顺畅多了。希望这篇能够帮准备做RN鸿蒙化的朋友把路看清少走几步弯路。