为现有基于 Expo 的 React Native 项目适配 HarmonyOS 系统
最近把一个之前为 Android / iOS 开发的 TODO 应用移植到了 HarmonyOS 上,用的是 expo-harmony 来做快速接入。
移植完回头一看,其实业务代码几乎没动,整个移植过程的工作量都花在装依赖和改配置上,不过中间踩了几个坑。这里按实际操作的顺序把整个过程记一下。
expo-harmony 是什么
- AtomGit 仓库:atomgit.com/baoshuo/expo-harmony
- GitHub 仓库:github.com/renbaoshuo/expo-harmony
欢迎给上面这两个仓库点点 Star 🌟~
HarmonyOS 上的 React Native 由 RNOH 承载。RNOH 是 React Native 面向 OpenHarmony 的发行版,和官方 React Native 是两条发布线,版本经常对不齐,Expo 官方也不支持它。expo-harmony 做的事情就是把这块补上,让 Expo 项目在极少改动的前提下跑上 HarmonyOS。
项目目前适配 Expo SDK 55 和 RNOH 0.84.1,提供的东西大概分三块。
- CLI 工具链。
expo-harmony doctor做环境诊断,prebuild生成原生工程,run构建、安装并启动应用,用法和 expo-cli 里对应的命令接近。 - 常用 Expo 模块的移植,发布在 npm 的
@expo-harmony/前缀下。expo-file-system配@expo-harmony/expo-file-system,装上就有鸿蒙原生实现,目前有几十个已经适配好了的包,足够覆盖常用需求。 - 对 Expo CNG 和 Autolinking 的支持。
harmony/原生工程从 app.json 生成,和android/、ios/一样不需要手动维护,也可以不进 git。真有需要手工定制的地方,后面会讲到的 patch-project 可以兜底。
有一个机制得先说清楚,不然看到后面的配置可能会觉得莫名其妙。Expo SDK 55 指定 react-native@0.83.6,RNOH 0.84.1 基于 RN 0.84.1,expo-harmony 不要求两边一致,而是把两套运行时装进同一个项目,打包时按平台分流。给 iOS 和 Android 打包走 Expo 原有链路,一条规则都不变。给鸿蒙打包时,Metro 把 import 'react-native' 换成 RNOH,把 import 'react' 重定向到 react-harmony。
react-harmony 是个 npm alias,实际安装的是 react@19.2.3。一个 bundle 只能有一个 react 实例,RNOH 的渲染层要求的 react 版本和 Expo SDK 55 锁定的 19.2.0 不一致,两边不能共用一份,所以用别名装出第二份。业务代码不用管这些,照常 import 官方包名就行。
移植前的项目
这个应用是一个纯本地的 ToDo List 应用,在 Android 和 iOS 环境使用官方的 Expo SDK 55 构建。

