本文是《React Native x HarmonyOS NEXT 创新能力接入方案》系列第 3 篇。上一篇我们完成了 RNOH TurboModule 最小实践,证明 React Native 页面可以调用 HarmonyOS 原生 ArkTS 模块。本篇在这个基础上接入第一个真实系统能力:通过 HarmonyOS Core Speech Kit 完成商品卖点 TTS 语音播报。

源码: https://atomgit.com/huqi/RNHarmonySkuAssistant


先来看看真机运行效果如下(PS:为了能跑 Kit,博主下了血本入手了时团的真机!):

TTS 页面真机运行效果

image-20260608225515338

image-20260608225441538


📋 全文摘要

本文是《React Native x HarmonyOS NEXT 创新能力接入方案》系列第3篇,详细介绍了如何在React Native应用中接入HarmonyOS Core Speech Kit实现TTS(文本转语音)功能。主要内容包括:

🎯 核心目标

  • 在RNOH(React Native on HarmonyOS)项目中接入HarmonyOS原生TTS能力
  • 实现商品卖点语音播报功能
  • 构建完整的React Native ↔ HarmonyOS TurboModule通信链路

🏗️ 技术架构

  1. React Native侧:声明NativeTTSModule,实现TTS页面组件
  2. C++层:TurboModule wrapper桥接RN与ArkTS
  3. ArkTS侧:接入Core Speech Kit,实现TTS引擎管理
  4. 生命周期管理:处理播放完成、停止播放和资源清理

📁 工程结构

  • 完整的模块化目录组织
  • RN页面、TurboModule、ArkTS模块分离
  • 配置文件和依赖管理

🔧 关键实现

  • RN页面:TTS控制界面,支持开始/停止播报、状态显示
  • TurboModule:C++ wrapper实现RN与ArkTS通信
  • ArkTS模块:Core Speech Kit的封装与调用
  • 生命周期:正确处理播放完成事件和资源释放

🚀 真机运行

  • 详细的真机部署步骤
  • 设备配置和权限设置
  • 运行效果验证

⚠️ 常见问题排查

  • TTSModule找不到的解决方案
  • 引擎状态显示异常处理
  • 播放完成事件监听问题
  • 多次点击播报的处理策略
  • 页面闪动问题修复

📝 总结与展望

  • 成功验证RNOH调用HarmonyOS系统能力的技术路径
  • 为后续OCR、图像识别等能力接入奠定基础
  • 下一篇预告:RNOH x HarmonyOS OCR商品包装/物流面单识别

本文通过完整的代码示例和详细的步骤说明,为开发者提供了在React Native应用中集成HarmonyOS TTS能力的实战指南,涵盖了从工程搭建到问题排查的全流程。

1. 本篇要实现什么?

本篇目标是实现一个 React Native 页面调用 HarmonyOS Core Speech Kit 进行语音播报 的完整闭环。

React Native 页面输入商品卖点
    ↓
点击“开始播报”
    ↓
调用 RNOH TurboModule
    ↓
C++ TurboModule wrapper 转发到 ArkTS
    ↓
ArkTS TTSModule 调用 Core Speech Kit textToSpeech
    ↓
系统完成文字转语音播放
    ↓
播放状态通过事件回传 React Native 页面

最终页面提供这些能力:

1. 展示 TTS 引擎是否可用
2. 输入商品卖点文案
3. 开始播报
4. 停止播报
5. 显示播放中、播放完成、已停止、播放失败等状态
6. 播报期间禁止重复点击开始播报

2. 为什么第一个系统能力先选 TTS?

在 HarmonyOS 创新能力接入里,TTS 很适合作为第一篇系统能力实践。

  1. 交互直观:点击按钮后能直接听到声音,真机效果明显。
  2. 链路清晰:RN 传文本,ArkTS 调系统能力,结果和状态回传 RN。
  3. 复杂度适中:相比 OCR、图片选择、端侧 AI 推理,TTS 更容易聚焦 TurboModule 注册、事件回传和系统 Kit 调用。

在本系列的统一 Demo —— RN Harmony SKU Assistant 中,我们把它设计成:

