小白实战手记:React Native 应用部署到鸿蒙设备全流程详解
但到了鸿蒙平台,部署应用变成了一个多步骤的手动流程:打包 JS → 复制 bundle → 构建 HAP → 安装到设备 → 启动应用。每个步骤都有可能出错,而且错误信息对新手来说完全不友好。我花了很多时间才搞清楚整个流程,所以把所有步骤详细记录下来,希望能帮到其他新手。RNOH 是开源鸿蒙社区的 React Native 适配框架,它让开发者可以用 React Native 技术栈构建鸿蒙应用。
小白实战手记:React Native 应用部署到鸿蒙设备全流程详解
作者:一个刚入坑鸿蒙 RN 的小白
目标读者:和我一样刚接触鸿蒙 React Native 开发,想把应用部署到真机上的新手
环境:macOS + DevEco Studio + React Native 0.82 + HarmonyOS NEXT
项目:集成了 TTS 语音合成、VersionNumber 版本信息、Share 分享、WebView 浏览器四大功能的 RN 鸿蒙应用
目录
- 前言:为什么要写这篇文章
- 认识 RNOH:React Native OpenHarmony
- 项目全景:我们到底要部署什么
- 环境准备:磨刀不误砍柴工
- 项目结构:两个项目的故事
- JS 侧代码:React Native 应用编写
- 原生侧代码:ArkTS 组件与 TurboModule
- C++ 层代码:引擎桥接与代码生成
- 配置文件:把所有东西串起来
- 第一步:打包 JS Bundle
- 第二步:构建 HAP
- 第三步:安装到鸿蒙设备
- 第四步:启动应用
- 调试技巧:出了问题怎么查
- 踩坑实录:那些让我抓狂的错误
- 完整构建部署命令速查
- 总结与心得
1. 前言:为什么要写这篇文章
我之前做 Android/iOS 的 React Native 开发时,部署应用只需要一条命令:
npx react-native run-android # Android
npx react-native run-ios # iOS
但到了鸿蒙平台,部署应用变成了一个多步骤的手动流程:打包 JS → 复制 bundle → 构建 HAP → 安装到设备 → 启动应用。每个步骤都有可能出错,而且错误信息对新手来说完全不友好。
我花了很多时间才搞清楚整个流程,所以把所有步骤详细记录下来,希望能帮到其他新手。
2. 认识 RNOH:React Native OpenHarmony
在开始之前,必须先介绍一下 RNOH(React Native OpenHarmony)——这是我们整个项目的基础。
2.1 什么是 RNOH?
RNOH 是开源鸿蒙社区的 React Native 适配框架,它让开发者可以用 React Native 技术栈构建鸿蒙应用。简单来说,它做的事情是:
你在 JS 层写的 <View>、<Text>、<WebView>
↓ RNOH 桥接
鸿蒙原生的 Column、Text、Web 组件
2.2 RNOH 的架构
┌─────────────────────────────────────────────────┐
│ JS 层 (TypeScript/React) │
│ 你写的 React 代码:App.tsx, WebView, TTS 等 │
│ 通过 Metro 打包成 bundle.harmony.js │
├─────────────────────────────────────────────────┤
│ 原生层 (ArkTS/ETS) │
│ RNOH 提供的 RNApp、RNAbility │
│ 你写的原生组件:RNCWebView.ets 等 │
│ 三方库的原生适配:RNCWebViewPackage.ets 等 │
├─────────────────────────────────────────────────┤
│ C++ 层 (JSI/NAPI) │
│ RNOH 提供的 React Native 引擎 │
│ 代码生成的组件描述符、Props、事件发射器 │
│ TurboModule 注册与通信 │
└─────────────────────────────────────────────────┘
2.3 RNOH 核心概念
| 概念 | 说明 | 例子 |
|---|---|---|
| RNAbility | 应用的入口 Ability,类似 Android 的 Activity | EntryAbility extends RNAbility |
| RNApp | RN 应用的根组件,负责加载 JS Bundle | RNApp({ rnInstanceConfig: ... }) |
| Package | 三方库的注册单元,包含组件、TurboModule | RNCWebViewPackage extends RNOHPackage |
| TurboModule | JS 调用原生方法的桥梁 | WebViewTurboModule |
| ComponentDescriptor | C++ 层的组件描述,定义 Props 和事件 | RNCWebViewComponentDescriptor |
| arkTsComponentNames | 声明哪些组件有 ArkTS 原生 UI 实现 | ["RNCWebView"] |
2.4 RNOH 在项目中的依赖
在我们的项目中,RNOH 通过 ohpm(OpenHarmony 包管理器)安装:
// entry/oh-package.json5
{
"dependencies": {
"@rnoh/react-native-openharmony": "0.82.30"
}
}
对应 JS 侧的依赖:
// package.json
{
"dependencies": {
"@react-native-oh/react-native-harmony": "^0.82.30",
"@react-native-oh/react-native-harmony-cli": "^0.82.30"
}
}
3. 项目全景:我们到底要部署什么
我们的应用集成了 四个三方库,每个库的功能和类型各不相同:
| 三方库 | 功能 | 类型 | Package 基类 | 有原生 UI? |
|---|---|---|---|---|
| react-native-tts | 语音合成 | 纯 TurboModule | RNPackage | ❌ |
| react-native-version-number | 版本信息读取 | 纯 TurboModule | RNPackage | ❌ |
| react-native-share | 系统分享 | 纯 TurboModule | RNPackage | ❌ |
| react-native-webview | 内嵌浏览器 | 组件 + TurboModule | RNOHPackage | ✅ |
最终应用效果:
- 版本信息卡片:显示应用版本号、构建版本、包名
- WebView 浏览器卡片:带地址栏、前进/后退/刷新按钮的简易浏览器
- 分享功能卡片:支持文本、链接、图片、视频分享
- 语音合成卡片:输入文本后可朗读,支持语速和音调调节
4. 环境准备:磨刀不误砍柴工
4.1 必备工具
| 工具 | 版本 | 用途 |
|---|---|---|
| Node.js | ≥ 22.11.0 | JS 运行时 |
| DevEco Studio | 5.x | 鸿蒙开发 IDE |
| HDC | 随 SDK 安装 | 鸿蒙设备调试工具(类似 ADB) |
| React Native CLI | 0.82 | JS 打包工具 |
| hvigorw | 随 DevEco 安装 | 鸿蒙构建工具(类似 Gradle) |
4.2 设置环境变量(macOS)
# 1. 设置 CAPI 架构标志(必须!否则构建可能失败)
export RNOH_C_API_ARCH=1
# 验证
echo $RNOH_C_API_ARCH
# 输出: 1
# 2. 如果想永久生效,加到 ~/.zshrc
echo 'export RNOH_C_API_ARCH=1' >> ~/.zshrc
source ~/.zshrc
# 3. 设置 HDC 工具路径(如果 hdc 命令找不到)
export PATH="/Applications/DevEco-Studio.app/Contents/sdk/HarmonyOS-xxx/openharmony/toolchains:$PATH"
# 4. 设置 HDC 端口(可选,解决连接问题)
HDC_SERVER_PORT=7035
launchctl setenv HDC_SERVER_PORT $HDC_SERVER_PORT
export HDC_SERVER_PORT
4.3 检查设备连接
# 查看已连接设备
hdc list targets
# 如果输出设备序列号,说明连接正常
# 如果为空,检查 USB 连接和开发者模式
4.4 DevEco Studio 签名配置
在 build-profile.json5 中,签名信息由 DevEco Studio 自动管理:
{
"app": {
"signingConfigs": [
{
"name": "default",
"type": "HarmonyOS",
"material": {
"certpath": "/Users/nutpi/.ohos/config/default_Myrndemo_xxx.cer",
"keyAlias": "debugKey",
"profile": "/Users/nutpi/.ohos/config/default_Myrndemo_xxx.p7b",
"signAlg": "SHA256withECDSA",
"storeFile": "/Users/nutpi/.ohos/config/default_Myrndemo_xxx.p12"
}
}
],
"products": [
{
"name": "default",
"signingConfig": "default",
"targetSdkVersion": "6.1.1(24)",
"compatibleSdkVersion": "6.1.0(23)",
"runtimeOS": "HarmonyOS"
}
]
}
}
💡 签名文件通常在 DevEco Studio 中通过 File → Project Structure → Signing Configs 自动生成。如果没有签名配置,HAP 构建会失败。
5. 项目结构:两个项目的故事
鸿蒙 RN 应用由两个项目组成,这是一个让很多新手困惑的地方:
reactnative/
├── AwesomeProject/ ← 项目 A:React Native JS 项目
│ ├── App.tsx ← 你的 React 代码
│ ├── package.json ← JS 依赖管理
│ ├── local_modules/ ← 本地三方库
│ │ ├── rntpc_react-native-tts/
│ │ ├── rntpc_react-native-share/
│ │ └── rntpc_react-native-webview/
│ ├── node_modules/ ← npm 安装的依赖
│ └── harmony/ ← Metro 打包输出目录
│ └── entry/src/main/resources/rawfile/
│ └── bundle.harmony.js
│
└── Myrndemo/ ← 项目 B:鸿蒙原生项目(DevEco Studio 打开)
└── entry/src/main/
├── ets/ ← ArkTS 原生代码
│ ├── pages/Index.ets ← 主页面
│ ├── entryability/ ← 应用入口
│ ├── RNPackagesFactory.ets ← Package 注册工厂
│ ├── tts/ ← TTS 原生代码
│ ├── share/ ← Share 原生代码
│ ├── version_number/ ← VersionNumber 原生代码
│ ├── webview/ ← WebView 原生代码
│ └── generated/ ← 代码生成的类型文件
├── cpp/ ← C++ 桥接代码
│ ├── CMakeLists.txt
│ └── generated/ ← 代码生成的 C++ 文件
└── resources/ ← 资源文件
├── base/element/string.json
└── rawfile/
└── bundle.harmony.js ← JS 打包产物(从项目 A 复制过来)
为什么是两个项目?
- 项目 A 负责 JS 侧的开发和打包,使用 npm + Metro
- 项目 B 负责鸿蒙原生的编译和部署,使用 ohpm + hvigorw
两个项目之间唯一的"桥梁"就是 bundle.harmony.js 文件——从项目 A 打包出来,复制到项目 B,最终打包进 HAP 部署到设备。
6. JS 侧代码:React Native 应用编写
6.1 package.json 依赖配置
{
"name": "AwesomeProject",
"version": "0.0.1",
"dependencies": {
"@react-native-oh/react-native-harmony": "^0.82.30",
"@react-native-oh/react-native-harmony-cli": "^0.82.30",
"@react-native-ohos/react-native-share": "file:local_modules/rntpc_react-native-share",
"@react-native-ohos/react-native-tts": "file:local_modules/rntpc_react-native-tts",
"@react-native-ohos/react-native-version-number": "^0.5.0-beta.1",
"@react-native-ohos/react-native-webview": "file:local_modules/rntpc_react-native-webview",
"metro": "^0.83.7",
"react": "19.1.1",
"react-native": "0.82.1",
"react-native-webview": "^13.10.2"
},
"scripts": {
"dev": "react-native bundle-harmony --dev"
}
}
关键点:
@react-native-ohos/*包使用file:local_modules/引入(本地包)react-native-webview原版库也要装——鸿蒙版的WebView.harmony.tsx会 import 原版的WebViewShared.tsx等共享文件@react-native-oh/react-native-harmony和@react-native-oh/react-native-harmony-cli是 RNOH 的核心依赖
6.2 App.tsx 完整代码
import React, { useState, useEffect, useRef } from 'react';
import {
View,
Text,
TextInput,
TouchableOpacity,
StyleSheet,
ScrollView,
Alert,
} from 'react-native';
import Tts from '@react-native-ohos/react-native-tts';
import VersionNumber from 'react-native-version-number';
import RNShare from '@react-native-ohos/react-native-share';
import { WebView } from '@react-native-ohos/react-native-webview';
function App() {
// TTS state
const [text, setText] = useState(
'你好,开源鸿蒙!欢迎使用 React Native 语音合成功能。' +
'RNOH(React Native OpenHarmony)是开源鸿蒙社区的 React Native 适配框架,' +
'让开发者可以用 React Native 技术栈构建鸿蒙应用。'
);
const [status, setStatus] = useState('就绪');
const [isSpeaking, setIsSpeaking] = useState(false);
const [rate, setRate] = useState(1.0);
const [pitch, setPitch] = useState(1.0);
// Share state
const [shareText, setShareText] = useState('开源鸿蒙');
const [shareStatus, setShareStatus] = useState('');
// WebView state
const [webviewUrl, setWebviewUrl] = useState('https://atomgit.com');
const [inputUrl, setInputUrl] = useState('https://atomgit.com');
const [webviewStatus, setWebviewStatus] = useState('');
const webviewRef = useRef(null);
// TTS 初始化
useEffect(() => {
Tts.getInitStatus().then(() => {
setStatus('TTS 引擎已就绪');
}).catch((err) => {
setStatus('TTS 引擎初始化失败: ' + JSON.stringify(err));
});
const onStart = Tts.addEventListener('tts-start', () => {
setIsSpeaking(true);
setStatus('正在朗读...');
});
const onFinish = Tts.addEventListener('tts-finish', () => {
setIsSpeaking(false);
setStatus('朗读完成');
});
const onError = Tts.addEventListener('tts-error', () => {
setIsSpeaking(false);
setStatus('朗读出错');
});
const onCancel = Tts.addEventListener('tts-cancel', () => {
setIsSpeaking(false);
setStatus('已停止');
});
return () => {
onStart.remove();
onFinish.remove();
onError.remove();
onCancel.remove();
};
}, []);
const handleSpeak = () => {
if (!text.trim()) {
Alert.alert('提示', '请输入要朗读的文本');
return;
}
Tts.speak(text);
};
const handleStop = () => Tts.stop(true);
const handleRateChange = (delta) => {
const newRate = Math.max(0.5, Math.min(2.0, rate + delta));
setRate(newRate);
Tts.setDefaultRate(newRate);
};
const handlePitchChange = (delta) => {
const newPitch = Math.max(0.5, Math.min(2.0, pitch + delta));
setPitch(newPitch);
Tts.setDefaultPitch(newPitch);
};
// Share handlers
const handleShare = async () => {
try {
setShareStatus('正在打开分享面板...');
const result = await RNShare.open({ message: shareText, title: '分享' });
setShareStatus(result.success ? '分享成功!' : '分享已取消');
} catch (error) {
setShareStatus('分享失败: ' + error.message);
}
};
const handleShareUrl = async () => {
try {
setShareStatus('正在分享链接...');
const result = await RNShare.open({
message: '开源鸿蒙',
url: 'https://atomgit.com/CPF-RN/',
title: '分享链接',
});
setShareStatus(result.success ? '链接分享成功!' : '链接分享已取消');
} catch (error) {
setShareStatus('链接分享失败: ' + error.message);
}
};
const handleShareImage = async () => {
try {
const result = await RNShare.open({
url: 'rawfile://share_assets/test_image.png',
title: '分享图片',
message: '开源鸿蒙',
});
setShareStatus(result.success ? '图片分享成功!' : '图片分享已取消');
} catch (error) {
setShareStatus('图片分享失败: ' + error.message);
}
};
const handleShareVideo = async () => {
try {
const result = await RNShare.open({
url: 'rawfile://share_assets/test_video.mp4',
title: '分享视频',
message: '开源鸿蒙',
});
setShareStatus(result.success ? '视频分享成功!' : '视频分享已取消');
} catch (error) {
setShareStatus('视频分享失败: ' + error.message);
}
};
// WebView handlers
const handleNavigate = () => {
if (inputUrl.trim()) {
let url = inputUrl.trim();
if (!url.startsWith('http://') && !url.startsWith('https://')) {
url = 'https://' + url;
}
setWebviewUrl(url);
setWebviewStatus('正在加载: ' + url);
}
};
const handleGoBack = () => webviewRef.current?.goBack();
const handleGoForward = () => webviewRef.current?.goForward();
const handleReload = () => {
webviewRef.current?.reload();
setWebviewStatus('正在刷新...');
};
return (
<ScrollView style={styles.container} contentContainerStyle={styles.contentContainer}>
<Text style={styles.title}>RN OHOS Demo</Text>
{/* 版本信息卡片 */}
<View style={styles.card}>
<Text style={styles.cardTitle}>应用版本信息</Text>
<View style={styles.infoRow}>
<Text style={styles.infoLabel}>应用版本</Text>
<Text style={styles.infoValue}>{VersionNumber.appVersion || 'N/A'}</Text>
</View>
<View style={styles.infoRow}>
<Text style={styles.infoLabel}>构建版本</Text>
<Text style={styles.infoValue}>{VersionNumber.buildVersion || 'N/A'}</Text>
</View>
<View style={styles.infoRow}>
<Text style={styles.infoLabel}>包名标识</Text>
<Text style={styles.infoValue}>{VersionNumber.bundleIdentifier || 'N/A'}</Text>
</View>
</View>
{/* WebView 浏览器卡片 */}
<View style={styles.card}>
<Text style={styles.cardTitle}>WebView 浏览器</Text>
<View style={styles.urlRow}>
<TextInput
style={styles.urlInput}
value={inputUrl}
onChangeText={setInputUrl}
placeholder="输入网址..."
autoCapitalize="none"
autoCorrect={false}
/>
<TouchableOpacity style={styles.goBtn} onPress={handleNavigate}>
<Text style={styles.btnText}>前往</Text>
</TouchableOpacity>
</View>
{webviewStatus ? (
<View style={styles.statusBar}>
<Text style={styles.statusText}>{webviewStatus}</Text>
</View>
) : null}
<View style={styles.webviewNavRow}>
<TouchableOpacity style={styles.navBtn} onPress={handleGoBack}>
<Text style={styles.btnText}>← 后退</Text>
</TouchableOpacity>
<TouchableOpacity style={styles.navBtn} onPress={handleReload}>
<Text style={styles.btnText}>↻ 刷新</Text>
</TouchableOpacity>
<TouchableOpacity style={styles.navBtn} onPress={handleGoForward}>
<Text style={styles.btnText}>前进 →</Text>
</TouchableOpacity>
</View>
<View style={styles.webviewContainer}>
<WebView
ref={webviewRef}
source={{ uri: webviewUrl }}
style={styles.webview}
javaScriptEnabled={true}
domStorageEnabled={true}
startInLoadingState={true}
onNavigationStateChange={(navState) => {
setWebviewStatus(navState.loading ? '加载中...' : '加载完成: ' + navState.url);
}}
onLoadStart={() => setWebviewStatus('正在加载...')}
onLoadEnd={() => setWebviewStatus('加载完成')}
onError={() => setWebviewStatus('加载出错')}
onHttpError={(error) => setWebviewStatus('HTTP错误: ' + error.nativeEvent.statusCode)}
/>
</View>
</View>
{/* 分享功能卡片 */}
<View style={styles.card}>
<Text style={styles.cardTitle}>分享功能</Text>
<TextInput
style={styles.input}
value={shareText}
onChangeText={setShareText}
multiline
placeholder="输入要分享的文本..."
/>
{shareStatus ? (
<View style={styles.statusBar}>
<Text style={styles.statusText}>{shareStatus}</Text>
</View>
) : null}
<View style={styles.btnRow}>
<TouchableOpacity style={[styles.btn, styles.shareBtn]} onPress={handleShare}>
<Text style={styles.btnText}>分享文本</Text>
</TouchableOpacity>
<TouchableOpacity style={[styles.btn, styles.shareUrlBtn]} onPress={handleShareUrl}>
<Text style={styles.btnText}>分享链接</Text>
</TouchableOpacity>
</View>
<View style={[styles.btnRow, { marginTop: 8 }]}>
<TouchableOpacity style={[styles.btn, styles.shareImageBtn]} onPress={handleShareImage}>
<Text style={styles.btnText}>分享图片</Text>
</TouchableOpacity>
<TouchableOpacity style={[styles.btn, styles.shareVideoBtn]} onPress={handleShareVideo}>
<Text style={styles.btnText}>分享视频</Text>
</TouchableOpacity>
</View>
</View>
{/* 语音合成卡片 */}
<View style={styles.card}>
<Text style={styles.cardTitle}>语音合成 (TTS)</Text>
{status ? (
<View style={styles.statusBar}>
<Text style={styles.statusText}>{status}</Text>
</View>
) : null}
<TextInput
style={styles.input}
value={text}
onChangeText={setText}
multiline
placeholder="输入要朗读的文本..."
/>
<View style={styles.controlRow}>
<Text style={styles.label}>语速: {rate.toFixed(1)}</Text>
<TouchableOpacity style={styles.smallBtn} onPress={() => handleRateChange(-0.1)}>
<Text style={styles.btnText}>-</Text>
</TouchableOpacity>
<TouchableOpacity style={styles.smallBtn} onPress={() => handleRateChange(0.1)}>
<Text style={styles.btnText}>+</Text>
</TouchableOpacity>
</View>
<View style={styles.controlRow}>
<Text style={styles.label}>音调: {pitch.toFixed(1)}</Text>
<TouchableOpacity style={styles.smallBtn} onPress={() => handlePitchChange(-0.1)}>
<Text style={styles.btnText}>-</Text>
</TouchableOpacity>
<TouchableOpacity style={styles.smallBtn} onPress={() => handlePitchChange(0.1)}>
<Text style={styles.btnText}>+</Text>
</TouchableOpacity>
</View>
<View style={styles.btnRow}>
<TouchableOpacity
style={[styles.btn, styles.speakBtn, isSpeaking && styles.btnDisabled]}
onPress={handleSpeak}
disabled={isSpeaking}
>
<Text style={styles.btnText}>朗读</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.btn, styles.stopBtn, !isSpeaking && styles.btnDisabled]}
onPress={handleStop}
disabled={!isSpeaking}
>
<Text style={styles.btnText}>停止</Text>
</TouchableOpacity>
</View>
</View>
</ScrollView>
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: '#f0f2f5' },
contentContainer: { padding: 16, paddingBottom: 40 },
title: { fontSize: 22, fontWeight: 'bold', textAlign: 'center', marginBottom: 16, color: '#1a1a1a' },
card: { backgroundColor: '#fff', borderRadius: 12, padding: 16, marginBottom: 16, shadowColor: '#000',
shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.08, shadowRadius: 4, elevation: 2 },
cardTitle: { fontSize: 17, fontWeight: '600', color: '#333', marginBottom: 12 },
infoRow: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center',
paddingVertical: 6, borderBottomWidth: 1, borderBottomColor: '#f0f0f0' },
infoLabel: { fontSize: 14, color: '#666' },
infoValue: { fontSize: 14, color: '#1a1a1a', fontWeight: '500' },
statusBar: { backgroundColor: '#e3f2fd', padding: 8, borderRadius: 6, marginBottom: 12 },
statusText: { fontSize: 13, color: '#1565c0', textAlign: 'center' },
input: { backgroundColor: '#fafafa', borderWidth: 1, borderColor: '#e0e0e0', borderRadius: 8,
padding: 10, fontSize: 15, minHeight: 70, marginBottom: 12, textAlignVertical: 'top' },
urlRow: { flexDirection: 'row', alignItems: 'center', marginBottom: 12 },
urlInput: { flex: 1, backgroundColor: '#fafafa', borderWidth: 1, borderColor: '#e0e0e0',
borderRadius: 8, padding: 10, fontSize: 14, marginRight: 8 },
goBtn: { backgroundColor: '#2196f3', paddingHorizontal: 16, paddingVertical: 10, borderRadius: 8 },
webviewNavRow: { flexDirection: 'row', justifyContent: 'center', marginBottom: 12 },
navBtn: { backgroundColor: '#546e7a', paddingHorizontal: 16, paddingVertical: 8,
borderRadius: 8, marginHorizontal: 6 },
webviewContainer: { height: 350, borderRadius: 8, overflow: 'hidden',
borderWidth: 1, borderColor: '#e0e0e0' },
webview: { flex: 1 },
controlRow: { flexDirection: 'row', alignItems: 'center', marginBottom: 8, justifyContent: 'center' },
label: { fontSize: 14, color: '#555', width: 90 },
smallBtn: { backgroundColor: '#607d8b', width: 32, height: 32, borderRadius: 16,
alignItems: 'center', justifyContent: 'center', marginHorizontal: 4 },
btnRow: { flexDirection: 'row', justifyContent: 'center', marginTop: 8 },
btn: { paddingHorizontal: 24, paddingVertical: 10, borderRadius: 8,
marginHorizontal: 8, minWidth: 80, alignItems: 'center' },
speakBtn: { backgroundColor: '#4caf50' },
stopBtn: { backgroundColor: '#f44336' },
shareBtn: { backgroundColor: '#2196f3' },
shareUrlBtn: { backgroundColor: '#ff9800' },
shareImageBtn: { backgroundColor: '#9c27b0' },
shareVideoBtn: { backgroundColor: '#e91e63' },
btnDisabled: { backgroundColor: '#ccc' },
btnText: { color: '#fff', fontSize: 14, fontWeight: 'bold' },
});
export default App;
7. 原生侧代码:ArkTS 组件与 TurboModule
7.1 应用入口:EntryAbility
// entry/src/main/ets/entryability/EntryAbility.ets
import { RNAbility } from '@rnoh/react-native-openharmony';
export default class EntryAbility extends RNAbility {
getPagePath() {
return 'pages/Index';
}
}
就这么简单!RNAbility 是 RNOH 提供的基类,它帮我们处理了所有 RN 初始化的工作。我们只需要告诉它页面路径是 pages/Index。
7.2 主页面:Index.ets
// entry/src/main/ets/pages/Index.ets
import {
AnyJSBundleProvider,
ComponentBuilderContext,
FileJSBundleProvider,
MetroJSBundleProvider,
ResourceJSBundleProvider,
RNApp,
RNOHErrorDialog,
RNOHLogger,
TraceJSBundleProviderDecorator,
RNOHCoreContext
} from '@rnoh/react-native-openharmony';
import { createRNPackages } from '../RNPackagesFactory';
@Builder
export function buildCustomRNComponent(ctx: ComponentBuilderContext) {}
const wrappedCustomRNComponentBuilder = wrapBuilder(buildCustomRNComponent)
@Entry
@Component
struct Index {
@StorageLink('RNOHCoreContext') private rnohCoreContext: RNOHCoreContext | undefined = undefined
@State shouldShow: boolean = false
private logger!: RNOHLogger
aboutToAppear() {
this.logger = this.rnohCoreContext!.logger.clone("Index")
const stopTracing = this.logger.clone("aboutToAppear").startTracing();
this.shouldShow = true
stopTracing();
}
onBackPress(): boolean | undefined {
this.rnohCoreContext!.dispatchBackPress()
return true
}
build() {
Column() {
if (this.rnohCoreContext && this.shouldShow) {
if (this.rnohCoreContext?.isDebugModeEnabled) {
RNOHErrorDialog({ ctx: this.rnohCoreContext })
}
RNApp({
rnInstanceConfig: {
createRNPackages,
enableNDKTextMeasuring: true,
enableBackgroundExecutor: false,
enableCAPIArchitecture: true,
arkTsComponentNames: ["RNCWebView"] // ← 关键!声明有原生 UI 的组件
},
initialProps: { "foo": "bar" } as Record<string, string>,
appKey: "AwesomeProject",
wrappedCustomRNComponentBuilder: wrappedCustomRNComponentBuilder,
onSetUp: (rnInstance) => {
rnInstance.enableFeatureFlag("ENABLE_RN_INSTANCE_CLEAN_UP")
},
jsBundleProvider: new TraceJSBundleProviderDecorator(
new AnyJSBundleProvider([
new ResourceJSBundleProvider(
this.rnohCoreContext.uiAbilityContext.resourceManager,
'bundle.harmony.js'
),
new ResourceJSBundleProvider(
this.rnohCoreContext.uiAbilityContext.resourceManager,
'hermes_bundle.hbc'
),
new FileJSBundleProvider('/data/storage/el2/base/files/bundle.harmony.js'),
new MetroJSBundleProvider(),
]),
this.rnohCoreContext.logger),
})
}
}
.height('100%')
.width('100%')
}
}
关键配置解释:
| 配置项 | 值 | 说明 |
|---|---|---|
createRNPackages |
函数引用 | 注册所有三方库 Package 的工厂函数 |
enableNDKTextMeasuring |
true | 使用 NDK 文本测量,性能更好 |
enableCAPIArchitecture |
true | 启用 CAPI 架构(对应 RNOH_C_API_ARCH=1) |
arkTsComponentNames |
["RNCWebView"] |
**必须有!**声明哪些组件由 ArkTS 渲染 |
appKey |
"AwesomeProject" |
必须和 JS 侧 AppRegistry.registerComponent() 的名称一致 |
jsBundleProvider |
AnyJSBundleProvider | JS Bundle 加载策略,按优先级依次尝试 |
JS Bundle 加载策略(按优先级从高到低):
ResourceJSBundleProvider('bundle.harmony.js')— 从 HAP 的 rawfile 资源加载ResourceJSBundleProvider('hermes_bundle.hbc')— 从 HAP 的 rawfile 加载 Hermes 字节码FileJSBundleProvider('/data/storage/...')— 从设备文件系统加载MetroJSBundleProvider()— 从 Metro 开发服务器加载(仅调试用)
7.3 Package 注册工厂
// entry/src/main/ets/RNPackagesFactory.ets
import { RNPackageContext, RNPackage } from '@rnoh/react-native-openharmony/ts';
import { RNTTSPackage } from './tts/RNTTSPackage';
import { RNVersionNumberPackage } from './version_number/RNVersionNumberPackage.ets';
import { RNSharePackage } from './share/RNSharePackage';
import { RNCWebViewPackage } from './webview/RNCWebViewPackage';
export function createRNPackages(ctx: RNPackageContext): RNPackage[] {
return [
new RNTTSPackage(ctx),
new RNVersionNumberPackage(ctx),
new RNSharePackage(ctx),
new RNCWebViewPackage(ctx),
];
}
每一个三方库都必须在这里注册,否则 JS 层调用时找不到对应的原生实现。
7.4 WebView 原生组件(重点详解)
因为 WebView 是唯一有原生 UI 组件的库,它的 Package 和其他三个不同:
// entry/src/main/ets/webview/RNCWebViewPackage.ets
import { RNOHPackage, ComponentBuilderContext } from '@rnoh/react-native-openharmony';
import { RNC, TM } from '../generated/ts';
import { AnyThreadTurboModuleFactory, AnyThreadTurboModule } from '@rnoh/react-native-openharmony/ts';
import { WebViewTurboModule } from './WebViewTurboModule';
import { RNCWebView } from './RNCWebView';
@Builder
function buildWebView(ctx: ComponentBuilderContext) {
RNCWebView({ ctx: ctx.rnComponentContext, tag: ctx.tag })
}
export class RNCWebViewPackage extends RNOHPackage {
// 1. 注册组件描述符(告诉引擎 RNCWebView 有哪些 Props)
createDescriptorWrapperFactoryByDescriptorType(ctx) {
return {
"RNCWebView": (ctx) => new RNC.RNCWebView.DescriptorWrapper(ctx.descriptor)
}
}
// 2. 注册 TurboModule(让 JS 可以调用原生方法)
createAnyThreadTurboModuleFactory(ctx) {
return new WebViewTurboModulesFactory(ctx);
}
// 3. 注册原生 UI 组件构建器(让引擎知道如何渲染 RNCWebView)
createWrappedCustomRNComponentBuilderByComponentNameMap() {
return new Map().set(RNCWebView.NAME, wrapBuilder(buildWebView))
}
}
class WebViewTurboModulesFactory extends AnyThreadTurboModuleFactory {
createTurboModule(name: string): AnyThreadTurboModule | null {
if (name === TM.RNCWebViewModule.NAME) {
return new WebViewTurboModule(this.ctx);
}
return null;
}
hasTurboModule(name: string): boolean {
return name === TM.RNCWebViewModule.NAME;
}
}
对比纯 TurboModule 的 Package(如 TTS):
// TTS 的 Package——没有原生 UI,更简单
export class RNTTSPackage extends RNPackage {
createDescriptorWrapperFactoryByDescriptorType(ctx) { ... }
createAnyThreadTurboModuleFactory(ctx) { ... }
// ❌ 没有 createWrappedCustomRNComponentBuilderByComponentNameMap
}
| 对比项 | RNPackage(TTS/Share/VersionNumber) | RNOHPackage(WebView) |
|---|---|---|
| 基类 | RNPackage |
RNOHPackage |
| 组件描述符 | ✅ 有 | ✅ 有 |
| TurboModule | ✅ 有 | ✅ 有 |
| 原生 UI 构建器 | ❌ 没有 | ✅ 必须有 |
| arkTsComponentNames | 不需要声明 | 必须声明 |
7.5 WebView TurboModule
// entry/src/main/ets/webview/WebViewTurboModule.ets(核心部分)
export class WebViewTurboModule extends AnyThreadTurboModule implements TM.RNCWebViewModule.Spec {
private shouldStartParamsMap: Map<number, ShouldStartParams> = new Map();
isFileUploadSupported(): Promise<boolean> {
return Promise.resolve(true);
}
// JS 端决定是否允许加载某个 URL
shouldStartLoadWithLockIdentifier(shouldStart: boolean, lockIdentifier: number): void {
let shouldStartParams = this.shouldStartParamsMap.get(lockIdentifier);
if (shouldStartParams) {
shouldStartParams.shouldStart = shouldStart;
shouldStartParams.lockState = shouldStart
? ShouldOverrideCallbackState.ALLOW_LOADING
: ShouldOverrideCallbackState.SHOULD_OVERRIDE;
}
}
setShouldStartParams(params: ShouldStartParams) {
this.shouldStartParamsMap.set(params.lockIdentifier, params);
}
}
7.6 WebView 原生组件核心逻辑
RNCWebView.ets 是整个项目最大的文件(904 行),它使用鸿蒙原生的 Web 组件来渲染网页:
// entry/src/main/ets/webview/RNCWebView.ets(简化版核心逻辑)
@Component
export struct RNCWebView {
public static readonly NAME = RNC.RNCWebView.NAME
ctx!: RNComponentContext
tag: number = 0
source: WebViewNewSource = { uri: "", method: "", body: "", html: "", baseUrl: "" }
controller: webview.WebviewController = new webview.WebviewController()
javaScriptEnable: boolean = true
aboutToAppear() {
this.url = this.source.uri as string;
this.onDescriptorWrapperChange(this.descriptorWrapper)
this.registerCommandCallback()
webview.WebviewController.setWebDebuggingAccess(
this.descriptorWrapper.rawProps.webviewDebuggingEnabled
)
}
aboutToDisappear() {
this.controller.deleteJavaScriptRegister(JAVASCRIPT_INTERFACE)
this.controller.refresh()
}
build() {
Web({ src: this.url, controller: this.controller })
.javaScriptAccess(this.javaScriptEnable)
.domStorageAccess(true)
.cacheMode(this.cacheMode)
.scrollable(this.scrollEnabled)
.onPageEnd((event) => {
this.webViewBaseOperate?.emitLoadingFinish({ progress: 100 })
})
.onProgressChange((event) => {
this.webViewBaseOperate?.emitProgressChange({ progress: event?.newProgress })
})
.onErrorReceive((event) => {
this.webViewBaseOperate?.emitLoadingError({
code: event?.error.getErrorCode(),
description: event?.error.getErrorInfo()
})
})
}
}
7.7 WebView 操作封装
WebViewBaseOperate.ets 封装了所有事件发射和命令处理逻辑:
// entry/src/main/ets/webview/WebViewBaseOperate.ets(核心部分)
export class BaseOperate {
private eventEmitter: RNC.RNCWebView.EventEmitter
private controller: webview.WebviewController
// 发射加载进度事件
emitProgressChange(params: ProgressInterface) {
this.eventEmitter.emit('loadingProgress', {
url: this.controller.getUrl(),
loading: params.progress != 100,
title: this.controller.getTitle(),
canGoBack: this.controller.accessBackward(),
canGoForward: this.controller.accessForward(),
progress: params.progress / 100
})
}
// 处理命令(goBack, goForward, reload 等)
registerCommandCallback(command: COMMAND_NAME, args: string[]) {
switch (command) {
case COMMAND_NAME.GOBACK:
this.controller.backward(); break;
case COMMAND_NAME.GOFORWARD:
this.controller.forward(); break;
case COMMAND_NAME.RELOAD:
this.controller.refresh(); break;
case COMMAND_NAME.STOPLOADING:
this.controller.stop(); break;
case COMMAND_NAME.LOADURL:
this.controller.loadUrl(args[0]); break;
case COMMAND_NAME.CLEARCACHE:
this.controller.clearCache(); break;
case COMMAND_NAME.INJECTJAVASCRIPT:
this.controller.runJavaScript(args[0]); break;
}
}
}
8. C++ 层代码:引擎桥接与代码生成
8.1 RNOHGeneratedPackage.h
这是 C++ 层最核心的注册文件,定义了 TurboModule 工厂、事件处理器、组件描述符和 JSI 绑定器:
// entry/src/main/cpp/generated/RNOHGeneratedPackage.h
#pragma once
#include "RNOH/Package.h"
#include "RNOH/ArkTSTurboModule.h"
#include "generated/RNShare.h"
#include "generated/TTSNativeModule.h"
#include "generated/RNVersionNumber.h"
#include "generated/RNOH/generated/BaseReactNativeWebviewPackage.h"
namespace rnoh {
class RNOHGeneratedPackageTurboModuleFactoryDelegate : public TurboModuleFactoryDelegate {
public:
SharedTurboModule createTurboModule(Context ctx, const std::string &name) const override {
if (name == "RNShare") {
return std::make_shared<RNShare>(ctx, name);
}
if (name == "TTSNativeModule") {
return std::make_shared<TTSNativeModule>(ctx, name);
}
if (name == "RNVersionNumber") {
return std::make_shared<RNVersionNumber>(ctx, name);
}
// WebView TurboModules
if (name == "RNCWebView") {
return std::make_shared<RNCWebView>(ctx, name);
}
if (name == "RNCWebViewModule") {
return std::make_shared<RNCWebViewModule>(ctx, name);
}
return nullptr;
};
};
class GeneratedEventEmitRequestHandler : public EventEmitRequestHandler {
public:
void handleEvent(Context const &ctx) override {
auto eventEmitter = ctx.shadowViewRegistry->getEventEmitter<...>(ctx.tag);
auto componentName = ctx.shadowViewRegistry->getComponentName(ctx.tag);
if (eventEmitter == nullptr) return;
std::vector<std::string> supportedComponentNames = {
"RNCWebView",
};
std::vector<std::string> supportedEventNames = {
"contentSizeChange", "renderProcessGone", "contentProcessDidTerminate",
"customMenuSelection", "fileDownload", "loadingError", "loadingFinish",
"loadingProgress", "loadingStart", "httpError", "message",
"openWindow", "scroll", "shouldStartLoadWithRequest",
};
// 如果组件名和事件名都在支持列表中,则分发事件
if (componentName 和 eventName 都在支持列表中) {
eventEmitter->dispatchEvent(ctx.eventName, payload);
}
}
};
class RNOHGeneratedPackage : public Package {
public:
// 注册组件描述符
std::vector<facebook::react::ComponentDescriptorProvider>
createComponentDescriptorProviders() override {
return {
facebook::react::concreteComponentDescriptorProvider<
facebook::react::RNCWebViewComponentDescriptor>(),
};
}
// 注册 JSI 绑定器
ComponentJSIBinderByString createComponentJSIBinderByName() override {
return {
{"RNCWebView", std::make_shared<RNCWebViewJSIBinder>()},
};
};
// 注册事件处理器
EventEmitRequestHandlers createEventEmitRequestHandlers() override {
return {
std::make_shared<GeneratedEventEmitRequestHandler>(),
};
}
};
} // namespace rnoh
关键点:
- TurboModule 注册:每个 TurboModule(包括
RNCWebView和RNCWebViewModule)都必须在工厂中注册 - 组件描述符:
RNCWebViewComponentDescriptor让 RN 引擎知道 RNCWebView 组件的存在 - JSI 绑定器:
RNCWebViewJSIBinder让 JS 侧可以访问 RNCWebView 的 Props - 事件处理器:将鸿蒙原生事件(如
onPageEnd)转发为 RN 事件(如loadingFinish)
8.2 CMakeLists.txt
# entry/src/main/cpp/CMakeLists.txt
project(rnapp)
cmake_minimum_required(VERSION 3.4.1)
set(OH_MODULE_DIR "${CMAKE_CURRENT_SOURCE_DIR}/../../../oh_modules")
set(RNOH_APP_DIR "${CMAKE_CURRENT_SOURCE_DIR}")
set(RNOH_CPP_DIR "${OH_MODULE_DIR}/@rnoh/react-native-openharmony/src/main/cpp")
set(RNOH_GENERATED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/generated")
add_subdirectory("${RNOH_CPP_DIR}" ./rn)
add_library(rnoh_app SHARED
"./PackageProvider.cpp"
"${RNOH_CPP_DIR}/RNOHAppNapiBridge.cpp"
"${RNOH_GENERATED_DIR}/RNOHGeneratedPackage.h"
# TTS
"${RNOH_GENERATED_DIR}/TTSNativeModule.cpp"
# VersionNumber
"${RNOH_GENERATED_DIR}/RNVersionNumber.cpp"
# Share
"${RNOH_GENERATED_DIR}/RNShare.cpp"
# WebView C++ 源文件
"${RNOH_GENERATED_DIR}/RNOH/generated/turbo_modules/RNCWebView.cpp"
"${RNOH_GENERATED_DIR}/RNOH/generated/turbo_modules/RNCWebViewModule.cpp"
"${RNOH_GENERATED_DIR}/RNOH/generated/components/RNCWebViewJSIBinder.h"
"${RNOH_GENERATED_DIR}/react/renderer/components/react_native_webview/ComponentDescriptors.h"
"${RNOH_GENERATED_DIR}/react/renderer/components/react_native_webview/EventEmitters.cpp"
"${RNOH_GENERATED_DIR}/react/renderer/components/react_native_webview/Props.cpp"
"${RNOH_GENERATED_DIR}/react/renderer/components/react_native_webview/ShadowNodes.cpp"
"${RNOH_GENERATED_DIR}/react/renderer/components/react_native_webview/States.cpp"
)
target_include_directories(rnoh_app PUBLIC
"${CMAKE_CURRENT_SOURCE_DIR}"
"${RNOH_GENERATED_DIR}"
)
target_link_libraries(rnoh_app PUBLIC rnoh)
注意:每个三方库的 C++ 源文件都必须加到 add_library 列表中,否则链接时会报 undefined symbol 错误。
8.3 代码生成的类型文件
ETS 侧的代码生成文件在 ets/generated/ 下:
generated/
├── ts.ts ← 统一导出入口
├── components/
│ ├── ts.ts ← 导出 RNCWebView
│ └── RNCWebView.ts ← 组件类型、Props、EventEmitter、CommandReceiver
└── turboModules/
├── ts.ts ← 导出所有 TurboModules
├── RNCWebView.ts ← RNCWebView TurboModule 类型
├── RNCWebViewModule.ts ← RNCWebViewModule TurboModule 类型
├── RNShare.ts
├── RNVersionNumber.ts
└── TTSNativeModule.ts
其中 ts.ts 统一导出为 RNC 和 TM 命名空间:
// generated/ts.ts
export * as RNC from "./components/ts" // RNC.RNCWebView.DescriptorWrapper
export * as TM from "./turboModules/ts" // TM.RNCWebViewModule.NAME
这些类型在原生代码中被大量使用:
import { RNC, TM } from '../generated/ts';
// 使用 RNC 访问组件类型
new RNC.RNCWebView.DescriptorWrapper(ctx.descriptor)
// 使用 TM 访问 TurboModule 类型
TM.RNCWebViewModule.NAME // "RNCWebViewModule"
9. 配置文件:把所有东西串起来
9.1 module.json5 — 模块配置
// entry/src/main/module.json5
{
"module": {
"name": "entry",
"type": "entry",
"mainElement": "EntryAbility",
"deviceTypes": ["phone", "2in1"],
"deliveryWithInstall": true,
"installationFree": false,
"pages": "$profile:main_pages",
"requestPermissions": [
{
"name": "ohos.permission.INTERNET" // WebView 必需
},
{
"name": "ohos.permission.READ_WRITE_DOWNLOAD_DIRECTORY", // 分享下载文件
"reason": "$string:perm_download_reason",
"usedScene": { "abilities": ["EntryAbility"], "when": "inuse" }
},
{
"name": "ohos.permission.FILE_ACCESS_PERSIST", // 分享本地文件
"reason": "$string:perm_file_access_reason",
"usedScene": { "abilities": ["EntryAbility"], "when": "inuse" }
}
],
"abilities": [
{
"name": "EntryAbility",
"srcEntry": "./ets/entryability/EntryAbility.ets",
"description": "$string:EntryAbility_desc",
"icon": "$media:layered_image",
"label": "$string:EntryAbility_label",
"startWindowIcon": "$media:startIcon",
"startWindowBackground": "$color:start_window_background",
"exported": true,
"skills": [
{
"entities": ["entity.system.home"],
"actions": ["ohos.want.action.home"]
}
]
}
]
}
}
权限说明:
| 权限 | 用途 | 哪个库需要 |
|---|---|---|
ohos.permission.INTERNET |
访问网络 | WebView |
ohos.permission.READ_WRITE_DOWNLOAD_DIRECTORY |
读写下载目录 | Share(分享图片/视频) |
ohos.permission.FILE_ACCESS_PERSIST |
持久化文件访问 | Share(分享本地文件) |
9.2 string.json — 字符串资源
// entry/src/main/resources/base/element/string.json
{
"string": [
{ "name": "module_desc", "value": "module description" },
{ "name": "EntryAbility_desc", "value": "description" },
{ "name": "EntryAbility_label", "value": "label" },
{ "name": "perm_download_reason", "value": "Used for sharing downloaded images and videos" },
{ "name": "perm_file_access_reason", "value": "Used for accessing local files to share" },
{ "name": "determined", "value": "OK" },
{ "name": "cancel", "value": "Cancel" },
{ "name": "use_your_camera", "value": "Use your camera" },
{ "name": "use_your_microphone", "value": "Use your microphone" },
{ "name": "on_confirm", "value": "Confirm" },
{ "name": "deny", "value": "Deny" }
]
}
⚠️
determined、cancel等字符串资源是 WebView 弹窗(JS alert/confirm)必需的,缺少会导致编译报错!
9.3 main_pages.json — 页面路由
{
"src": ["pages/Index"]
}
只有一个页面,就是我们的 RN 容器页面。
9.4 oh-package.json5 — 鸿蒙依赖
// entry/oh-package.json5
{
"name": "entry",
"version": "1.0.0",
"dependencies": {
"@rnoh/react-native-openharmony": "0.82.30",
"@ppd/ffrt": "1.1.5"
}
}
RNOH 的原生包通过 ohpm 安装,版本号必须和 JS 侧的 @react-native-oh/react-native-harmony 一致。
10. 第一步:打包 JS Bundle
10.1 打包命令
cd /Users/nutpi/Desktop/reactnative/AwesomeProject
export RNOH_C_API_ARCH=1
npx react-native bundle-harmony --dev false
10.2 打包输出
[INFO] Redirected imports to 4 harmony-specific third-party package(s):
[INFO] • react-native-share → @react-native-ohos/react-native-share
[INFO] • react-native-tts → @react-native-ohos/react-native-tts
[INFO] • react-native-version-number → @react-native-ohos/react-native-version-number
[INFO] • react-native-webview → @react-native-ohos/react-native-webview
info Created harmony/entry/src/main/resources/rawfile/bundle.harmony.js
关键信息:
- Metro 自动将
react-native-share等原版包重定向到鸿蒙适配版 - 4 个包全部重定向成功,说明
package.json配置正确 - 输出文件路径:
harmony/entry/src/main/resources/rawfile/bundle.harmony.js
10.3 复制 Bundle 到鸿蒙项目
cp harmony/entry/src/main/resources/rawfile/bundle.harmony.js \
../Myrndemo/entry/src/main/resources/rawfile/bundle.harmony.js
💡 这是最容易忘的一步!如果你修改了 JS 代码但没有重新复制 bundle,设备上运行的还是旧代码。
11. 第二步:构建 HAP
11.1 构建命令
cd /Users/nutpi/Desktop/reactnative/Myrndemo
export RNOH_C_API_ARCH=1
hvigorw assembleHap --no-daemon
11.2 构建过程
> hvigor Finished :entry:default@PreBuild... after 51 ms
> hvigor Finished :entry:default@ConfigureCmake... after 7 ms
> hvigor Finished :entry:default@BuildNativeWithCmake... after 8 ms
> hvigor Finished :entry:default@CompileResource... after 154 ms
> hvigor Finished :entry:default@BuildJS... after 1 ms
> hvigor Finished :entry:default@BuildNativeWithNinja... after 230 ms ← C++ 编译
> hvigor Finished :entry:default@CompileArkTS... after 2 s 101 ms ← ArkTS 编译
> hvigor Finished :entry:default@PackageHap... after 332 ms ← 打包 HAP
> hvigor Finished :entry:default@SignHap... after 1 s 110 ms ← 签名
> hvigor BUILD SUCCESSFUL in 5 s 215 ms
构建步骤详解:
| 步骤 | 耗时 | 做了什么 |
|---|---|---|
| PreBuild | 51ms | 预处理检查 |
| ConfigureCmake | 7ms | 配置 CMake 构建 |
| BuildNativeWithCmake | 8ms | CMake 配置(增量构建时很快) |
| CompileResource | 154ms | 编译资源文件(string.json、图片等) |
| BuildJS | 1ms | JS 资源处理 |
| BuildNativeWithNinja | 230ms | 用 Ninja 编译 C++ 代码 |
| CompileArkTS | 2.1s | 编译 ArkTS 代码(最耗时) |
| PackageHap | 332ms | 打包成 HAP |
| SignHap | 1.1s | 签名 |
11.3 构建输出
entry/build/default/outputs/default/entry-default-signed.hap
11.4 构建警告(可忽略)
构建过程中可能会有一些警告:
WARN: 'page_show' conflict, first declared. ← 字符串资源重复声明,不影响
WARN: ArkTS: Definite assignment assertions are not supported ← RNOH 内部代码,不影响
WARN: ArkTS: 'getContext' has been deprecated ← WebView 内部使用了旧 API,不影响
这些警告来自 RNOH 框架和 WebView 库的内部代码,不影响应用的正常运行。
12. 第三步:安装到鸿蒙设备
12.1 安装命令
hdc install -r entry/build/default/outputs/default/entry-default-signed.hap
-r表示覆盖安装(重新安装时需要)
12.2 安装输出
[Info]App install path:/path/to/entry-default-signed.hap
msg:install bundle successfully.
AppMod finish
12.3 安装失败排查
| 错误 | 原因 | 解决方案 |
|---|---|---|
Error: device not found |
设备未连接 | 检查 USB 连接、hdc list targets |
Error: install failed |
签名不匹配 | 先卸载旧版 hdc uninstall com.nutpi.rndemo,再重新安装 |
Error: permission denied |
设备未授权 | 开启开发者模式、允许 USB 调试 |
13. 第四步:启动应用
13.1 启动命令
hdc shell aa start -a EntryAbility -b com.nutpi.rndemo
参数说明:
-a EntryAbility:指定启动的 Ability 名称(对应module.json5中的abilities[0].name)-b com.nutpi.rndemo:指定应用的包名
13.2 启动输出
start ability successfully.
13.3 启动失败排查
| 错误 | 原因 | 解决方案 |
|---|---|---|
start ability failed |
Ability 名称或包名错误 | 检查 module.json5 中的配置 |
| 应用闪退 | JS Bundle 加载失败 | 检查 rawfile 中是否有 bundle.harmony.js |
| 白屏 | 组件未注册 | 检查 RNPackagesFactory.ets 和 arkTsComponentNames |
14. 调试技巧:出了问题怎么查
14.1 查看日志
# 查看所有日志
hdc shell hilog
# 过滤 WebView 相关日志
hdc shell hilog | grep -i "WebView"
# 过滤 RNOH 框架日志
hdc shell hilog | grep -i "RNOH"
# 过滤应用包名相关日志
hdc shell hilog | grep "com.nutpi.rndemo"
# 实时查看错误日志
hdc shell hilog | grep -iE "error|fail|crash"
14.2 查看 JS 错误
如果应用启动后显示红框错误,日志中通常会有详细的 JS 错误信息:
hdc shell hilog | grep -i "exception\|TypeError\|ReferenceError"
14.3 截图
# 截取设备屏幕
hdc shell snapshot_display -f /data/local/tmp/screenshot.jpeg
# 拉取截图到本地
hdc file recv /data/local/tmp/screenshot.jpeg ./screenshot.jpeg
14.4 卸载应用
hdc uninstall com.nutpi.rndemo
14.5 使用 Metro 开发服务器(实时调试)
在 Index.ets 中,jsBundleProvider 配置了 MetroJSBundleProvider(),这意味着如果在同一网络中运行 Metro 服务器,应用会尝试从 Metro 加载 JS Bundle,实现热更新调试:
# 终端 1:启动 Metro 服务器
cd /Users/nutpi/Desktop/reactnative/AwesomeProject
npx react-native start --harmony
# 终端 2:构建并安装 HAP
cd /Users/nutpi/Desktop/reactnative/Myrndemo
export RNOH_C_API_ARCH=1
hvigorw assembleHap --no-daemon
hdc install -r entry/build/default/outputs/default/entry-default-signed.hap
hdc shell aa start -a EntryAbility -b com.nutpi.rndemo
# 修改 JS 代码后,按 r 键重新加载
⚠️ Metro 调试需要设备和开发机在同一网络,且设备能访问开发机的 8081 端口。
15. 踩坑实录:那些让我抓狂的错误
坑 1:TypeError: Cannot read property ‘useRef’ of null
最坑的一个错误! 应用启动后红框报错。
原因:local_modules/rntpc_react-native-webview/node_modules/react/ 下有一个 React 18.2.0 的副本,和项目根目录的 React 19.1.1 冲突,导致 hooks 失效。
解决:
rm -rf local_modules/rntpc_react-native-webview/node_modules
坑 2:WebView 白屏不渲染
原因:Index.ets 中缺少 arkTsComponentNames: ["RNCWebView"]。
解决:在 rnInstanceConfig 中添加 arkTsComponentNames。
坑 3:RNOHPackage vs RNPackage 用错
原因:WebView 有原生 UI 组件,必须用 RNOHPackage,我一开始用了 RNPackage。
解决:把 RNCWebViewPackage 的基类改为 RNOHPackage,并实现 createWrappedCustomRNComponentBuilderByComponentNameMap 方法。
坑 4:编译报错找不到字符串资源
原因:WebView 的 JS 弹窗需要 determined、cancel 等字符串资源。
解决:在 string.json 中添加这些资源。
坑 5:Metro 打包报错 Unable to resolve module
Error: Unable to resolve module `./WebViewShared` from `WebView.harmony.tsx`
原因:WebViewShared.tsx 等文件在 node_modules/react-native-webview/src/ 下,Metro 无法从 local_modules 解析到。
解决:手动复制共享文件到 local_modules 目录。
坑 6:HAP 安装失败签名不匹配
原因:之前安装了不同签名的版本。
解决:
hdc uninstall com.nutpi.rndemo
hdc install -r entry-default-signed.hap
坑 7:忘记复制 bundle.harmony.js
原因:修改了 JS 代码后重新打包,但忘记复制到 Myrndemo 目录。
解决:养成习惯,每次打包后立即复制。
坑 8:RNOH_C_API_ARCH 环境变量未设置
原因:忘记设置 export RNOH_C_API_ARCH=1,导致构建和打包行为不一致。
解决:加到 ~/.zshrc 中永久生效。
16. 完整构建部署命令速查
把所有命令串起来,这就是每次修改代码后需要执行的完整流程:
# ======== 1. 打包 JS Bundle ========
cd /Users/nutpi/Desktop/reactnative/AwesomeProject
export RNOH_C_API_ARCH=1
npx react-native bundle-harmony --dev false
# ======== 2. 复制 Bundle 到鸿蒙项目 ========
cp harmony/entry/src/main/resources/rawfile/bundle.harmony.js \
../Myrndemo/entry/src/main/resources/rawfile/bundle.harmony.js
# ======== 3. 构建 HAP ========
cd /Users/nutpi/Desktop/reactnative/Myrndemo
export RNOH_C_API_ARCH=1
hvigorw assembleHap --no-daemon
# ======== 4. 安装到设备 ========
hdc install -r entry/build/default/outputs/default/entry-default-signed.hap
# ======== 5. 启动应用 ========
hdc shell aa start -a EntryAbility -b com.nutpi.rndemo
# ======== 6. 查看日志(可选) ========
hdc shell hilog | grep -iE "RNOH|WebView|TTS|Share"
一键脚本(可以保存为 deploy.sh):
#!/bin/bash
set -e
export RNOH_C_API_ARCH=1
echo "📦 Step 1: Bundling JS..."
cd /Users/nutpi/Desktop/reactnative/AwesomeProject
npx react-native bundle-harmony --dev false
echo "📋 Step 2: Copying bundle..."
cp harmony/entry/src/main/resources/rawfile/bundle.harmony.js \
../Myrndemo/entry/src/main/resources/rawfile/bundle.harmony.js
echo "🔨 Step 3: Building HAP..."
cd /Users/nutpi/Desktop/reactnative/Myrndemo
hvigorw assembleHap --no-daemon
echo "📱 Step 4: Installing..."
hdc install -r entry/build/default/outputs/default/entry-default-signed.hap
echo "🚀 Step 5: Launching..."
hdc shell aa start -a EntryAbility -b com.nutpi.rndemo
echo "✅ Done!"
17. 总结与心得
17.1 部署流程总览
┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐
│ JS 项目 │ │ 鸿蒙项目 │ │ 鸿蒙设备 │
│ AwesomeProject │ │ Myrndemo │ │ │
│ │ │ │ │ │
│ App.tsx │ │ Index.ets │ │ │
│ package.json │ │ RNCWebView.ets │ │ │
│ local_modules/ │ │ CMakeLists.txt │ │ │
│ │ │ module.json5 │ │ │
└────────┬──────────┘ └────────┬──────────┘ └──────────────────┘
│ │ ▲
bundle-harmony │ │
│ │ hdc install
▼ │ │
bundle.harmony.js ──────────────►│ │
(复制) │ hdc shell
│ aa start
hvigorw │
assembleHap │
│ │
▼ │
entry-default-signed.hap ─────────┘
(安装)
17.2 核心经验
- 两个项目,一个桥梁:JS 项目打包 → bundle.harmony.js → 复制到鸿蒙项目 → 构建 HAP → 部署
- RNOH 是核心:理解 RNOH 的
RNAbility、RNApp、Package、TurboModule概念是关键 - 有 UI 组件的库用
RNOHPackage:纯 TurboModule 用RNPackage,有原生 UI 的用RNOHPackage arkTsComponentNames不能忘:不声明就白屏- 删除嵌套 node_modules:避免 React 双实例导致 hooks 失效
RNOH_C_API_ARCH=1必须设置:打包和构建都需要- 每次改 JS 都要重新复制 bundle:最容易忘的一步
- hdc 是你的好朋友:安装、启动、日志、截图都靠它
17.3 与 Android/iOS 的对比
| 步骤 | Android/iOS | HarmonyOS |
|---|---|---|
| 安装三方库 | npm install |
npm install + 手动复制原生代码 |
| 原生链接 | autolinking | 手动注册 Package |
| C++ 配置 | 不需要 | 修改 CMakeLists + RNOHGeneratedPackage.h |
| 打包 JS | 自动(run-android) | 手动 bundle-harmony |
| 复制 bundle | 自动 | 手动 cp |
| 构建 | run-android 一条命令 |
hvigorw assembleHap |
| 安装 | 自动 | hdc install |
| 启动 | 自动 | hdc shell aa start |
| 调试日志 | adb logcat / Chrome DevTools | hdc hilog |
| 总步骤 | 1 条命令 | 5 条命令 |
17.4 展望
鸿蒙 RN 的部署流程目前确实比 Android/iOS 复杂,但这主要是生态还在早期阶段。随着 RNOH 的不断完善,相信未来会有:
- autolinking 机制:自动注册 Package 和组件
- 一键部署命令:类似
react-native run-harmony - 更好的调试工具:类似 Chrome DevTools 的鸿蒙版
- Hermes 字节码支持:更快的 JS 加载速度
RNOH 社区正在快速发展,欢迎加入 https://atomgit.com/CPF-RN/ 一起贡献!
💡 如果你也是小白,记住这句话:部署鸿蒙 RN 应用 = 打包 JS → 复制 bundle → 构建 HAP → 安装 → 启动。五步走,缺一不可。遇到问题先看日志,90% 的错误都是配置遗漏导致的。
更多推荐

所有评论(0)