RNOH x HarmonyOS Core Speech Kit TTS:商品卖点语音播报真机实践

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

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


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


📋 全文摘要

本文是《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 进行语音播报 的完整闭环。

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

最终页面提供这些能力:

text 复制代码
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 模块共同配合。

text 复制代码
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 可以调用的原生能力:

ts 复制代码
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');

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

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

text 复制代码
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 引擎是否可用:

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

  checkAvailability();
}, []);

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

tsx 复制代码
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;
    }
  },
);

开始播报时做两个保护:

tsx 复制代码
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 列表入口:

tsx 复制代码
// 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 侧仍然可能报:

text 复制代码
TurboModuleRegistry.getEnforcing(...): 'TTSModule' could not be found

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

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:

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

最后在 PackageProvider.cpp 加入:

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

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

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

ts 复制代码
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:

ts 复制代码
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

ts 复制代码
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. 停止播放与生命周期清理

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

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

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

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

ts 复制代码
__onDestroy__() {
  this.clearPlaybackPollTimer();
  if (this.ttsEngine) {
    this.ttsEngine.shutdown();
    this.ttsEngine = null;
    this.engineReady = false;
  }
}

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


11. 真机运行步骤

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

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

本次工程验证命令:

bash 复制代码
npm test -- --runInBand
hvigorw assembleApp --no-daemon

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


12. 常见问题与排查

问题 1:TTSModule 找不到

现象:

text 复制代码
TurboModuleRegistry.getEnforcing(...): 'TTSModule' could not be found

优先检查:

text 复制代码
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() 会主动尝试初始化引擎:

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

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

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

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

当前实现里:

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

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

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

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

当前做法是:

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

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

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

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

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


13. 本篇小结

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

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

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

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

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


14. 下一篇预告

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

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

下一篇会实现:

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

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

相关推荐
TrisighT1 小时前
Electron 窗口切后台,我的轮询怎么停了?排查一下午才发现是浏览器搞的鬼
electron·harmonyos
yuegu7771 小时前
HarmonyOS应用<节气通>开发第12篇:设置页开发
华为·harmonyos
IT大白鼠1 小时前
BGP路径选择机制:属性分类、作用解析与选路流程全解
网络·网络协议·华为
李二。1 小时前
鸿蒙 PC 端截图标注工具全解析
华为·harmonyos
特立独行的猫a2 小时前
MQTT Client的Tauri应用移植到 OpenHarmony 鸿蒙 PC/ARM64 实践记录
mqtt·华为·rust·harmonyos·tauri·移植·鸿蒙pc
AI_零食2 小时前
鸿蒙原生 ArkTS:margin 溢出、Row 弹性分配与 alignItems 的交互
学习·华为·开源·harmonyos·鸿蒙·鸿蒙系统
AI_零食2 小时前
鸿蒙原生 ArkTS:border 的盒模型、深层嵌套约束传递与 scale 缩放
学习·华为·harmonyos·鸿蒙·鸿蒙系统
小成Coder2 小时前
【Jack实战】如何在应用内拉起应用评论弹窗引导用户评价
华为·harmonyos·鸿蒙
非凡大爹2 小时前
实验十一 华为路由器和交换机实现单区域 OSPF 动态路由协议配置实验指导书
网络·华为