商品卖点语音播报工具。

这既能展示鸿蒙语音能力,也能和后续 OCR、端侧 AI、商品资料管理等文章串起来。


3. 当前工程目录结构

本篇实现不是单一 ArkTS 文件就能完成。当前 RNOH 工程需要 JS spec、RN 页面、C++ wrapper、Package 注册和 ArkTS 模块共同配合。

RNHarmonySkuAssistant
├── App.tsx
├── src
│   ├── native
│   │   └── NativeTTSModule.ts
│   └── pages
│       ├── SkuTurboModulePage.tsx
│       └── TTSPage.tsx
└── harmony
    └── entry
        └── src
            └── main
                ├── cpp
                │   ├── CMakeLists.txt
                │   ├── PackageProvider.cpp
                │   ├── TTSPackage.h
                │   └── turbomodule
                │       ├── TTSModule.cpp
                │       └── TTSModule.h
                └── ets
                    ├── GeneratedPackage.ets
                    └── turbomodule
                        └── TTSModule.ets

关键文件说明:

文件 作用
src/native/NativeTTSModule.ts RN 侧 TurboModule 类型声明
src/pages/TTSPage.tsx TTS Demo 页面与事件监听
harmony/entry/src/main/cpp/turbomodule/TTSModule.* C++ TurboModule 方法映射
harmony/entry/src/main/cpp/TTSPackage.h C++ Package 工厂,按模块名创建 TTSModule
harmony/entry/src/main/cpp/PackageProvider.cpp TTSPackage 加入 RNOH Package 列表
harmony/entry/src/main/ets/GeneratedPackage.ets ArkTS TurboModule 工厂注册
harmony/entry/src/main/ets/turbomodule/TTSModule.ets Core Speech Kit TTS 真实调用

4. RN 侧声明 NativeTTSModule

src/native/NativeTTSModule.ts 负责声明 RN 可以调用的原生能力:

import type { TurboModule } from 'react-native';
import { TurboModuleRegistry } from 'react-native';

export interface Spec extends TurboModule {
  speak(text: string): Promise<string>;
  stop(): Promise<string>;
  isAvailable(): Promise<boolean>;
  addListener(eventName: string): void;
  removeListeners(count: number): void;
}

export default TurboModuleRegistry.getEnforcing<Spec>('TTSModule');

这里有两个容易踩坑的点。

第一,模块名必须是同一个:

RN 侧:TurboModuleRegistry.getEnforcing<Spec>('TTSModule')
C++ 侧:if (name == "TTSModule")
ArkTS 侧:static readonly NAME = 'TTSModule'

第二,RN 的 NativeEventEmitter 会检查原生模块是否提供 addListenerremoveListeners。即使 ArkTS 侧不需要做额外订阅管理,也要把这两个方法暴露出来,否则会出现事件监听相关警告或异常。


5. React Native 页面实现

src/pages/TTSPage.tsx 负责页面交互和状态展示。

页面初始化时先检查 TTS 引擎是否可用:

useEffect(() => {
  const checkAvailability = async () => {
    try {
      const available = await TTSModule.isAvailable();
      setIsAvailable(available);
    } catch {
      setIsAvailable(false);
    }
  };

  checkAvailability();
}, []);

状态变化不靠 speak() 的 Promise 猜测,而是监听原生事件:

const ttsEventEmitter = new NativeEventEmitter(TTSModule);

const subscription = ttsEventEmitter.addListener(
  'TTSStatus',
  (event: { type: string; errorMessage?: string }) => {
    switch (event.type) {
      case 'start':
        setStatus('播放中');
        setSpeaking(true);
        break;
      case 'complete':
        setStatus('播放完成');
        setSpeaking(false);
        break;
      case 'stop':
        setStatus('已停止');
        setSpeaking(false);
        break;
      case 'error':
        setStatus(`播放失败: ${event.errorMessage ?? '未知错误'}`);
        setSpeaking(false);
        break;
    }
  },
);

开始播报时做两个保护:

