给现有的 React Native 应用增加 HarmonyOS 支持,理想情况是业务代码一行不改,新增一个平台就像安装一个新依赖。为了验证这条路,我写了一个 demo 应用,功能围绕 expo-sharing 展开,从 react-native CLI 初始化的裸工程开始,用 expo-harmony 完成接入,最终在 Android、iOS、HarmonyOS 三个平台的模拟器上运行。这篇文章记录完整的接入步骤和过程中遇到的问题。

先看最终效果。同一份 App.tsx,没有任何平台分支,在三个模拟器上都跑通了生成文件、调起系统分享面板、面板关闭后更新状态的完整流程。

AndroidiOSHarmonyOS

同一个界面,三个平台各自的渲染效果。

Expo Harmony 是什么

欢迎给上面这两个仓库点点 Star 🌟~

expo-harmony 的目标一句话可以说清,让 Expo 驱动的 React Native 应用运行在 HarmonyOS 上。

它的底层是 RNOH,也就是 React Native 在 OpenHarmony 上的移植实现。RNOH 解决了渲染层和运行时的问题,Expo 生态是另一块空白,expo-harmony 补的是这一块。项目目前适配 Expo SDK 55 和 RNOH 0.84.1。

对应用开发者来说,有四点值得了解。

一是模块覆盖。常用 Expo 模块基本都有了 HarmonyOS 实现,统一发布在 @expo-harmony/ 这个 scope 下,expo-file-system、expo-sharing、expo-clipboard、expo-image、expo-sqlite、expo-notifications 都在列表里,完整清单见仓库 README。

二是成对安装。业务代码 import 的始终是官方 JS 包,比如 expo-sharing。HarmonyOS 原生实现由配套的 @expo-harmony/expo-sharing 提供。两个包一起安装,版本相互对应。JS 这一层完全不用感知平台。

三是两种工作流。一种是 CNG,HarmonyOS 原生工程由 app.json 配置生成,不需要手工维护。另一种是 bare,在已有原生工程里集成,harmony/ 目录自行维护。demo 用的是 bare 方式,因为工程本来就手工维护着 android/ 和 ios/,再加一个手工维护的 harmony/ 顺理成章。

四是自动链接。依赖安装完成后执行一条命令,模块的原生注册和构建依赖自动生成,不需要为每个模块手写 ArkTS 或 C++ 的注册代码。

Demo 应用

工程用社区 CLI 初始化,纯 RN 模板,没有任何 Expo 的东西。

npx --yes @react-native-community/cli@latest init ExpoSharingDemo \
  --version 0.83.10 --pm npm

版本选择 RN 0.83.10。原因在讲两套 React Native 时会说明,这里先记住一点,iOS/Android 侧的 RN 版本要跟随 Expo SDK 指定的版本,SDK 55 对应 0.83 系列。最初用 0.84.1 初始化,Android 构建时 expo-modules-core 的 Kotlin 编译报 Promise.kt 'reject' overrides nothing,因为 Expo SDK 55 的原生代码是针对 RN 0.83.x 的接口写的,改回 0.83.10 后编译通过。

demo 的功能是本地笔记分享。点击「生成本地笔记文件」,用 expo-file-system 在应用缓存目录写一份文本笔记,界面显示文件路径、内容预览和 Sharing.isAvailableAsync() 的检测结果。点击「用系统面板分享这份笔记」,调 expo-sharing 拉起系统分享面板分享这个文件。面板关闭后 Promise 结束,界面更新状态。全程不联网。

expo-sharing 是 Expo 里负责调起系统分享面板的模块。核心方法 shareAsync 接收一个文件地址,调用后弹出系统分享面板,用户选择目标或关闭面板后 Promise 完成。它不返回分享结果,用户选了什么目标、有没有真的分享出去,应用无从得知。模块还提供 isAvailableAsync 检测设备的分享能力。在 HarmonyOS 上 shareAsync 只接受本地文件的 file:// 地址,不支持 data URI,iOS 和 Android 可以直接传 data URI,这是接入过程中遇到的第一个平台差异。因此文件需要先写入本地,demo 配合使用了同样有鸿蒙适配包的 expo-file-system。

核心逻辑如下。

import * as Sharing from 'expo-sharing';
import { File, Paths } from 'expo-file-system';

const NOTE_FILE_NAME = 'expo-sharing-demo-note.txt';

// 生成笔记文件
const cacheUri = Paths.cache.uri.endsWith('/') ? Paths.cache.uri : `${Paths.cache.uri}/`;
const noteFile = new File(`${cacheUri}${NOTE_FILE_NAME}`);
noteFile.create({ overwrite: true, idempotent: true });
noteFile.write(buildNoteContent());