版本对齐
先说环境。构建 HAP 需要 DevEco Studio,带完整的 HarmonyOS SDK、OHPM、Hvigor 和 HDC,还要一台真机或者在 Device Manager 里创建的模拟器。这些有没有装对,expo-harmony doctor 会逐项检查,不用自己一个个去确认。
接入的第一步是把版本对齐。expo-harmony 的 peerDependencies 精确锁在 expo@55.0.26,移植的模块也各自锁官方包的精确版本,@expo-harmony/expo-file-system 锁 expo-file-system@55.0.24,@expo-harmony/expo-haptics 锁 expo-haptics@55.0.14。
npm install --save-exact expo@55.0.26 react-native@0.83.6
npm install --save-exact expo-file-system@55.0.24 expo-haptics@55.0.14
这里有个坑挺容易踩。expo install --fix 会按 SDK 55 当前最新的小版本对齐,跑完一看,expo 就不是 55.0.26 了。接入 expo-harmony 的项目要以 @expo-harmony/* 的 peerDependencies 为准手动核对,不能信 --fix,这也是后面装包的时候带 --save-exact 的原因。
安装依赖
先在项目根新建一个 .npmrc。
legacy-peer-deps=true
RNOH 0.84.1 的 peer 是 react-native@0.84.1,和项目里的 0.83.6 冲突。两套 React Native 共存是 expo-harmony 的设计,这个冲突本来就是预期内的,放宽 peer 检查就行。不加这个文件,npm 装依赖会直接报 ERESOLVE。如果使用 yarn 可以略过这一步。
然后装 CLI 和核心依赖。
npm install --save-dev @expo-harmony/cli
npm install \
@expo-harmony/metro-config \
@expo-harmony/expo \
@expo-harmony/expo-modules-core \
@expo-harmony/expo-modules-autolinking \
@expo-harmony/prebuild-config \
@expo-harmony/expo-build-properties \
@expo-harmony/expo-file-system \
@expo-harmony/expo-checkbox \
@expo-harmony/expo-haptics \
@expo-harmony/expo-status-bar \
@react-native-oh/react-native-harmony@0.84.1 \
@react-native-oh/react-native-harmony-cli@0.84.1 \
react-native-worklets@0.7.4 \
@react-native-ohos/react-native-worklets@1.0.0 \
@react-native-ohos/react-native-safe-area-context \
react-harmony@npm:react@19.2.3
规则很简单,每个要在鸿蒙上用到的 Expo 模块,都配一个同名的 @expo-harmony/expo-* 包。带原生实现的社区库是另一条路,配 RNOH 社区的适配包,react-native-safe-area-context 配 @react-native-ohos/react-native-safe-area-context,这个包后面讲白屏的时候还会用到。@react-native-oh/react-native-harmony 是 RNOH 的 JS 侧运行时,和它的 CLI 版本必须配套。两个 worklets 包是 expo-modules-core 在鸿蒙上的依赖要求。装完可以跑一下 npx expo-harmony modules list,它会列出每个模块对 Harmony 的支持情况,心里有个底。
另外三个包是我被报错教育之后才补上的,写出来,大家可以一次装好。
npm install --save-exact hermes-compiler@250829098.0.9 @expo/metro-runtime@55.0.12
npm install --save-dev @react-native-community/cli@15.1.3
这里面 hermes-compiler 的坑最深,值得单独说说。react-native 自带一个同名包,版本 0.14.1,而 expo-harmony run 的运行时校验要求项目里有 RNOH 指定的 250829098.0.9。不在项目根显式装指定版本,会导致运行时无法找到正确的 Hermes 编译器,进而报错。@expo/metro-runtime 是 doctor 要求的,@react-native-community/cli 是 prebuild 里 link-harmony 步骤的依赖。
配置 app.json 和 metro.config.js
app.json 要改三个地方。
{
"expo": {
"platforms": ["ios", "android", "harmony"],
"plugins": [
"@expo-harmony/prebuild-config",
[
"@expo-harmony/expo-build-properties",
{
"harmony": {
"compatibleSdkVersion": 21,
"targetSdkVersion": 24
}
}
]
],
"harmony": {
"bundleName": "com.example.expotodoapp",
"label": "Expo Todo",
"icon": "./assets/icon.png",
"versionCode": 1
}
}
}
platforms 加上 harmony。plugins 里 @expo-harmony/prebuild-config 注册 Harmony 的 Base Mods。harmony 块里 bundleName 是鸿蒙的应用包名,必填。compatibleSdkVersion 默认是 20,expo-file-system 要求最低 21,所以这里显式写成 21,targetSdkVersion 24 对应 HarmonyOS 6.1.1。
接着新建 metro.config.js。这个项目之前没有这个文件,现在需要自己写一个了。
'use strict';
const { getDefaultConfig } = require('expo/metro-config');
const { withHarmonyConfig } = require('@expo-harmony/metro-config');
const projectRoot = __dirname;
const isHarmony = process.env.EXPO_HARMONY === '1';
const config = getDefaultConfig(projectRoot);
module.exports = withHarmonyConfig(config, {
enabled: isHarmony,
projectRoot,
aliases: {
react: 'react-harmony',
},
});
aliases 就是前面说的 react 重定向。EXPO_HARMONY 由 expo-harmony 的命令自动设置,跑 expo run:android、expo run:ios 时这个变量不存在,withHarmonyConfig 原样返回默认配置,iOS 和 Android 的打包完全不受影响。
最后在 package.json 加几个脚本,在 .gitignore 加一行 /harmony/。
{
"start:harmony": "expo-harmony start",
"run:harmony": "expo-harmony run",
"prebuild:harmony": "expo-harmony prebuild",
"check:harmony": "expo-harmony prebuild . --check",
"doctor:harmony": "expo-harmony doctor"
}
生成原生工程
npx expo-harmony prebuild
这条命令按 CNG 的方式生成 harmony/ 目录,应用配置、entry 模块、Hvigor 配置、RNOH 宿主代码都在里面,Expo 模块的原生链接一并完成,不需要自己动里面的文件。三端的依赖互不引用,iOS 的在 Pods 里,Android 的在 Gradle 里,鸿蒙的在 harmony/oh-package.json5 里,由 OHPM 安装、Hvigor 构建。
这里也有个小坑。prebuild 会往 package.json 写一批依赖,@babel/core、metro、react-dom 这些,然后询问是否安装。脚本或 CI 这类非交互环境里它会直接退出,不用慌,手动 npm install 一次再重跑就行。
构建之前建议跑一遍诊断。
npx expo-harmony doctor
它检查配置、插件注册、Metro、依赖版本、Harmony SDK 和构建工具,有 error 就照着输出改,一般都能定位到问题。
构建运行
npx expo-harmony run
run 依次做环境检查、选设备、OHPM 装依赖、Hvigor 构建 debug HAP、hdc 安装、启动应用,Debug 模式下还会起一个 Metro。没有连接设备时它会自动拉起本地唯一的模拟器,要求 DevEco Studio 6.1.0 以上,多个候选设备时用 --device 指定。iOS 和 Android 这边的命令不变,expo run:ios、expo run:android 照旧。
第一次运行就翻车了,遇到两个问题。
白屏
应用是装上了,一启动却是一片白,只剩 LogBox 的悬浮条挂在那里。Metro 正常出 bundle,JS 也没有报错,一开始挺摸不着头脑的。排查下来是 react-native-safe-area-context 的原生组件在鸿蒙侧没有注册,SafeAreaProvider 一挂载,整棵组件树就渲染失败了。
这个库在鸿蒙上要用 RNOH 社区的适配包 @react-native-ohos/react-native-safe-area-context。JS 这边什么都不用改,适配包声明了 harmony.alias,给鸿蒙打包时 RNOH 的 resolver 会把 import 'react-native-safe-area-context' 自动重定向到适配包,和 react 换成 react-harmony 是同一类机制。
不过光装适配包还不行,白屏照旧。适配包声明的是给 Metro 用的别名信息,autolinking 不认它,原生部分不会被自动链进工程,harmony/ 里有四处要手动补。第一处是 harmony/oh-package.json5,加上适配包的 HAR 依赖。
'@react-native-ohos/react-native-worklets': '../node_modules/@react-native-ohos/react-native-worklets/harmony/worklets.har',
+'@react-native-ohos/react-native-safe-area-context': '../node_modules/@react-native-ohos/react-native-safe-area-context/harmony/safe_area.har',
第二处在 ArkTS 侧的 harmony/entry/src/main/ets/RNOHPackagesFactory.ets,注册包工厂。
import { ReanimatedWorkletPackage } from '@react-native-ohos/react-native-worklets';
+import { SafeAreaViewPackage } from '@react-native-ohos/react-native-safe-area-context';
new ReanimatedWorkletPackage(ctx),
+ new SafeAreaViewPackage(ctx),
剩下两处在 C++ 侧。harmony/entry/src/main/cpp/autolinking.cmake 加编译目标和链接。
+ if(NOT TARGET rnoh_safe_area)
+ add_subdirectory("${OH_MODULES_DIR}/@react-native-ohos/react-native-safe-area-context/src/main/cpp" ./rnoh_safe_area)
+ endif()
set(AUTOLINKED_LIBRARIES
expo_harmony__expo
expo_harmony__expo_modules_core
rnoh_worklets
+ rnoh_safe_area
)
harmony/entry/src/main/cpp/RNOHPackagesFactory.h 加头文件和工厂注册。
#include "ReanimatedWorkletPackage.h"
+#include "SafeAreaViewPackage.h"
std::make_shared<rnoh::ReanimatedWorkletPackage>(ctx),
+ std::make_shared<rnoh::SafeAreaViewPackage>(ctx),
问题接着就来了。harmony/ 是 prebuild 的生成物,这四处手改在下次 prebuild --clean 的时候就会被冲掉,总不能每重新生成一次就改一遍。
对于这种问题,expo-harmony 有专门的 patch-project 工具。它可以把 harmony/ 里的手动修改存成一个 patch,prebuild 时自动应用回去。
不只 safe-area,任何对生成工程的手工定制都可以走这条路持久化下来。
npm install --save-dev @expo-harmony/patch-project
先在 app.json 的 plugins 里注册,放在插件列表最后。
"plugins": [
"@expo-harmony/prebuild-config",
["@expo-harmony/expo-build-properties", { ... }],
+ "@expo-harmony/patch-project"
]
四处修改都写好之后,执行一次生成命令。
npx @expo-harmony/patch-project
# Saved Harmony patch: cng-patches/harmony+fd1d14b8….patch
命令把当前的 harmony/ 和默认生成的工程做比较,差异存进 cng-patches/harmony+<checksum>.patch,这个目录要提交进仓库。之后不管普通 prebuild 还是 --clean 全量重新生成,插件都会自动把补丁套上。用 prebuild --clean 跑一遍就能验证,重新生成的工程里四处修改都还在。
生成补丁的时候踩了一个坑。CMake 的构建产物 harmony/entry/.cxx 默认不在 harmony/.gitignore 里,几个 GB 的东西被 git diff 全量吐出来,直接报 stdout maxBuffer length exceeded。在 harmony/.gitignore 里补上 .cxx/ 和 **/.cxx/ 两条规则就好了。
补丁生效后重新构建,白屏消失,鸿蒙拿到的安全区是适配包测出来的真实窗口 inset,顶部正确避让状态栏和挖孔。业务代码一行没动,三端走的都是 react-native-safe-area-context 的 import。
顺带记两个相关的东西。平台判断写 Platform.OS === 'harmony',React Native 官方类型里还没有这个值,TypeScript 会报类型没有重叠,断言成 string 再比就行。如果一个库连适配包都没有,Metro 支持 .harmony.tsx 这类平台后缀文件,可以给鸿蒙写一个降级实现顶一下,只是降级终究不如真实的原生实现,能用适配包还是优先适配包。
dateFormat not implemented
白屏解决后列表能渲染了,本以为没事了,结果日期一行显示着 “dateFormat not implemented”。查了一下,RNOH 的 Hermes 没有实现 Intl,toLocaleDateString 不可用,只好改成手动拼接,三端行为倒是一致了。
const today = useMemo(() => {
const d = new Date();
const weekdays = ['日', '一', '二', '三', '四', '五', '六'];
return `${d.getFullYear()}年${d.getMonth() + 1}月${d.getDate()}日 星期${weekdays[d.getDay()]}`;
}, []);
用日期库的同学要留意这点,不少日期库依赖 Intl,上鸿蒙之前先确认一下。
运行效果
两个问题都解决之后,应用就完整跑起来了。模拟器是 HarmonyOS 6.1.1,交互、勾选、删除、统计都正常,顶部安全区按适配包的真实测量避让状态栏,强杀进程再启动,数据和勾选状态都在,expo-file-system、expo-checkbox、expo-haptics 三个移植模块工作正常。

结语
最后回顾一下整个接入的改动。.npmrc 一行,依赖一批,package.json 加五个脚本,app.json 加几个字段,metro.config.js 一个新文件,cng-patches/ 里一个补丁,业务代码只改了一个日期格式化函数。真正花时间的地方在确认第三方库的鸿蒙支持情况,和给 safe-area-context 的适配包补上原生链接。
用 expo-harmony 给现有 Expo 应用增加 HarmonyOS 支持,改动集中在依赖和配置,业务代码基本不动,iOS 和 Android 的构建也不受影响。整个过程比预想的简单。仓库里有 快速开始文档 和一个完整的 demo 工程可以参照。
更多推荐
所有评论(0)