前段时间团队接到一个任务,要把既有的 React Native 应用适配到鸿蒙设备上。初看似乎是个常规的“多端打包”问题,毕竟 RN 号称跨端,多一个目标平台似乎只是构建配置的事情。真正动手后才发现,事情远没有这么简单:React Native 与 HarmonyOS 之间并没有一条官方直通路,社区适配层、鸿蒙原生组件、JS 桥接每一环都可能有意想不到的坑,最典型的就是那个让人头疼的“启动白屏”。
这篇文章主要记录我在 React Native 工程里集成鸿蒙(HarmonyOS)组件,尤其是鸿蒙原生组件的完整过程。我会先补上必要的鸿蒙开发基础知识,再讲清楚为什么社区路线是目前的主流选择,然后用一个底部导航栏作为示例,手把手展示如何把 ArkUI 组件封装成可被 RN 调用的“鸿组件”,最后专门聊聊启动白屏的排查思路。无论你是刚开始接触鸿蒙开发,还是已经在考虑 RN 鸿蒙化,这篇都能帮你省掉不少试错时间。
1. 鸿蒙不是“换皮安卓”:动手前先更新底层认知
1.1 从 API 12 / 5.0.0(12) 这代 SDK 说起
HarmonyOS 的版本号体系对刚接触的人来说有点乱,开发工具里经常会看到5.0.0(12)这种格式,它表示系统版本为 5.0,SDK API 级别为 12。这一代鸿蒙(HarmonyOS NEXT)在系统架构上有一个重要变化:去掉了传统的安卓兼容层,应用必须使用 HAP 格式编译、打包和安装。换句话说,在 Android 上直接运行的 APK,在这套系统上已经不能被识别和执行了。
这个变化对 RN 项目来说是一个根本性的约束。我们不能再像处理普通跨端问题那样,把 Android 构建产物稍作调整就塞进鸿蒙设备。必须重新走一条独立的编译链路:JS 业务代码仍然可以沿用,但宿主容器、原生模块、资源加载、网络权限、App 入口统统要按照鸿蒙的规则重新实现。有不少 RN 项目在鸿蒙化过程中卡在第一关,就是因为团队还抱着“换个包名就能跑”的预期。
1.2 ArkTS 与 ArkUI:RN 开发者要接受的三点差异
第一个差异是语言层面的收紧。ArkTS 官方定义为 TypeScript 的超集,但它不是“想怎么写就怎么写”的 TS,而是删掉了一些运行时灵活性,比如部分动态类型操作和模糊的类型推断会被编译器直接拦下。以前在 RN 里写习惯了any走天下,到了 ArkTS 会被 IDE 的语法检查不断提醒修正。
第二个差异是 UI 写法。ArkUI 采用声明式 UI 范式,结构与 SwiftUI、Flutter 有相似之处,核心是“装饰器 + 状态管理”。@Component把一个 struct 变成可渲染组件,@State表示组件内部响应式状态,@Prop是父传子的单向数据流,@Link则是父子之间的双向同步,@Builder用来复用局部 UI 结构。跟 React 的 Hooks 思路对比,虽然同样强调“状态驱动界面”,但使用习惯和底层渲染机制完全不同,不能做一对一翻译。
第三个差异是布局体系。ArkUI 有自己的长度单位、布局容器和自适应规则,虽然Row、Column、Stack这些容器名称看着熟悉,但 flex 行为、安全区处理、滚动容器的能力边界和 CSS/RN 并不等价。想把一个 RN 页面整体搬成鸿蒙组件,表面上是语法转换,实际要重新适配布局语义,这个心态建设很重要。
1.3 Stage 模型下的应用入口:RN 容器应该挂在哪里
HarmonyOS 的应用模型目前以 Stage 模型为主。和 Android 单一 Activity 概念不同,Stage 模型中每个可交互入口都是一个UIAbility,你可以在应用里配置多个 UIAbility,每个都有独立生命周期。RN 容器本质上是一个能渲染 JS 视图的原生页面,它在鸿蒙侧的宿主必须依附在某个 UIAbility 上。
这个看起来只是一个架构名词,实际踩坑时影响很大。比如页面返回、前后台切换时,UIAbility 的生命周期回调怎么映射到 RN 的AppState;多个 UIAbility 之间不能依靠全局单例共享内存态;路由跳转对鸿蒙来说是基于 Navigation 或 router 的能力,而 RN 自己的路由栈和鸿蒙原生路由并不互通。第一次接入的时候,我花在搞懂宿主工作方式上的时间,比写原生组件还多。
2. 集成策略:为什么我最后选了“按组件接入”
2.1 事实背景:React Native 官方没有直接支持鸿蒙
需要先看清楚现实:React Native 官方团队目前没有把 HarmonyOS 列为一等支持目标。所以鸿蒙设备上跑 RN 应用,靠的是社区维护的适配工程,像名字里带ohos或harmony的那一批 GitHub 仓库,本质是维护一套 RN 源码的鸿蒙分支,把 JS 运行时、原生模块注册、UI 渲染桥接到 ArkUI 上。用起来的感觉很像当年在 Windows 上跑 RN:能跑通,但版本必须严格对齐。
这里说的版本对齐是整个集成过程中最容易让人崩溃的点。RN 发一个大版本,适配层要跟着改;鸿蒙 SDK 发一个大版本,适配层还要跟着改。两个上游都在动,而我们的项目就夹在中间。我自己的结论是:决定集成鸿蒙后,先用一个适配层明确支持的 RN 版本,并且锁定住,不要因为“顺手”升级其他依赖。许多集成失败的项目,不是因为思路不对,而是因为版本组合本身就没人验证过。
2.2 三条路线的对比与选型
在真正动手前,建议先做一次方案选型。我梳理下来大致有三条路线:
| 集成路线 | 优点 | 缺点 | 适合场景 |
|---|---|---|---|
| 鸿蒙原生壳 + 整个 RN 容器 | RN 业务代码改动最少,能最快看到 Demo | 容器层兼容风险高,原生能力仍需重新桥接 | 技术验证、短期演示 |
| RN 页面中按需嵌入鸿组件 | 可渐进式接入、可灰度、可回滚 | 桥接工作量较大,需要长期维护 | 长期项目、核心体验不容妥协 |
| 用 ArkTS 逐步重写关键页面 | 鸿蒙特性利用率最高,性能与体验最完整 | 工程量最大,相当于重做一套 App | 业务方向已明确全面投入鸿蒙 |
我最终推荐的是中间这条“按组件接入”。原因有三点:第一,风险可控。一次只封装一两个鸿组件,出了问题能立刻定位到具体模块;相反,如果一开始就把整个 RN 容器套进鸿蒙壳里,任何一个桥接缺陷都可能影响全页面。第二,人力模型更合理。RN 工程师负责 JS 封装和组件 API 设计,鸿蒙工程师负责 ArkTS 原生实现,只要接口定义清楚,两边可以并行推进。第三,鸿蒙的分布式能力只有在原生侧才真正可用,与其在 JS 层做各种模拟,不如把它做成原生能力模块,再按需暴露给 RN。
2.3 选型前先回答团队里的三个问题
除了技术上的优劣,建议团队在动手前也要想清楚几个现实问题:有没有人能写 ArkTS?如果团队不大,是否有一个人愿意同时维护 JS 桥接层和鸿蒙原生组件?公司业务是否真的有大量鸿蒙用户?如果这三个问题的答案有一个不乐观,我的建议都是“先放一放”。技术手段再成熟,没有持续投入的人力,RN 鸿蒙化很难真正落地,反而会拖垮主线业务。
3. 按组件接入的做法:把 ArkTS 底部导航栏封装成 RN 组件
3.1 第一次动手前,先锁定这套版本组合
我建议的基线差不多是这样:
| 技术层 | 建议选型 |
|---|---|
| HarmonyOS 系统 | 5.0.0(12),API 12 |
| 开发工具 | DevEco Studio 5.0 及以上 |
| React Native 版本 | 以适配层明确支持的稳定版本为准,如 0.72~0.74 这一档 |
| JS 引擎 | 用适配层默认支持的,不要自己切换 |
| 社区适配工程 | 通过 npm 安装的鸿蒙适配包 |
这里要特别提醒:把某个确定可用的组合记录下来,写到项目 README 里。很多时候一两个月后回头看,已经没人记得当初用了哪个适配层版本、哪个 RN 补丁版本,只能重新试错。
3.2 在 ArkUI 里写一个可复用的底部导航栏组件
底部导航栏是鸿蒙应用开发里非常常见的基础组件,也适合作为第一个练手对象。结构很简单:一个横向Row,内部放多个 Tab 项,选中态和非选中态通过状态变量控制。
@Component export struct HMNavBar { @Link currentIndex: number; @Prop items: string[] = ['首页', '动态', '我的']; onTabChange?: (index: number) => void; @Builder tabItem(title: string, index: number) { Column({ space: 2 }) { Text(title) .fontSize(this.currentIndex === index ? 18 : 15) .fontColor(this.currentIndex === index ? '#1E88E5' : '#999999') .fontWeight(this.currentIndex === index ? FontWeight.Bold : FontWeight.Regular) } .justifyContent(FlexAlign.Center) .height('100%') .layoutWeight(1) .onClick(() => { this.onTabChange?.(index); }) } build() { Row() { ForEach(this.items, (title: string, index: number) => { this.tabItem(title, index) }) } .width('100%') .height(56) .backgroundColor('#FFFFFF') .border({ width: { bottom: 1 }, color: '#EEEEEE' }) } }这段代码的核心思想是:组件内部用@Link currentIndex接收外部传入的选中值,用@Prop items接收 Tab 配置,当用户点击某个 Tab 时,通过onTabChange回调把新的索引抛给上层。用 React 的思维去理解,它就是一个受控组件。
3.3 把原生组件注册给 React Native
在社区适配层中,鸿蒙原生组件要暴露给 RN,通常需要在鸿蒙工程里找一份“原生组件注册清单”,把HMNavBar填进去,并给它一个对外名称,例如HMTabBar。这里有个很容易忽略的点:原生组件的对外名称需要全局唯一,否则跟 Android/iOS 已有的同名组件会冲突,RN 侧解析时会串号。
RN 侧在旧架构下可以直接用requireNativeComponent把这个原生组件变成 JS 组件:
import { requireNativeComponent } from 'react-native'; const NativeTabBar = requireNativeComponent('HMTabBar'); export default function NavBar(props) { const { index = 0, onSelect } = props; const handleSelect = (e) => { const nextIndex = e?.nativeEvent?.index; if (typeof nextIndex === 'number' && onSelect) { onSelect(nextIndex); } }; return <NativeTabBar style={{ height: 56 }} index={index} onSelect={handleSelect} />; }如果你接入的适配层已经支持新架构,codegen 会自动生成类型化接口,但鸿蒙侧的桥接逻辑仍要手写。所以无论新旧架构,注册表、参数名、回调事件结构,都是最容易出错的部分。
3.4 参数与事件的几个“对不齐”陷阱
这类桥接工作中,我最常遇到的问题是:
- 参数大小写对不上。RN 原生组件只看 camelCase 的 prop,鸿蒙侧如果定义
tabIndex,JS 侧就不能改成tab-index或TabIndex。 - 回调事件结构对不上。鸿蒙侧通过事件通道吐给 JS 的字段名,跟 RN 侧读取的字段名必须完全一致,否则拿到的是
undefined。 - 不要传
undefined给原生组件。桥接通道序列化时,undefined字段很容易丢失或者被转成异常值,给默认值比什么都重要。 style只能控制容器层。鸿组件内部布局由 ArkUI 决定,RN 侧传进来的style并不等于 ArkUI 内部的 flex 属性,需要把对外暴露的样式参数单独设计出来。
4. 不只是 UI 组件:把鸿蒙分布式能力做成 RN 原生模块
4.1 分布式能力的价值与封装边界
HarmonyOS 从一开始就强调自己是分布式操作系统,多设备协同、数据流转、跨端服务都不是概念,而是系统级 API 提供的实际能力。但 React Native 本身并不感知这些能力。要在 RN 项目里用上鸿蒙的分布式特性,最干净的做法是在鸿蒙侧把这些能力封装成原生模块,再通过NativeModules.XXX暴露给 JS。
这里有必要控制封装边界:设备发现、远程数据同步、与系统账号相关的状态,这些都不应该在 JS 层硬模拟。JS 层既没有权限也没有合适的系统 API,强行去模拟只会得到一堆不稳定代码。
4.2 一个分布式模块的封装套路
鸿蒙侧模块的大致结构可以分成四步:初始化系统能力、提供同步或异步方法、把结果通过回调或事件队列返回、在模块销毁时做反注册。JS 侧再把它封装成 Promise 风格,统一错误码:
import { NativeModules } from 'react-native'; const { HMDistribute } = NativeModules; export async function listReachableDevices() { try { const devices = await HMDistribute.listReachableDevices(); return devices || []; } catch (e) { const code = e?.code || 'UNKNOWN'; console.error(`[HMDistribute] list failed: ${code}`); return []; } }在真正落地时,你大概率还要关注设备上线和下线的事件监听。通常由鸿蒙侧通过事件通道主动推送给 JS 侧,JS 侧在useEffect或页面生命周期里注册监听,并在组件卸载时及时移除,避免事件监听在多次页面切换后堆积,导致内存上涨或者白屏。
4.3 跨端数据同步的几个注意点
事件推送频率不能太高。分布式设备状态变化频繁时,原生侧自己要做节流和合并,否则桥接通道会被瞬时的大量事件塞满。数据序列化时,不要直接传对象引用,鸿蒙侧到 JS 侧要经过桥通道,对象字段顺序、非法字符都可能成为问题。另外还要考虑非鸿蒙环境的降级方案,同一个 JS 业务在三端跑,如果调用一个只有鸿蒙才有的分布式模块,Android 或 iOS 上就得有一个空实现或者明确错误码,否则线上会出现不可控异常。
5. 启动白屏排查实录:一整套能复现的排查思路
5.1 白屏只是“结果”,不一定是“崩溃”
“React Native 启动白屏”是鸿蒙化搜索里排名靠前的关键词,我自己也在这个问题上耗费了大量时间。典型表现是:点击应用图标,窗口能正常打开,但页面一直空白,没有红屏报错,也没有崩溃日志。原因在于 RN 在鸿蒙上的加载链路太长,任何一环断掉都可能整体黑屏,而这条链路横跨系统容器、Bundle 下载、JS 引擎、原生模块注册四个层面。排查白屏最忌讳上来就改代码,而是应该按链路逐段验证。
5.2 逐段验证的具体步骤
第一步,用 HiLog 过滤关键标签。DevEco Studio 的 HiLog 窗口支持关键字过滤,先看ReactNativeJS、RNOH或适配层约定的其他标签。如果全程没有任何 JS 相关日志,问题大概率出在容器加载或 Bundle 没有到达。
第二步,确认 Metro 是否收到请求。在电脑上启动 Metro 终端,观察有没有来自设备的 Bundle 请求。如果没有,不是地址问题就是网络问题;如果有,就盯着错误信息往下走。
第三步,检查网络权限。鸿蒙工程module.json5里的权限声明经常被忽略,如果没加ohos.permission.INTERNET,真机上是拉不到 Metro Bundle 的。更隐蔽的是,模拟器可能表现又不一样,所以同一份配置在不同设备上白屏表现不同,会让人非常困惑。
第四步,排除 JS 引擎问题。如果适配层同时支持 Hermes 和 JSC,把引擎切换一下再跑。这里不是让你两个引擎都上生产,而是用“切换法”确认问题到底是不是引擎兼容性引起的。
第五步,检查自定义原生组件注册。如果白屏页面里插入了自定义鸿组件,而它没有在注册表里注册,RN 在创建原生视图时会失败,表现同样是白屏。把页面组件拆到只剩一个基础视图,再逐步加回来,能快速定位到是哪一步引起的。
5.3 一张白屏排查决策表
| 白屏现象 | 优先检查项 | 验证方法 | 常见处理 |
|---|---|---|---|
| 首启动全白,无任何日志 | 容器或 Bundle 未加载 | HiLog 过滤 RN 标签 | 确认 HAP 内置 Bundle 路径 |
| 模拟器正常,真机白屏 | Metro 网络不可达 | Metro 终端看请求 | 配置局域网 IP 访问地址 |
| 有报错日志但页面空白 | JS 运行时异常 | 读取错误堆栈 | 修复 JS 执行错误 |
| 页面中某一区域白屏 | 原生组件未注册 | 组件拆零、逐一恢复 | 检查注册表和组件名 |
| 旋转屏幕后白屏 | 生命周期或布局问题 | 复现并看 ArkUI 日志 | 补页面周期回调 |
5.4 白屏排查的一个经验结论
白屏排查的大方向就是逐段隔离。团队里经常有人遇到白屏就反复重启、清缓存,这种做法除了浪费生命没有太大价值。把系统日志、Metro 日志、鸿蒙组件日志三条流同时打开,哪一段先断,就顺着哪一段往下查,基本上都能在半小时内定位到问题。要特别记住:白屏不是本次问题的名字,只是问题的伪装。
6. 从跑通到稳定:命名、性能、测试这些事
6.1 命名规范和目录隔离
当项目里同时存在 Android/iOS 原生组件和鸿组件时,最好给鸿组件一套统一前缀,比如HM。原生组件的标签名、模块名、文件目录都带上前缀,避免跟 Android 同名组件在注册表里冲突。桥接代码单独放在src/harmony/下,跟纯 JS 业务分家,以后鸿蒙侧升级时只需要动这一层,不用翻整个项目。
6.2 首屏性能和包体积
如果不是整体替换鸿蒙端,注意别把所有的鸿组件一次性打进主包。鸿组件的固有开销不在 JS 侧,而在 ArkUI 侧组件树的创建成本。一个页面塞进太多重组件时,系统日志里能看到组件树构建耗时明显上升。我建议按页面拆、按能力拆,用到时再加载。界面层级尽量保持浅平,能用一个轻量容器解决的就不要嵌套多层。底部导航栏这类固定组件可以常驻,但像分布式设备列表这类低频模块,放到页面需要时再动态加载更合适。
6.3 测试矩阵和发版节奏
模拟器适合第一轮功能验证,真机必须做第二轮回归。两者的网络权限、安全区域、分辨率差异可能暴露完全不同的问题。每次鸿蒙 SDK 升级到新的 API 版本后,都需要提前做一轮兼容测试。发版节奏上,我个人的习惯是先放一个小流量灰度,观察启动白屏率和崩溃率,稳定后再全量。如果出了问题,回滚也只需要撤掉鸿相关模块,不会牵连到整个 RN 版本。
我自己走完一遍之后的体会是,如果你正在评估“在 React Native 里做鸿组件”这条路线,不必一开始就追求把整个 App 塞进鸿蒙。挑一个像底部导航栏这样边界清晰、用户能直接感知的小组件,从踩通一次完整的桥接开始,成本最低、见效最快,也能让团队提前看到桥接层的真实复杂度。等这条路走顺了,再谈大规模接入也不迟。