const handleSpeak = async () => {
  const content = text.trim();

  if (!content) {
    Alert.alert('提示', '请输入需要播报的文字');
    return;
  }

  if (speaking) {
    Alert.alert('提示', '当前正在播报,请先停止后再开始新的播报');
    return;
  }

  setStatus('准备播报');
  await TTSModule.speak(content);
};

这样可以避免连续多次点击“开始播报”导致多个播报请求叠加。真实业务里也可以选择“新播报自动 stop 上一次”,但 Demo 里采用更保守的方式:播报中禁用开始按钮。


6. App.tsx 增加 Demo 入口

为了保留上一篇 TurboModule 最小实践,本篇没有把 App.tsx 直接替换成 TTS 页面,而是增加了 Demo 列表入口:

// App.tsx 节选
type DemoKey = 'list' | 'sku' | 'tts';

export default function App() {
  const [currentDemo, setCurrentDemo] = useState<DemoKey>('list');

  if (currentDemo === 'sku') {
    return <SkuTurboModulePage onBack={() => setCurrentDemo('list')} />;
  }

  if (currentDemo === 'tts') {
    return <TTSPage onBack={() => setCurrentDemo('list')} />;
  }

  return (
    <SafeAreaView style={styles.safeArea}>
      <ScrollView contentContainerStyle={styles.container}>
        <Text style={styles.title}>RNOH 能力 Demo</Text>
        <DemoCard
          title="TurboModule 最小实践"
          desc="调用 ArkTS 模块,验证 RN 到原生的基础桥接。"
          tag="基础链路"
          onPress={() => setCurrentDemo('sku')}
        />
        <DemoCard
          title="HarmonyOS TTS 语音播报"
          desc="调用 Core Speech Kit,将商品卖点文案转换为语音。"
          tag="系统能力"
          onPress={() => setCurrentDemo('tts')}
        />
      </ScrollView>
    </SafeAreaView>
  );
}

后续我们会继续扩展:OCR、端侧 AI、Form Kit 等能力


7. C++ TurboModule wrapper

RNOH 的 TurboModule 调用链里,C++ wrapper 是很关键的一层。如果只写 ArkTS TTSModule.ets,RN 侧仍然可能报:

TurboModuleRegistry.getEnforcing(...): 'TTSModule' could not be found

本篇在 harmony/entry/src/main/cpp/turbomodule/TTSModule.cpp 中声明方法映射:

TTSModule::TTSModule(const ArkTSTurboModule::Context ctx, const std::string name)
    : ArkTSTurboModule(ctx, name) {
  methodMap_ = {
      ARK_ASYNC_METHOD_METADATA(speak, 1),
      ARK_ASYNC_METHOD_METADATA(stop, 0),
      ARK_ASYNC_METHOD_METADATA(isAvailable, 0),
      ARK_SCHEDULE_METHOD_METADATA(addListener, 1),
      ARK_SCHEDULE_METHOD_METADATA(removeListeners, 1),
  };
}

再通过 TTSPackage.h 按模块名创建 C++ TurboModule:

SharedTurboModule createTurboModule(Context ctx, const std::string &name) const override {
  if (name == "TTSModule") {
    return std::make_shared<TTSModule>(ctx, name);
  }
  return nullptr;
}

最后在 PackageProvider.cpp 加入:

packages.emplace_back(std::make_unique<TTSPackage>(ctx));

并在 CMakeLists.txt 编译 ./turbomodule/TTSModule.cpp。这几处少任意一处,都可能导致 RN 找不到模块。


8. ArkTS 侧接入 Core Speech Kit

harmony/entry/src/main/ets/turbomodule/TTSModule.ets 使用 @kit.CoreSpeechKit

import { textToSpeech } from '@kit.CoreSpeechKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { UITurboModule, UITurboModuleContext } from '@rnoh/react-native-openharmony';

引擎采用懒加载:首次 isAvailable()speak() 时创建。

const initParams: textToSpeech.CreateEngineParams = {
  language: 'zh-CN',
  person: 0,
  online: 1,
  extraParams: {
    style: 'interaction-broadcast',
    locate: 'CN',
    name: 'RNOHTTSEngine',
  },
};

