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

作者:一个刚入坑鸿蒙 RN 的小白
目标读者:和我一样刚接触鸿蒙 React Native 开发,想把应用部署到真机上的新手
环境:macOS + DevEco Studio + React Native 0.82 + HarmonyOS NEXT
项目:集成了 TTS 语音合成、VersionNumber 版本信息、Share 分享、WebView 浏览器四大功能的 RN 鸿蒙应用


目录

  1. 前言:为什么要写这篇文章
  2. 认识 RNOH:React Native OpenHarmony
  3. 项目全景:我们到底要部署什么
  4. 环境准备:磨刀不误砍柴工
  5. 项目结构:两个项目的故事
  6. JS 侧代码:React Native 应用编写
  7. 原生侧代码:ArkTS 组件与 TurboModule
  8. C++ 层代码:引擎桥接与代码生成
  9. 配置文件:把所有东西串起来
  10. 第一步:打包 JS Bundle
  11. 第二步:构建 HAP
  12. 第三步:安装到鸿蒙设备
  13. 第四步:启动应用
  14. 调试技巧:出了问题怎么查
  15. 踩坑实录:那些让我抓狂的错误
  16. 完整构建部署命令速查
  17. 总结与心得

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 加载策略(按优先级从高到低):

  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';

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(包括 RNCWebViewRNCWebViewModule)都必须在工厂中注册
  • 组件描述符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 统一导出为 RNCTM 命名空间:

// 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" }
  ]
}

⚠️ determinedcancel 等字符串资源是 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.etsarkTsComponentNames

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 弹窗需要 determinedcancel 等字符串资源。

解决:在 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 的 RNAbilityRNAppPackageTurboModule 概念是关键
  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 的对比

步骤 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% 的错误都是配置遗漏导致的。

Logo

开源鸿蒙跨平台开发社区汇聚开发者与厂商,共建“一次开发,多端部署”的开源生态,致力于降低跨端开发门槛,推动万物智联创新。

更多推荐