小白实战手记: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 的 ActivityEntryAbility extends RNAbility
RNAppRN 应用的根组件,负责加载 JS BundleRNApp({ rnInstanceConfig: ... })
Package三方库的注册单元,包含组件、TurboModuleRNCWebViewPackage extends RNOHPackage
TurboModuleJS 调用原生方法的桥梁WebViewTurboModule
ComponentDescriptorC++ 层的组件描述,定义 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语音合成纯 TurboModuleRNPackage❌
react-native-version-number版本信息读取纯 TurboModuleRNPackage❌
react-native-share系统分享纯 TurboModuleRNPackage❌
react-native-webview内嵌浏览器组件 + TurboModuleRNOHPackage✅

最终应用效果:

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

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

4.1 必备工具

工具版本用途
Node.js≥ 22.11.0JS 运行时
DevEco Studio5.x鸿蒙开发 IDE
HDC随 SDK 安装鸿蒙设备调试工具(类似 ADB)
React Native CLI0.82JS 打包工具
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 的工厂函数
enableNDKTextMeasuringtrue使用 NDK 文本测量,性能更好
enableCAPIArchitecturetrue启用 CAPI 架构(对应 RNOH_C_API_ARCH=1)
arkTsComponentNames["RNCWebView"]**必须有!**声明哪些组件由 ArkTS 渲染
appKey"AwesomeProject"必须和 JS 侧 AppRegistry.registerComponent() 的名称一致
jsBundleProviderAnyJSBundleProviderJS 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)
基类RNPackageRNOHPackage
组件描述符✅ 有✅ 有
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

构建步骤详解:

步骤耗时做了什么
PreBuild51ms预处理检查
ConfigureCmake7ms配置 CMake 构建
BuildNativeWithCmake8msCMake 配置(增量构建时很快)
CompileResource154ms编译资源文件(string.json、图片等)
BuildJS1msJS 资源处理
BuildNativeWithNinja230ms用 Ninja 编译 C++ 代码
CompileArkTS2.1s编译 ArkTS 代码(最耗时)
PackageHap332ms打包成 HAP
SignHap1.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 failedAbility 名称或包名错误检查 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 核心经验

  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 的对比

步骤Android/iOSHarmonyOS
安装三方库npm installnpm 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 DevToolshdc 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

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