textToSpeech.createEngine(initParams, (err, engine) => {
  if (!err && engine) {
    this.ttsEngine = engine;
    this.engineReady = true;
    resolve();
  } else {
    this.engineReady = false;
    reject(`TTS 引擎创建失败: code=${err?.code}, message=${err?.message}`);
  }
});

播报时设置监听器,并通过 RNOH 的 device event 通知 RN:

private emitTTSStatus(payload: TTSStatusPayload): void {
  this.ctx.rnInstance.emitDeviceEvent('TTSStatus', payload);
}

const speakListener: textToSpeech.SpeakListener = {
  onStart: (utteranceId: string): void => {
    this.clearPlaybackPollTimer();
    this.emitTTSStatus({ type: 'start', utteranceId });
  },
  onComplete: (utteranceId: string): void => {
    this.waitForPlaybackComplete(utteranceId);
  },
  onStop: (utteranceId: string): void => {
    this.clearPlaybackPollTimer();
    this.emitTTSStatus({ type: 'stop', utteranceId });
  },
  onError: (utteranceId: string, errorCode: number, errorMessage: string): void => {
    this.clearPlaybackPollTimer();
    this.emitTTSStatus({
      type: 'error',
      utteranceId,
      errorCode,
      errorMessage,
    });
  },
};

9. 播放完成为什么不能只看 onComplete?

这次真机调试里有一个关键问题:onComplete 回调触发时,扬声器实际可能还在播放。

也就是说,onComplete 更接近“合成请求完成 / 输出完成”的信号,不一定等于用户听到的声音已经结束。如果 RN 在这个回调里立刻显示“播放完成”,页面状态就会早于真实播放。

当前实现采用更稳的方式:收到 onComplete 后继续轮询 ttsEngine.isBusy(),等引擎不忙时再向 RN 发 complete

private waitForPlaybackComplete(utteranceId: string): void {
  this.clearPlaybackPollTimer();

  this.playbackPollTimer = setInterval(() => {
    if (!this.ttsEngine) {
      this.clearPlaybackPollTimer();
      this.emitTTSStatus({ type: 'complete', utteranceId });
      return;
    }

    if (!this.ttsEngine.isBusy()) {
      this.clearPlaybackPollTimer();
      this.emitTTSStatus({ type: 'complete', utteranceId });
    }
  }, 200);
}

这不是估算播报时长,而是使用引擎状态判断播放是否仍在进行。相比按文本长度估算时间,它对语速、设备性能、语音合成策略更友好。


10. 停止播放与生命周期清理

停止播报时,需要同时处理两件事:

1. 清掉播放完成轮询定时器
2. 调用 TTS 引擎 stop()
async stop(): Promise<string> {
  if (!this.ttsEngine) {
    return '引擎未初始化,无需停止';
  }

  this.clearPlaybackPollTimer();
  this.ttsEngine.stop();
  return '已停止播报';
}

模块销毁时也要释放资源:

__onDestroy__() {
  this.clearPlaybackPollTimer();
  if (this.ttsEngine) {
    this.ttsEngine.shutdown();
    this.ttsEngine = null;
    this.engineReady = false;
  }
}

这一步可以避免页面销毁、热更新或应用退出后留下无意义的引擎状态。


11. 真机运行步骤

本篇在真机上验证,建议按下面顺序排查:

1. 启动 Metro
2. 重新编译 HarmonyOS 工程
3. 安装到真机
4. 打开设备音量
5. 进入 “HarmonyOS TTS 语音播报” Demo
6. 确认页面显示 “TTS 引擎可用”
7. 点击 “开始播报”
8. 听到系统语音播报
9. 点击 “停止播报” 验证停止链路

本次工程验证命令:

npm test -- --runInBand
hvigorw assembleApp --no-daemon

其中 hvigorw assembleApp 已可完成构建。构建过程中仍可能看到 RNOH 或 Metro 的提示日志,例如 8081 端口已被占用、部分 RNOH 编译 warning,这类信息需要结合实际输出判断是否影响安装运行。


