小白实战手记:React Native 应用部署到鸿蒙设备全流程详解

2 评论 143 浏览 0 收藏 70 分钟

React Native 开发者进军鸿蒙生态的必经之路,本文详细解析了从零开始将 RN 应用部署到 HarmonyOS 设备的全流程。通过 TTS 语音合成、WebView 浏览器等四大功能模块的实战案例,揭秘 RNOH 框架的架构设计与双项目协同机制,手把手教你解决环境配置、签名打包等关键问题。

目标读者:刚接触鸿蒙 React Native 开发,想把应用部署到真机上的新手

环境:macOS + DevEco Studio + React Native 0.82 + HarmonyOS 6

项目:集成了 TTS 语音合成、VersionNumber 版本信息、Share 分享、WebView 浏览器四大功能的 RN 鸿蒙应用

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 核心概念

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. 项目全景:我们到底要部署什么

我们的应用集成了 四个三方库,每个库的功能和类型各不相同:

最终应用效果

  • 版本信息卡片:显示应用版本号、构建版本、包名
  • WebView 浏览器卡片:带地址栏、前进/后退/刷新按钮的简易浏览器
  • 分享功能卡片:支持文本、链接、图片、视频分享
  • 语音合成卡片:输入文本后可朗读,支持语速和音调调节

4. 环境准备:磨刀不误砍柴工

4.1 必备工具

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

exportfunction 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()

returntrue

}

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%’)

}

}

关键配置解释

JS Bundle 加载策略(按优先级从高到低):

  1. ResourceJSBundleProvider(‘bundle.harmony.js’) — 从 HAP 的 rawfile 资源加载
  2. ResourceJSBundleProvider(‘hermes_bundle.hbc’) — 从 HAP 的 rawfile 加载 Hermes 字节码
  3. FileJSBundleProvider(‘/data/storage/…’) — 从设备文件系统加载
  4. 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’;

exportfunction 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 })

}

exportclass RNCWebViewPackage extends RNOHPackage {

//

1. 注册组件描述符(告诉引擎 RNCWebView 有哪些 Props)

createDescriptorWrapperFactoryByDescriptorType(ctx) {

return {

“RNCWebView”: (ctx) =>new RNC.RNCWebView.DescriptorWrapper(ctx.descriptor)

}

}

//

2. 注册 TurboModule(让 JS 可以调用原生方法)

createAnyThreadTurboModuleFactory(ctx) {

returnnew WebViewTurboModulesFactory(ctx);

}

//

3. 注册原生 UI 组件构建器(让引擎知道如何渲染 RNCWebView)

createWrappedCustomRNComponentBuilderByComponentNameMap() {

returnnew Map().set(RNCWebView.NAME, wrapBuilder(buildWebView))

}

}

class WebViewTurboModulesFactory extends AnyThreadTurboModuleFactory {

createTurboModule(name: string): AnyThreadTurboModule | null {

if (name === TM.RNCWebViewModule.NAME) {

returnnew WebViewTurboModule(this.ctx);

}

returnnull;

}

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

}

7.5 WebView TurboModule

// entry/src/main/ets/webview/WebViewTurboModule.ets(核心部分)

exportclass WebViewTurboModule extends AnyThreadTurboModule implements TM.RNCWebViewModule.Spec {

private shouldStartParamsMap: Map<number, ShouldStartParams> = new Map();

isFileUploadSupported(): Promise<boolean> {

returnPromise.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 {

publicstatic 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 asstring;

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(核心部分)

exportclass 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”) {

returnstd::make_shared<RNShare>(ctx, name);

}

if (name == “TTSNativeModule”) {

returnstd::make_shared<TTSNativeModule>(ctx, name);

}

if (name == “RNVersionNumber”) {

returnstd::make_shared<RNVersionNumber>(ctx, name);

}

// WebView TurboModules

if (name == “RNCWebView”) {

returnstd::make_shared<RNCWebView>(ctx, name);

}

if (name == “RNCWebViewModule”) {

returnstd::make_shared<RNCWebViewModule>(ctx, name);

}

returnnullptr;

};

};

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”]

}

]

}

]

}

}

权限说明

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

构建步骤详解

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 安装失败排查

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 启动失败排查

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 核心经验

  1. 两个项目,一个桥梁:JS 项目打包 → bundle.harmony.js → 复制到鸿蒙项目 → 构建 HAP → 部署
  2. RNOH 是核心:理解 RNOH 的 RNAbility、RNApp、Package、TurboModule 概念是关键
  3. 有 UI 组件的库用 RNOHPackage:纯 TurboModule 用 RNPackage,有原生 UI 的用 RNOHPackage
  4. arkTsComponentNames 不能忘:不声明就白屏
  5. 删除嵌套 node_modules:避免 React 双实例导致 hooks 失效
  6. RNOH_C_API_ARCH=1 必须设置:打包和构建都需要
  7. 每次改 JS 都要重新复制 bundle:最容易忘的一步
  8. hdc 是你的好朋友:安装、启动、日志、截图都靠它

17.3 与 Android/iOS 的对比

17.4 展望

鸿蒙 RN 的部署流程目前确实比 Android/iOS 复杂,但这主要是生态还在早期阶段。随着 RNOH 的不断完善,相信未来会有:

  • autolinking 机制:自动注册 Package 和组件
  • 一键部署命令:类似 react-native run-harmony
  • 更好的调试工具:类似 Chrome DevTools 的鸿蒙版
  • Hermes 字节码支持:更快的 JS 加载速度

本文由人人都是产品经理作者【nutpi】,微信公众号:【nutpi】,原创/授权 发布于人人都是产品经理,未经许可,禁止转载。

题图来自Unsplash,基于 CC0 协议。

更多精彩内容,请关注人人都是产品经理微信公众号或下载App
评论
评论请登录
  1. 对最终用户来说体验没区别,但对开发者来说部署流程的简化能直接降低开发焦虑,提升迭代速度,这是很大的隐性收益。

    来自广东 回复
  2. RNOH版本和鸿蒙SDK版本需谨慎匹配,教程基于0.82.30和鸿蒙6.1.0,但不同小版本之间可能存在API不兼容或bug。建议保持依赖的版本锁,升级时仔细阅读changelog,另外注意C API架构标志RNOH_C_API_ARCH可能在不同版本下含义不同,需要验证。

    来自广东 回复