// 分享
await Sharing.shareAsync(noteFile.uri, {
  mimeType: 'text/plain',
  UTI: 'public.plain-text',
  dialogTitle: '分享本地笔记',
});

路径手工拼接而不是用 Paths.join,这是两处跨端调整之一,后文会说明。工程里的 react-native-safe-area-context 保留使用,界面用 SafeAreaProvider 和 useSafeAreaInsets 处理安全区,它在三个平台都有真实用途。

安装依赖

依赖按四组安装。版本取这次实际使用的组合,@expo-harmony/* 包的 peerDependencies 写明了配套版本,安装时需要核对。

第一组,Expo 官方 JS 包。

npm install expo@55.0.26 expo-modules-core@55.0.25 \
  expo-sharing@55.0.20 expo-file-system@55.0.24

第二组,HarmonyOS 适配包,和上面的版本一一对应。

npm install @expo-harmony/cli@55.0.26-harmony.13 \
  @expo-harmony/metro-config@55.0.26-harmony.4 \
  @expo-harmony/expo@55.0.26-harmony.3 \
  @expo-harmony/expo-modules-core@55.0.25-harmony.5 \
  @expo-harmony/expo-modules-autolinking@55.0.25-harmony.5 \
  @expo-harmony/expo-sharing@55.0.20-harmony.7 \
  @expo-harmony/expo-file-system@55.0.24-harmony.5

第三组,RNOH 运行时。react-harmony 是一个 npm alias,实际安装的是 react@19.2.3,挂在 react-harmony 这个名字下,为什么需要它后面会讲。hermes-compiler 必须作为直接依赖安装,Release 打包编译 Hermes 字节码时要用。

npm install --save-exact @react-native-oh/react-native-harmony@0.84.1 \
  @react-native-oh/react-native-harmony-cli@0.84.1 \
  react-harmony@npm:react@19.2.3 hermes-compiler@250829098.0.9
npm install react-native-worklets@0.7.4 @react-native-ohos/react-native-worklets@1.0.0

第四组,打包配套,对齐 expo-harmony bare 示例工程的清单。

npm install @expo/metro-runtime@55.0.12 @expo/log-box@55.0.12 \
  metro@0.83.3 metro-config@0.83.7
npm install --save-dev babel-preset-expo@~55.0.22

demo 用到了 safe-area-context 的鸿蒙适配包,一并安装。

npm install @react-native-ohos/react-native-safe-area-context@5.6.4

安装过程中有两个问题需要提前说明。

一是 npm 会报 ERESOLVE。RNOH 的 peerDependencies 锁 react-native@0.84.1,项目里 iOS/Android 侧用的是 0.83.10,两边冲突。在项目根的 .npmrc 写入 legacy-peer-deps=true 即可,expo-harmony 官方示例也是这样处理。

legacy-peer-deps=true

二是 Metro 可能报 Cannot find module 'babel-preset-expo'。npm 会把 babel-preset-expo 嵌套装进 expo/node_modules,Babel 从项目根解析不到。把它显式安装为根目录的 devDependency 即可解决,上面第四组命令里已经带上。

配置 Metro,让两套 React Native 共存

这里需要先交代一个背景。RNOH 和官方 React Native 是两条发布线,版本对不齐是常态,Expo SDK 锁自己的 react-native,RNOH 有自己的版本。expo-harmony 的做法是不要求两边一致,两套都装进 node_modules,打包时按平台分流。

iOS / AndroidHarmonyOS
React Native0.83.10RNOH 0.84.1,基于 RN 0.84.1
React19.2.0react-harmony,即 react@19.2.3
原生构建CocoaPods / GradleOHPM + Hvigor

React 为什么要两份。一个 bundle 里只能有一个 react 实例,出现两个会报 hooks 错误。RNOH 0.84 的渲染层配 react 19.2.3,Expo SDK 55 锁 react 19.2.0,两边不能共用也不能只留一份,所以用 npm alias 装出第二份。

分流靠 metro.config.js。

const { getDefaultConfig } = require('expo/metro-config');
const { withHarmonyConfig } = require('@expo-harmony/metro-config');

const projectRoot = __dirname;
const isHarmony = process.env.EXPO_HARMONY === '1';

module.exports = withHarmonyConfig(getDefaultConfig(projectRoot), {
  enabled: isHarmony,
  projectRoot,
  aliases: { react: 'react-harmony' },
});

enabled 由环境变量 EXPO_HARMONY 控制,expo-harmony 的命令会自动设置它。日常 iOS/Android 打包时这个开关关闭,打包行为和原来一致。为鸿蒙打包时开关打开,import 'react-native' 会被换成 RNOH,import 'react' 命中 aliases 指到 react-harmony。业务代码一行不用改,仍然写 import { View } from 'react-native'。

注意 Metro 配置要基于 expo/metro-config 的 getDefaultConfig 再套 withHarmonyConfig。Expo 的 Metro 配置带着模块解析和初始化流程,withHarmonyConfig 是在这之上接入 RNOH 的 resolver,只给 react-native 设一个别名是不够的。

接着换 Babel 预设,新建 react-native.config.js。

// babel.config.js
module.exports = { presets: ['babel-preset-expo'] };

// react-native.config.js
module.exports = require('@react-native-oh/react-native-harmony-cli/react-native.config.js');

react-native.config.js 这行只是把 RNOH 的 CLI 命令注册进来,不影响 iOS/Android 原有的 autolinking。

最后在 package.json 里加四个脚本。

{
  "scripts": {
    "start:harmony": "expo-harmony start",
    "run:harmony": "expo-harmony run",
    "build:harmony": "expo-harmony build",
    "doctor:harmony": "expo-harmony doctor"
  }
}

给 iOS 和 Android 接上 Expo 模块

这一步和鸿蒙无关,是裸 RN 工程使用 Expo 模块的通用前置。Expo 官方对这件事有文档和 install-expo-modules 工具,我在 RN 0.83 的模板上运行这个工具报了 Unable to find compatible Expo SDK version,于是参照 Expo 官方 bare 模板手工补了几个文件。遇到同样的报错时,按下面的修改即可。

Android 侧改两处。settings.gradle 接入 expo-gradle-plugin,把社区 CLI 的 autolinking 命令换成 Expo 的。

 pluginManagement {
   includeBuild("../node_modules/@react-native/gradle-plugin")
+  def expoPluginsPath = new File(
+    providers.exec {
+      workingDir(rootDir)
+      commandLine("node", "--print", "require.resolve('expo-modules-autolinking/package.json', { paths: [require.resolve('expo/package.json')] })")
+    }.standardOutput.asText.get().trim(),
+    "../android/expo-gradle-plugin"
+  ).absolutePath
+  includeBuild(expoPluginsPath)
 }
 plugins {
   id("com.facebook.react.settings")
+  id("expo-autolinking-settings")
 }
-extensions.configure(com.facebook.react.ReactSettingsExtension){ ex -> ex.autolinkLibrariesFromCommand() }
+extensions.configure(com.facebook.react.ReactSettingsExtension) { ex ->
+  ex.autolinkLibrariesFromCommand(expoAutolinking.rnConfigCommand)
+}
+expoAutolinking.useExpoModules()
+expoAutolinking.useExpoVersionCatalog()

app/build.gradle 的 react 块里加三行,入口文件交给 expo 解析,Release 打包走 Expo CLI,这样 Metro 配置在所有平台保持一致。

+def projectRoot = rootDir.getAbsoluteFile().getParentFile().getAbsolutePath()
 react {
+    entryFile = file(["node", "-e", "require('expo/scripts/resolveAppEntry')", projectRoot, "android", "absolute"].execute(null, rootDir).text.trim())
+    cliFile = new File(["node", "--print", "require.resolve('@expo/cli', { paths: [require.resolve('expo/package.json')] })"].execute(null, rootDir).text.trim())
+    bundleCommand = "export:embed"

iOS 侧改 Podfile,顶部 require expo 的 autolinking 脚本,target 里加 use_expo_modules!,config 命令换成 expo-modules-autolinking 的。

 require Pod::Executable.execute_command('node', ['-p',
   'require.resolve("react-native/scripts/react_native_pods.rb", {paths: [process.argv[1]]})', __dir__]).strip
+require File.join(File.dirname(`node --print "require.resolve('expo/package.json')"`), 'scripts/autolinking')

 target 'ExpoSharingDemo' do
+  use_expo_modules!
-  config = use_native_modules!
+  config_command = ['node', '--no-warnings', '--eval',
+    'require(\'expo/bin/autolinking\')', 'expo-modules-autolinking',
+    'react-native-config', '--json', '--platform', 'ios']
+  config = use_native_modules!(config_command)

还有一个构建问题。RN 依赖的 glog 0.3.5 在新版 Xcode 下编译不过,报 unknown type name 'int32'。Podfile 顶部加两个环境变量,让 RN 核心依赖走 Expo 模板同款的预编译方案,源码编译环节没有了,问题随之绕开。

ENV['RCT_USE_RN_DEP'] ||= '1'
ENV['RCT_USE_PREBUILT_RNCORE'] ||= '1'

pod install 之后在 Podfile.lock 里确认 ExpoModulesCore、ExpoFileSystem、ExpoSharing 都在。到这里可以先运行一遍 Android 和 iOS,确认 Expo 模块在原有平台工作正常,再进入鸿蒙的部分。

搭建 HarmonyOS 原生工程

先交代环境。需要 DevEco Studio 和完整的 HarmonyOS SDK,ohpm、hvigor、hdc 这些工具都在里面,Node 用 20 以上。bare 工程不用 prebuild,harmony/ 原生工程从 expo-harmony 仓库的 apps/bare/harmony 复制而来,官方文档明确允许把示例工程当作起点。复制时排除自动链接的生成物和构建产物,这些之后由工具重新生成。

# <expo-harmony> 换成你本地的仓库路径
rsync -a \
  --exclude oh_modules --exclude oh-package-lock.json5 \
  --exclude entry/src/main/cpp/autolinking.cmake \
  --exclude entry/src/main/cpp/RNOHPackagesFactory.h \
  --exclude entry/src/main/cpp/rnoh_codegen \
  --exclude entry/src/main/ets/RNOHPackagesFactory.ets \
  --exclude entry/src/main/ets/generated \
  --exclude entry/src/main/resources/rawfile/hermes_bundle.hbc \
  --exclude .hvigor --exclude build --exclude .cxx \
  <expo-harmony>/apps/bare/harmony/ harmony/

复制得到的是一套可直接使用的 RNOH 宿主工程,ArkTS 的 Ability、页面、Worker,C++ 的 CMake 和包注册都在。需要修改的地方不多,如下表。

文件改什么
AppScope/app.json5bundleName 改成 com.exposharingdemo.app
oh-package.json5name 同步成包名,删掉示例里用不到的 expo-battery 依赖
build-profile.json5compatibleSdkVersion 改为 6.0.1(21)
AppScope 下的 string.jsonapp_name 改成 Expo Sharing Demo
entry 下的 string.jsonability label 改成本地笔记分享
entry/src/main/ets/pages/Index.etsappKey 从 BareBattery 改成 ExpoSharingDemo

有三个容易出错的地方。

bundleName 的格式。鸿蒙要求包名至少三段,com.exposharingdemo 不行,hvigor 会报 pattern 校验错误。加一段变成 com.exposharingdemo.app 即可通过校验。它和 Android 的 applicationId 不要求一致。

compatibleSdkVersion。示例工程默认 6.0.0(20),而 expo-file-system 的适配包要求最低 API 21,构建时会直接报 compatibleSdkVersion cannot be lower than the minimum compatible version required by the dependencies。改成 6.0.1(21) 即可,targetSdkVersion 保持不变。

appKey 的值要与 app.json 的 name 一致,也就是 AppRegistry.registerComponent 注册的那个名字,这里是 ExpoSharingDemo。不一致时页面白屏。另外应用在桌面和任务栏显示的名字由原生资源 string.json 决定,改 app.json 的 displayName 对鸿蒙不起作用。

自动链接和首次运行

依赖和原生工程就位后,在应用根目录执行自动链接。

npx expo-harmony-autolinking link --project-root . --harmony-project-path ./harmony
cd harmony && ohpm install --all && cd ..

link 命令解析已安装的 Expo 和 RNOH 模块,生成包注册文件 RNOHPackagesFactory,更新 CMake 配置,把各适配包的 HAR 写进 oh-package.json5。ohpm install 负责安装原生依赖,首次从 DevEco Studio 或 hvigor 发起构建之前必须先完成这一步。之后新增或删除了原生模块,重新构建应用即可。

先运行一次诊断。

npm run doctor:harmony

它会检查 bare 工程识别、Metro 配置、依赖解析、原生模块清单、DevEco SDK 和工具链。出现 error 时按照输出逐项修改,全部通过后再继续。

运行使用两个终端。一个启动 Metro,注意必须用 start:harmony 启动的这一份,它会带上 EXPO_HARMONY=1 环境变量。为 iOS/Android 启动的那份 Metro 服务不了鸿蒙 bundle,解析结果是不对的,这一点在实际开发里容易忽略。

npm run start:harmony -- --clear

另一个终端构建安装。

npm run run:harmony -- --no-bundler

run:harmony 会构建 HAP,选择设备或启动模拟器,安装并拉起应用,端口反向映射也一并处理。不带 --no-bundler 时它会连 Metro 一起管理,分成两个终端是为了分别查看 Metro 和构建的日志。

应用启动后界面和 Android、iOS 上的一致。点击「生成本地笔记文件」,状态卡片显示文件写进了应用沙箱的 cache 目录,路径是 file:///data/storage/el2/base/haps/entry/cache/expo-sharing-demo-note.txt,分享能力检测为可用。再点击分享按钮,系统分享面板弹出。

AndroidiOSHarmonyOS

三个平台的面板样式不同。Android 显示 Sharing 1 file 和文件名,iOS 显示文本文稿和字节数,鸿蒙显示文件卡片和大小,下面是华为分享、复制、另存为、打印这些目标,同机安装的其他支持接收分享的应用也会出现在列表里。面板关闭后 Promise 完成,三端行为一致,应用只拿到面板关闭这个事实,用户选了什么目标、有没有真的分享出去,接口不提供。

业务代码里的两处跨端调整

接入鸿蒙对业务代码的影响只有两处,都不是平台分支,改完三个平台共用。

第一处是文件路径。new File(Paths.cache, 'note.txt') 这种写法在 iOS 和 Android 上正常,在鸿蒙上拿到的 uri 会变成 cache 目录本身,文件名丢失,写入时报 Cannot replace a directory with a file。原因是 RNOH 的 URL polyfill 不支持 pathname 写入,Paths.join 内部走 file:// URL 分支时取回的还是原值。改成手工拼接缓存目录 URI 和文件名,三端行为一致。

const cacheUri = Paths.cache.uri.endsWith('/') ? Paths.cache.uri : `${Paths.cache.uri}/`;
const noteFile = new File(`${cacheUri}${NOTE_FILE_NAME}`);

第二处是时间格式化。RNOH 的 Hermes 没有实现 Date.prototype.toLocaleString,直接调用显示 dateFormat not implemented。笔记里的生成时间改为手工格式化,同时保证了三端显示统一。

const pad = (n: number) => String(n).padStart(2, '0');
const generatedAt =
  `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())} ` +
  `${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`;

零散的坑

safe-area-context 的适配包没有被自动链接。生成的注册文件里只有 Expo 系和 worklets 的包,缺 SafeAreaViewPackage。排查后发现 RNOH 的链接工具只处理 package.json 里声明了 harmony.autolinking 的包,这个适配包只声明了 harmony.alias。解决办法是写一个 postinstall 脚本,在每次 npm install 之后给它补上 autolinking 元数据。

pkg.harmony.autolinking = {
  ohPackageName: '@react-native-ohos/react-native-safe-area-context',
  etsPackageClassName: 'SafeAreaViewPackage',
  etsPackageImport: 'named',
  cppPackageClassName: 'SafeAreaViewPackage',
  cmakeLibraryTargetName: 'rnoh_safe_area',
};

几个字段分别告诉链接工具 HAR 包名、ArkTS 和 C++ 侧导出的类名、导入方式以及 CMake 目标名。类名和目标名要与适配包实际导出的一致,按包名推断会链接失败,报 SafeAreaViewPackage.h file not found。postinstall 挂在 package.json 的 scripts 里,重装依赖后依然生效。这个经验可以推广,生成的注册文件里少了某个适配包时,先看它的 package.json 有没有 harmony.autolinking 声明。

Metro 对文件变更的监听在这个环境下不太可靠。修改代码后应用没有变化时,先用 --clear 重启 Metro 再重启应用,再排查别的原因。

构建时可能看到一条 ERR_EXPO_HARMONY_DUPLICATE_MODULE 警告,是依赖树里嵌套的 react-native@0.84.1 触发的,构建结果不受影响,可以忽略。

写在最后

回顾为鸿蒙接入实际做的事。安装了一批依赖,其中鸿蒙专用的一半在接入 iOS/Android 阶段已经装好。写了一份 metro.config.js,用别名让两套 React Native 分流。从示例工程复制了 harmony/ 目录,改了包名、SDK 版本、应用名和 appKey 四处。执行了一次自动链接。业务代码改了两行兼容写法。

没有做的事更能说明问题。没有写一行 ArkTS 业务代码,没有平台分支,没有为鸿蒙替换任何库,原有 iOS 和 Android 的构建流程没有变化。

模块移植和工具链这两部分最繁重的工作,expo-harmony 已经完成,落到应用这一层,剩下的主要是配置和少量跨端兼容代码。如果应用本来就在用 Expo 模块,或者愿意把部分原生能力换成有适配的 Expo 模块,上鸿蒙的改动量就是这篇文章记录的内容。

bare 接入的完整文档在仓库的 BareInstallation.md,建议动手前通读一遍,QuickStart.md 里对两套 React Native 共存机制有更细的解释。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