12. 常见问题与排查

问题 1:TTSModule 找不到

现象:

TurboModuleRegistry.getEnforcing(...): 'TTSModule' could not be found

优先检查:

1. RN 侧模块名是否为 TTSModule
2. C++ TTSModule.cpp 是否声明 speak / stop / isAvailable / addListener / removeListeners
3. TTSPackage.h 是否按 name == "TTSModule" 创建模块
4. PackageProvider.cpp 是否加入 TTSPackage
5. CMakeLists.txt 是否编译 turbomodule/TTSModule.cpp
6. GeneratedPackage.ets 是否注册 ArkTS TTSModule
7. 是否重新编译安装,而不是只刷新 Metro Bundle

本篇最容易漏的是 C++ Package 和 CMake 注册。ArkTS 文件存在,不代表 RN 一定能找到 TurboModule。

问题 2:页面显示 TTS 引擎不可用,但实际能播报

如果 isAvailable() 只是读取一个未初始化状态,就可能出现“页面显示不可用,点击后又能播报”的矛盾。

当前实现里,isAvailable() 会主动尝试初始化引擎:

isAvailable()
    ↓
如果已有可用引擎,返回 true
    ↓
否则调用 initEngine()
    ↓
初始化成功返回 true,失败返回 false

这样页面展示的“可用/不可用”和真实播报能力就能保持一致。

问题 3:点击开始播报后页面短暂闪动

这通常不是 TTS 引擎问题,而是 RN 页面状态在“准备播报”和原生事件回调之间快速切换。

当前实现里:

点击按钮:准备播报
onStart:播放中
onComplete + isBusy false:播放完成
onStop:已停止

如果页面文案或状态块高度变化明显,就会造成视觉闪动。解决方式是保持状态区域尺寸稳定,并避免把临时状态写成大标题。

问题 4:播放还没结束就显示播放完成

不要按文本长度估算播报时长,也不要在 speak() Promise resolve 后直接显示完成。

当前做法是:

onComplete
    ↓
轮询 ttsEngine.isBusy()
    ↓
引擎空闲后 emit complete
    ↓
RN 显示播放完成

问题 5:多次点击开始播报会播放多次吗?

如果不做保护,连续点击确实可能发起多个播报请求。当前 Demo 在 RN 侧用 speaking 锁住按钮:

speaking === true 时:
- “开始播报”按钮禁用
- 文案显示“正在播报”
- 如果仍触发 handleSpeak,会提示先停止当前播报

真实业务里也可以改成“开始新播报前自动 stop 上一次”,但要明确产品语义。


13. 本篇小结

本篇完成了 RNOH 接入 HarmonyOS Core Speech Kit TTS 的真机实践:

React Native TTSPage
    ↓
NativeTTSModule.speak(text)
    ↓
C++ ArkTSTurboModule wrapper
    ↓
ArkTS TTSModule
    ↓
Core Speech Kit textToSpeech
    ↓
系统语音播报
    ↓
TTSStatus 事件回传 RN

它的重点不只是“能发声”,而是把 RNOH 系统能力接入中几个高频问题都跑了一遍:

1. TurboModule 模块名一致性
2. C++ Package 注册
3. ArkTS 工厂注册
4. NativeEventEmitter 事件回传
5. 真机 TTS 引擎可用性检查
6. 播放完成状态不能靠时长估算
7. 连续点击的播报请求控制

这套链路跑通后,后续接 OCR、端侧 AI、图片选择、系统通知等能力时,工程结构就有了一个可复用的模板。


14. 下一篇预告

下一篇建议进入视觉能力:

RNOH x HarmonyOS OCR:商品包装 / 物流面单识别接入方案

下一篇会实现:

RN 页面选择图片
    ↓
调用 ArkTS 图片选择与 OCR 能力
    ↓
识别商品包装 / 物流面单文字
    ↓
结果回显到 RN 页面

相比 TTS,OCR 会多出图片选择、文件路径、图片权限、识别结果结构化等问题,会更接近真实业务中的系统能力接入。

Logo

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

更多推荐