在 HarmonyOS 上使用 expo-sharing
给现有的 React Native 应用增加 HarmonyOS 支持,理想情况是业务代码一行不改,新增一个平台就像安装一个新依赖。为了验证这条路,我写了一个 demo 应用,功能围绕 expo-sharing 展开,从 react-native CLI 初始化的裸工程开始,用 expo-harmony 完成接入,最终在 Android、iOS、HarmonyOS 三个平台的模拟器上运行。这篇文章记录完整的接入步骤和过程中遇到的问题。
先看最终效果。同一份 App.tsx,没有任何平台分支,在三个模拟器上都跑通了生成文件、调起系统分享面板、面板关闭后更新状态的完整流程。
| Android | iOS | HarmonyOS |
|---|---|---|
![]() | ![]() | ![]() |
同一个界面,三个平台各自的渲染效果。
Expo Harmony 是什么
- AtomGit 仓库:atomgit.com/baoshuo/expo-harmony
- GitHub 仓库:github.com/renbaoshuo/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 / Android | HarmonyOS | |
|---|---|---|
| React Native | 0.83.10 | RNOH 0.84.1,基于 RN 0.84.1 |
| React | 19.2.0 | react-harmony,即 react@19.2.3 |
| 原生构建 | CocoaPods / Gradle | OHPM + 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.json5 | bundleName 改成 com.exposharingdemo.app |
oh-package.json5 | name 同步成包名,删掉示例里用不到的 expo-battery 依赖 |
build-profile.json5 | compatibleSdkVersion 改为 6.0.1(21) |
AppScope 下的 string.json | app_name 改成 Expo Sharing Demo |
entry 下的 string.json | ability label 改成本地笔记分享 |
entry/src/main/ets/pages/Index.ets | appKey 从 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,分享能力检测为可用。再点击分享按钮,系统分享面板弹出。

| Android | iOS | HarmonyOS |
|---|---|---|
![]() | ![]() | ![]() |
三个平台的面板样式不同。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 共存机制有更细的解释。
更多推荐










所有评论(0)