React Native 三方库 react-native-tts 鸿蒙适配实战:从零到语音播报

本文记录了将开源 React Native 三方库 react-native-tts 安装集成到 HarmonyOS NEXT 平台的完整过程,涵盖 RN 工程初始化、OHOS 原生工程搭建、三方库 JS 端与 ArkTS 端集成、Codegen 桥接代码生成、C++ 原生层配置,以及踩坑排障的完整复盘。


一、背景

1.1 三方库简介

react-native-tts 是一个 React Native Text-To-Speech 语音合成库,支持 iOS、Android 和 Windows 平台,提供文本朗读、语速/音调控制、语音列表获取等能力。

鸿蒙适配版本由 CPF-RN 团队维护,已适配 OpenHarmony 平台,使用鸿蒙原生 @kit.CoreSpeechKit 语音合成引擎实现底层能力。

1.2 适配目标

目标项 说明
三方库 react-native-tts@react-native-ohos/react-native-tts
RN 框架版本 @rnoh/react-native-openharmony 0.82.30
React Native 版本 0.82.1
目标平台 HarmonyOS NEXT (API 23+)
目标能力 语音合成(speak)、语速/音调设置、播放控制(stop/pause/resume)、事件监听

1.3 支持能力一览

API 功能 鸿蒙支持
getInitStatus() 获取 TTS 引擎初始化状态
speak(utterance) 朗读文本
stop(onWordBoundary) 停止朗读
pause(onWordBoundary) 暂停朗读
resume() 恢复朗读
setDefaultRate(rate) 设置默认语速
setDefaultPitch(pitch) 设置默认音调
voices() 获取音色列表
addEventListener() 添加事件监听
removeEventListener() 移除事件监听
setDucking() 降低其他音频音量
setDefaultEngine() 设置默认引擎
setDefaultVoice() 设置默认音色
setDefaultLanguage() 设置默认语言
setIgnoreSilentSwitch() 忽略静音开关
engines() 获取引擎列表

二、适配路线图

整个适配过程分为以下阶段:

阶段 内容 关键产出
阶段一 RN 工程创建与 OHOS 工程初始化 AwesomeProject + Myrndemo 双工程
阶段二 JS 端依赖安装与 Metro 配置 package.json + metro.config.js
阶段三 OHOS 端源码集成 tts/ 目录 5 个 ArkTS 文件
阶段四 Codegen 桥接代码生成 generated/ 目录 8 个文件
阶段五 C++ 原生层配置 CMakeLists.txt + PackageProvider.cpp
阶段六 App 业务代码与 Bundle 构建 App.tsx + bundle.harmony.js
阶段七 构建部署与验证 设备端语音播报成功

三、逐步适配过程

阶段一:RN 工程创建与 OHOS 工程初始化

1.1 创建 React Native 项目
npx @react-native-community/cli init AwesomeProject --version 0.82.1
cd AwesomeProject

⚠️ 版本注意:React Native 版本必须与 @rnoh/react-native-openharmony 版本对齐。0.82.30 对应 RN 0.82.x,版本不匹配会导致 TurboModule 找不到或白屏。

1.2 安装 Harmony 依赖

AwesomeProject/package.json 中添加:

{
  "dependencies": {
    "react": "18.3.1",
    "react-native": "0.82.1",
    "@react-native-oh/react-native-harmony": "^0.82.30",
    "@react-native-oh/react-native-harmony-cli": "^0.82.30",
    "metro": "^0.83.7"
  }
}

执行安装:

npm install
1.3 配置 Metro

创建 AwesomeProject/metro.config.js

const {mergeConfig, getDefaultConfig} = require('@react-native/metro-config');
const {createHarmonyMetroConfig} = require('@react-native-oh/react-native-harmony/metro.config');

const config = {
  transformer: {
    getTransformOptions: async () => ({
      transform: {
        experimentalImportSupport: false,
        inlineRequires: true,
      },
    }),
  },
};

module.exports = mergeConfig(getDefaultConfig(__dirname), createHarmonyMetroConfig({
  reactNativeHarmonyPackageName: '@react-native-oh/react-native-harmony',
}), config);

💡 Metro 配置的作用是将 react-native-tts 的 import 自动重定向到 @react-native-ohos/react-native-tts

1.4 创建 OHOS 工程

使用 DevEco Studio 创建 HarmonyOS NEXT 项目 Myrndemo,选择 Empty Ability 模板。项目结构如下:

Myrndemo/
├── AppScope/
├── entry/
│   ├── src/main/
│   │   ├── ets/
│   │   │   ├── entryability/EntryAbility.ets
│   │   │   ├── pages/Index.ets
│   │   │   └── RNPackagesFactory.ets
│   │   ├── cpp/
│   │   │   ├── CMakeLists.txt
│   │   │   └── PackageProvider.cpp
│   │   ├── resources/rawfile/
│   │   └── module.json5
│   ├── oh-package.json5
│   └── build-profile.json5
├── oh_modules/
└── build-profile.json5
1.5 配置环境变量
# 在 ~/.zshrc 中添加
export RNOH_C_API_ARCH=1
export HDC_SERVER_PORT=7035
export PATH="/Applications/DevEco-Studio.app/Contents/sdk/{版本路径}/openharmony/toolchains:$PATH"
source ~/.zshrc

阶段二:JS 端依赖安装与 Metro 配置

2.1 安装 react-native-tts
cd AwesomeProject
npm install @react-native-ohos/react-native-tts

安装完成后,package.json 中会新增:

{
  "dependencies": {
    "@react-native-ohos/react-native-tts": "^4.1.1-0.0.3"
  }
}
2.2 验证 Metro 重定向

执行 Bundle 构建时,Metro 会自动输出重定向信息:

[INFO] Redirected imports to 1 harmony-specific third-party package(s):
[INFO] • react-native-tts → @react-native-ohos/react-native-tts

这说明 import Tts from 'react-native-tts' 会被自动重定向到鸿蒙适配版本 @react-native-ohos/react-native-tts


阶段三:OHOS 端源码集成

Myrndemo/entry/src/main/ets/ 下创建 tts/ 目录,放置 5 个 ArkTS 文件。

3.1 目录结构
entry/src/main/ets/
├── tts/
│   ├── RNTTSPackage.ts        # RN Package 注册入口
│   ├── RNTTSTurboModule.ts    # TurboModule 实现(桥接层)
│   ├── TextToSpeechManager.ts # TTS 引擎管理(核心逻辑)
│   ├── AudioPlayer.ts         # 音频渲染器(播放层)
│   └── Logger.ts              # 日志工具
├── generated/                  # Codegen 生成(阶段四产出)
│   ├── ts.ts
│   ├── index.ets
│   ├── turboModules/
│   │   ├── TTSNativeModule.ts
│   │   └── ts.ts
│   └── components/
│       └── ts.ts
├── RNPackagesFactory.ets
└── pages/Index.ets
3.2 RNTTSPackage.ts — Package 注册入口
/*
 * Copyright (c) 2024 Huawei Device Co., Ltd. All rights reserved
 * Use of this source code is governed by a MIT license that can be
 * found in the LICENSE file.
 */
import type { TurboModule, TurboModuleContext } from '@rnoh/react-native-openharmony/ts';
import { RNPackage, TurboModulesFactory } from '@rnoh/react-native-openharmony/ts';
import { TM } from '../generated/ts';
import { RNTTSTurboModule } from './RNTTSTurboModule';

class RNTTSTurboModuleFactory extends TurboModulesFactory {
  createTurboModule(name: string): TurboModule | null {
    if (name === TM.TTSNativeModule.NAME) {
      return new RNTTSTurboModule(this.ctx);
    }
    return null;
  }

  hasTurboModule(name: string): boolean {
    return name === TM.TTSNativeModule.NAME;
  }
}

export class RNTTSPackage extends RNPackage {
  createTurboModulesFactory(ctx: TurboModuleContext): TurboModulesFactory {
    return new RNTTSTurboModuleFactory(ctx);
  }
}

💡 TM.TTSNativeModule.NAME 来自 Codegen 生成的类型,值为 'TTSNativeModule',确保 JS 端与 ArkTS 端的模块名一致。

3.3 RNTTSTurboModule.ts — TurboModule 桥接层
/*
 * Copyright (c) 2024 Huawei Device Co., Ltd. All rights reserved
 * Use of this source code is governed by a MIT license that can be
 * found in the LICENSE file.
 */

import { TurboModule } from '@rnoh/react-native-openharmony/ts';
import { TM } from '../generated/ts';
import { TextToSpeechManager } from './TextToSpeechManager';

type EventCallback = (event: string) => void;


export class RNTTSTurboModule extends TurboModule implements TM.TTSNativeModule.Spec {

  constructor(ctx) {
    super(ctx)
  }

  private TextToSpeechManager = new TextToSpeechManager(this.ctx);

  public getInitStatus(): Promise<unknown> {
    return this.TextToSpeechManager.getInitStatus();
  }

  public requestInstallEngine(): Promise<unknown> {
    return Promise.resolve('success');
  }

  public requestInstallData(): Promise<unknown> {
    return Promise.resolve('success');
  }

  public setDucking(enabled: boolean): Promise<unknown> {
    return Promise.resolve('success');
  }

  public setDefaultEngine(engineName: string): Promise<boolean> {
    return Promise.resolve(true);
  }

  public setDefaultVoice(voiceId: string): Promise<unknown> {
    return Promise.resolve('success');
  }

  public setDefaultRate(rate: number, skipTransform: boolean): Promise<unknown> {
    return this.TextToSpeechManager.setDefaultRate(rate, skipTransform);
  }

  public setDefaultPitch(pitch: number): Promise<unknown> {
    return this.TextToSpeechManager.setDefaultPitch(pitch);
  }

  public setDefaultLanguage(language: string): Promise<unknown> {
    return Promise.resolve('success');
  }

  public setIgnoreSilentSwitch(ignoreSilentSwitch: boolean): Promise<boolean> {
    return Promise.resolve(true);
  }

  public voices(): Promise<TM.TTSNativeModule.Voice[]> {
    return this.TextToSpeechManager.voices();
  }

  public engines(): Promise<TM.TTSNativeModule.Engine[]> {
    return Promise.resolve([]);
  }

  public speak(utterance: string, params: {}): Object {
    return this.TextToSpeechManager.speak(utterance, params as Record<string, string>);
  }

  public stop(onWordBoundary: boolean): Promise<boolean> {
    return this.TextToSpeechManager.stop(onWordBoundary);
  }

  public pause(onWordBoundary: boolean): Promise<boolean> {
    return this.TextToSpeechManager.pause(onWordBoundary);
  }

  public resume(): Promise<boolean> {
    return this.TextToSpeechManager.resume();
  }

  public addEventListener(type: string, handler: (event: Object) => Object): void {
    return this.TextToSpeechManager.addEventListener(type, handler as EventCallback);
  }

  public removeEventListener(type: string, handler: (event: Object) => Object): void {
    return this.TextToSpeechManager.removeEventListener(type, handler as EventCallback);
  }
}

💡 TurboModule 是 RN 新架构中 JS 与原生通信的桥梁。implements TM.TTSNativeModule.Spec 确保方法签名与 Codegen 生成的接口一致。未支持的 API 返回空实现。

3.4 TextToSpeechManager.ts — TTS 核心逻辑
/*
 * Copyright (c) 2024 Huawei Device Co., Ltd. All rights reserved
 * Use of this source code is governed by a MIT license that can be
 * found in the LICENSE file.
 */

import { textToSpeech } from '@kit.CoreSpeechKit';
import { util } from '@kit.ArkTS';
import { RNOHContext, RNOHLogger } from '@rnoh/react-native-openharmony/ts';
import { TM } from '../generated/ts';
import { AudioPlayer } from './AudioPlayer';

type EventCallback = (id: string) => void;


export class TextToSpeechManager {
  private context: RNOHContext | undefined = undefined;
  private tts: textToSpeech.TextToSpeechEngine;
  private ready: boolean;
  private processFlag: boolean;

  private audioPlayer: AudioPlayer;

  private speakParams: Record<string, Object> = {
    // 语速,0.5-2
    "speed": 1,
    // 音调,0-2
    "pitch": 1,
    // 合成类型,为0返回音频流,为1播放
    "playType": 0,
  }

  constructor(ctx: RNOHContext) {
    this.context = ctx;
    this.initEngine();
    this.audioPlayer = new AudioPlayer(ctx);
  }

  /*创建tts实例*/
  private initEngine(): Promise<string> {
    const extraParam: Record<string, Object> = {
      "style": 'interaction-broadcast',
      "locate": 'CN',
      "name": 'EngineName'
    };
    const initParamsInfo: textToSpeech.CreateEngineParams = {
      // 当前仅支持"zh-CN"中文
      language: 'zh-CN',
      // 音色,不可修改
      person: 0,
      // 0为在线,1为离线,当前仅支持离线模式
      online: 1,
      extraParams: extraParam
    };
    return new Promise((resolve, reject) => {
      textToSpeech.createEngine(initParamsInfo).then(res => {
        this.tts = res;
        this.tts.setListener(this.speakCallback);
        this.ready = true;
        resolve('Success');
      }).catch(error => {
        reject(JSON.stringify(error));
      });
    })
  }

  /*设置speak的回调信息*/
  private get speakCallback() {
    const that = this;
    return {
      // 开始播报回调
      onStart(requestId: string, response: textToSpeech.StartResponse) {
        that.processFlag = false;
      },
      // 合成完成及播报完成回调
      onComplete(requestId: string, response: textToSpeech.CompleteResponse) {
        that.emitEvent('tts-start', requestId);
        that.audioPlayer.sortBufferQueue();
        that.audioPlayer.processQueue(requestId, () => {
          that.emitEvent('tts-finish', requestId);
        });
      },
      // 停止播报回调
      onStop(requestId: string, response: textToSpeech.StopResponse) {},
      // 返回音频流
      onData(requestId: string, audio: ArrayBuffer, response: textToSpeech.SynthesisResponse) {
        if (response.sequence > 0) {
          if (!that.processFlag) {
            that.emitEvent('tts-progress', requestId);
            that.processFlag = true;
          }
          that.audioPlayer.receiveData({ buffer: audio, index: response.sequence }, requestId);
        }
      },
      // 错误回调
      onError(requestId: string, errorCode: number, errorMessage: string) {
        that.emitEvent('tts-error', requestId);
        that.audioPlayer.stop();
        that.audioPlayer.flush();
      }
    }
  }

  /*获取当前初始化状态*/
  public getInitStatus(): Promise<"success"> {
    return new Promise((resolve, reject) => {
      if (!this.ready) {
        try {
          this.initEngine().then(() => {
            resolve('success');
          }).catch(err => {
            reject(JSON.stringify(err));
          })
        } catch (exception) {
          reject(JSON.stringify(exception));
        }
      } else {
        resolve('success');
      }
    })
  }

  /*获取当前音色列表*/
  public voices(): Promise<TM.TTSNativeModule.Voice[]> {
    let voicesQuery: textToSpeech.VoiceQuery = {
      requestId: util.generateRandomUUID(false),
      online: 1
    };
    return new Promise((resolve, reject) => {
      try {
        this.tts.listVoices(voicesQuery).then(res => {
          const rList = res.map(v => {
            return {
              id: '',
              name: '',
              language: v.language,
              quality: 0,
              latency: 0,
              networkConnectionRequired: false,
              notInstalled: false
            }
          });
          resolve(rList);
        })
      } catch (e) {
        reject(JSON.stringify(e));
      }
    })
  }

  /*设置默认语速*/
  public setDefaultRate(rate: number, skipTransform?: boolean): Promise<"success"> {
    return new Promise((resolve, reject) => {
      try {
        this.speakParams.speed = rate;
        resolve('success');
      } catch (e) {
        reject(JSON.stringify(e));
      }
    });
  }

  /*设置默认音调*/
  public setDefaultPitch(pitch: number): Promise<"success"> {
    return new Promise((resolve, reject) => {
      try {
        this.speakParams.pitch = pitch;
        resolve('success');
      } catch (e) {
        reject(JSON.stringify(e));
      }
    });
  }

  /*停止语音播放*/
  public stop(onWordBoundary?: boolean): Promise<boolean> {
    return this.audioPlayer.stop();
  }

  /*开始合成语音并播放*/
  public speak(utterance: string, params: Record<string, string> = {}): string | number {
    if (!this.ready) {
      return;
    }

    if (this.tts.isBusy()) {
      return;
    }

    const utteranceId = util.generateRandomUUID(false);
    const speakParams = { requestId: utteranceId, extraParams: { ...this.speakParams, ...params } };
    try {
      this.audioPlayer.start().then(() => {
        this.audioPlayer.clearCacheData().then(() => {
          this.tts.speak(utterance, speakParams);
        })
      })
    } catch (exception) {
      throw new Error(JSON.stringify(exception));
    }
    return utteranceId;
  }

  /*暂停语音播放*/
  public pause(onWordBoundary: boolean): Promise<boolean> {
    return this.audioPlayer.pause();
  }

  /*重新播放之前暂停的语音*/
  public resume(): Promise<boolean> {
    return this.audioPlayer.resume();
  }

  private emitEvent(name: string, id: string) {
    this.context.rnInstance.emitDeviceEvent(name, id);
  }

  /*RN内置事件发射器调用所必需的*/
  public addEventListener(type: string, listener: EventCallback) {
    // Keep: Required for RN built in Event Emitter Calls.
  }

  /*RN内置事件发射器调用所必需的*/
  public removeEventListener(type: string, listener: EventCallback) {
    // Keep: Required for RN built in Event Emitter Calls.
  }
}

💡 关键设计

  • playType: 0 表示返回音频流(而非直接播放),由 AudioPlayer 自行控制播放流程
  • onComplete 回调触发时先排序缓冲区再播放,避免音频碎片乱序
  • emitEvent 通过 rnInstance.emitDeviceEvent 将事件传递到 JS 端
  • 鸿蒙 TTS 引擎当前仅支持 zh-CN 中文和离线模式(online: 1
3.5 AudioPlayer.ts — 音频渲染器
/*
 * Copyright (c) 2024 Huawei Device Co., Ltd. All rights reserved
 * Use of this source code is governed by a MIT license that can be
 * found in the LICENSE file.
 */

import audio from '@ohos.multimedia.audio';
import { RNOHContext } from '@rnoh/react-native-openharmony/ts';


type DataItem = {buffer: ArrayBuffer, index: number};
type Callback = () => void;


export class AudioPlayer {
  private TAG: string = 'AudioPlayer';
  private context: RNOHContext | undefined = undefined;
  private audioRenderer: audio.AudioRenderer;
  private bufferQueue: DataItem[] = [];
  private isWriting: boolean = false;
  public writeId: string = '';

  constructor(ctx: RNOHContext) {
    this.context = ctx;
    this.getAudioRenderer();
  }

  private get audioStatus(): number {
    return this.audioRenderer.state.valueOf();
  }

  public get isPrepare(): boolean {
    return this.audioStatus === audio.AudioState.STATE_PREPARED;
  }

  public get isRunning(): boolean {
    return this.audioStatus === audio.AudioState.STATE_RUNNING;
  }

  public get isPause(): boolean {
    return this.audioStatus === audio.AudioState.STATE_PAUSED;
  }

  public get isStop(): boolean {
    return this.audioStatus === audio.AudioState.STATE_STOPPED;
  }

  /*获取音频渲染器*/
  private getAudioRenderer(): Promise<string> {
    const audioStreamInfo: audio.AudioStreamInfo = {
      samplingRate: audio.AudioSamplingRate.SAMPLE_RATE_16000,
      channels: audio.AudioChannel.CHANNEL_1,
      sampleFormat: audio.AudioSampleFormat.SAMPLE_FORMAT_S16LE,
      encodingType: audio.AudioEncodingType.ENCODING_TYPE_RAW
    };

    const audioRendererInfo: audio.AudioRendererInfo = {
      usage: audio.StreamUsage.STREAM_USAGE_MUSIC,
      rendererFlags: 0
    };

    const audioRendererOptions: audio.AudioRendererOptions = {
      streamInfo: audioStreamInfo,
      rendererInfo: audioRendererInfo
    };

    return new Promise((resolve, reject) => {
      audio.createAudioRenderer(audioRendererOptions,(err, data) => {
        if (err) {
          reject(JSON.stringify(err));
          throw new Error(JSON.stringify(err));
        } else {
          this.audioRenderer = data;
          resolve('Success');
        }
      });
    })
  }

  // 清空缓存数据
  public clearCacheData(): Promise<void> {
    return new Promise((resolve, reject) => {
      try {
        this.bufferQueue = [];
        this.writeId = '';
        this.isWriting = false;
        resolve();
      } catch (e) {
        reject();
        throw new Error(JSON.stringify(e));
      }
    })
  }

  // 接收音频数据流
  public receiveData(data: DataItem, requestId: string) {
    this.writeId = requestId;
    this.bufferQueue.push(data);
  }

  // 接收到的音频流顺序有误,不预先排序会产生杂音
  public sortBufferQueue(){
    this.bufferQueue.sort((a: DataItem, b: DataItem) => a.index -b.index);
  }

  // 处理缓冲队列
  public async processQueue(requestId: string, callback?: Callback) {
    this.writeId = requestId;
    for (const item of this.bufferQueue) {
      await this.writeBuffer(item.buffer);
    }
    this.isWriting = false;
    if (callback) {
      callback();
    }
  }

  // 启动音频渲染器
  public start(): Promise<boolean> {
    return new Promise((resolve, reject) => {
      try {
        if (this.isStop || !this.isRunning && !this.isPause && !this.isPrepare) {
          this.audioRenderer.start((err) => {
            if (err) {
              reject(JSON.stringify(err));
            } else {
              resolve(true);
            }
          });
        } else {
          resolve(true);
        }
      } catch (e) {
        reject(JSON.stringify(e));
        throw new Error(JSON.stringify(e));
      }
    })
  }

  // 写入音频数据到渲染器
  private writeBuffer(buffer: ArrayBuffer): Promise<void> {
    return new Promise((resolve, reject) => {
      try {
        this.isWriting = true;
        this.audioRenderer.write(buffer, (err) => {
          if (err) {
            this.isWriting = false;
            reject(JSON.stringify(err));
          } else {
            resolve();
          }
        });
      } catch (e) {
        this.isWriting = false;
        reject(JSON.stringify(e));
      }
    })
  }

  /*停止播放*/
  public stop(): Promise<boolean> {
    return new Promise((resolve, reject) => {
      try {
        if (this.isRunning || this.isPause) {
          this.audioRenderer.stop();
          this.isWriting = false;
          resolve(true);
        }
      } catch (e) {
        reject(JSON.stringify(e));
        throw new Error(JSON.stringify(e));
      }
    })
  }

  /*暂停*/
  public pause(): Promise<boolean> {
    return new Promise((resolve, reject) => {
      try {
        if(this.isRunning && !this.isPause){
          this.emitEvent('tts-pause');
          this.audioRenderer.pause();
          this.isWriting = false;
          resolve(true);
        }
      } catch (e) {
        reject(JSON.stringify(e));
        throw new Error(JSON.stringify(e));
      }
    })
  }

  /*继续播放*/
  public resume(): Promise<boolean> {
    return new Promise((resolve, reject) => {
      try {
        if(this.isPause && !this.isRunning){
          this.audioRenderer.start().then(() => {
            this.emitEvent('tts-resume');
            this.processQueue(this.writeId);
            resolve(true);
          }).catch((e) => reject(JSON.stringify(e)));
        }
      } catch (e) {
        reject(JSON.stringify(e));
        throw new Error(JSON.stringify(e));
      }
    })
  }

  /*清空缓存区*/
  public flush(): Promise<boolean> {
    return new Promise((resolve, reject) => {
      try {
        this.audioRenderer.flush();
        resolve(true);
      } catch (e) {
        reject(JSON.stringify(e));
        throw new Error(JSON.stringify(e));
      }
    })
  }

  private emitEvent(name: string){
    this.context.rnInstance.emitDeviceEvent(name, this.writeId);
  }
}

💡 关键设计

  • 使用 @ohos.multimedia.audioAudioRenderer 渲染 PCM 音频流
  • 采样率 16kHz、单声道、S16LE 格式——与鸿蒙 TTS 引擎输出匹配
  • sortBufferQueue() 对音频碎片按 sequence 排序,避免杂音
  • processQueue() 顺序写入缓冲区后回调通知播放完成
3.6 Logger.ts — 日志工具
import hilog from '@ohos.hilog';

class Logger {
  private domain: number;
  private prefix: string;
  private format: string = '%{public}s, %{public}s';
  private isDebug: boolean;

  constructor(prefix: string = 'TTS', domain: number = 0xFF00, isDebug: boolean = false) {
    this.prefix = prefix;
    this.domain = domain;
    this.isDebug = isDebug;
  }

  debug(...args: string[]): void {
    if (this.isDebug) {
      hilog.debug(this.domain, this.prefix, this.format, args);
    }
  }

  info(...args: string[]): void {
    hilog.info(this.domain, this.prefix, this.format, args);
  }

  warn(...args: string[]): void {
    hilog.warn(this.domain, this.prefix, this.format, args);
  }

  error(...args: string[]): void {
    hilog.error(this.domain, this.prefix, this.format, args);
  }
}

export default new Logger('TTS', 0xFF00, false)
3.7 注册 RNTTSPackage

修改 RNPackagesFactory.ets

import { RNPackageContext, RNPackage } from '@rnoh/react-native-openharmony/ts';
import { RNTTSPackage } from './tts/RNTTSPackage';

export function createRNPackages(ctx: RNPackageContext): RNPackage[] {
  return [
    new RNTTSPackage(ctx),
  ];
}
3.8 配置 oh-package.json5
{
  "name": "entry",
  "version": "1.0.0",
  "description": "Please describe the basic information.",
  "main": "",
  "author": "",
  "license": "",
  "dependencies": {
    "@rnoh/react-native-openharmony": "0.82.30",
    "@ppd/ffrt": "1.1.5"
  },
  "devDependencies": {},
  "dynamicDependencies": {}
}

⚠️ 注意:此处不需要添加 @react-native-ohos/react-native-tts.har 依赖,因为我们采用的是源码直接集成方式,将 5 个 ArkTS 文件直接放入项目中。


阶段四:Codegen 桥接代码生成

Codegen 是 RN 新架构的关键工具,根据 TurboModule Spec 自动生成跨语言桥接代码。

4.1 执行 Codegen
cd AwesomeProject

npx react-native codegen-harmony \
  --ets-output-path ../Myrndemo/entry/src/main/ets/generated \
  --cpp-output-path ../Myrndemo/entry/src/main/cpp/generated \
  --project-root-path . \
  --no-safety-check
4.2 生成结果
• ../Myrndemo/entry/src/main/cpp/generated/RNOHGeneratedPackage.h
• ../Myrndemo/entry/src/main/cpp/generated/TTSNativeModule.cpp
• ../Myrndemo/entry/src/main/cpp/generated/TTSNativeModule.h
• ../Myrndemo/entry/src/main/ets/generated/components/ts.ts
• ../Myrndemo/entry/src/main/ets/generated/index.ets
• ../Myrndemo/entry/src/main/ets/generated/ts.ts
• ../Myrndemo/entry/src/main/ets/generated/turboModules/TTSNativeModule.ts
• ../Myrndemo/entry/src/main/ets/generated/turboModules/ts.ts

info Generated 8 file(s)
4.3 关键生成文件解读

generated/ts.ts — 统一导出入口:

export * as RNC from "./components/ts"
export * as TM from "./turboModules/ts"

generated/turboModules/TTSNativeModule.ts — TM 类型定义:

import { Tag } from "@rnoh/react-native-openharmony/ts"

export namespace TTSNativeModule {
  export const NAME = 'TTSNativeModule' as const

  export type Engine = {name: string, label: string, default: boolean, icon: number}
  export type Voice = {id: string, name: string, language: string, quality: number,
    latency: number, networkConnectionRequired: boolean, notInstalled: boolean}

  export interface Spec {
    getInitStatus(): Promise<unknown>;
    requestInstallEngine(): Promise<unknown>;
    requestInstallData(): Promise<unknown>;
    setDucking(enabled: boolean): Promise<unknown>;
    setDefaultEngine(engineName: string): Promise<boolean>;
    setDefaultVoice(voiceId: string): Promise<unknown>;
    setDefaultRate(rate: number, skipTransform: boolean): Promise<unknown>;
    setDefaultPitch(pitch: number): Promise<unknown>;
    setDefaultLanguage(language: string): Promise<unknown>;
    setIgnoreSilentSwitch(ignoreSilentSwitch: boolean): Promise<boolean>;
    voices(): Promise<Voice[]>;
    engines(): Promise<Engine[]>;
    speak(utterance: string, params: {}): Object;
    stop(onWordBoundary: boolean): Promise<boolean>;
    pause(onWordBoundary: boolean): Promise<boolean>;
    resume(): Promise<boolean>;
    addEventListener(type: string, handler: (event: Object) => Object): void;
    removeEventListener(type: string, handler: (event: Object) => Object): void;
  }
}

💡 TM.TTSNativeModule.Spec 是 TurboModule 必须实现的接口,TM.TTSNativeModule.NAME 是模块注册名('TTSNativeModule'),确保 C++ → ArkTS → JS 三端一致。

generated/RNOHGeneratedPackage.h — C++ Package 注册:

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

class RNOHGeneratedPackage : public Package {
  public:
    RNOHGeneratedPackage(Package::Context ctx) : Package(ctx){};
    std::unique_ptr<TurboModuleFactoryDelegate> createTurboModuleFactoryDelegate() override {
        return std::make_unique<RNOHGeneratedPackageTurboModuleFactoryDelegate>();
    }
};

generated/TTSNativeModule.cpp — C++ 方法映射表:

TTSNativeModule::TTSNativeModule(const ArkTSTurboModule::Context ctx, const std::string name)
  : ArkTSTurboModule(ctx, name) {
    methodMap_ = {
        ARK_ASYNC_METHOD_METADATA(getInitStatus, 0),
        ARK_ASYNC_METHOD_METADATA(requestInstallEngine, 0),
        ARK_ASYNC_METHOD_METADATA(requestInstallData, 0),
        ARK_ASYNC_METHOD_METADATA(setDucking, 1),
        ARK_ASYNC_METHOD_METADATA(setDefaultEngine, 1),
        ARK_ASYNC_METHOD_METADATA(setDefaultVoice, 1),
        ARK_ASYNC_METHOD_METADATA(setDefaultRate, 2),
        ARK_ASYNC_METHOD_METADATA(setDefaultPitch, 1),
        ARK_ASYNC_METHOD_METADATA(setDefaultLanguage, 1),
        ARK_ASYNC_METHOD_METADATA(setIgnoreSilentSwitch, 1),
        ARK_ASYNC_METHOD_METADATA(voices, 0),
        ARK_ASYNC_METHOD_METADATA(engines, 0),
        ARK_METHOD_METADATA(speak, 2),
        ARK_ASYNC_METHOD_METADATA(stop, 1),
        ARK_ASYNC_METHOD_METADATA(pause, 1),
        ARK_ASYNC_METHOD_METADATA(resume, 0),
        ARK_METHOD_METADATA(addEventListener, 2),
        ARK_METHOD_METADATA(removeEventListener, 2),
    };
}

💡 ARK_ASYNC_METHOD_METADATA 标记异步方法(返回 Promise),ARK_METHOD_METADATA 标记同步方法。数字为参数个数。


阶段五:C++ 原生层配置

5.1 CMakeLists.txt

修改 entry/src/main/cpp/CMakeLists.txt

project(rnapp)
cmake_minimum_required(VERSION 3.4.1)
set(CMAKE_SKIP_BUILD_RPATH TRUE)
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")
set(CMAKE_ASM_FLAGS "-Wno-error=unused-command-line-argument -Qunused-arguments")
set(CMAKE_CXX_FLAGS "-fstack-protector-strong -Wl,-z,relro,-z,now,-z,noexecstack -s -fPIE -pie")
add_compile_definitions(WITH_HITRACE_SYSTRACE)
set(WITH_HITRACE_SYSTRACE 1)

add_subdirectory("${RNOH_CPP_DIR}" ./rn)

add_library(rnoh_app SHARED
    "./PackageProvider.cpp"
    "${RNOH_CPP_DIR}/RNOHAppNapiBridge.cpp"
    "${RNOH_GENERATED_DIR}/RNOHGeneratedPackage.h"
    "${RNOH_GENERATED_DIR}/TTSNativeModule.cpp"
)

target_include_directories(rnoh_app PUBLIC
    "${CMAKE_CURRENT_SOURCE_DIR}"
    "${RNOH_GENERATED_DIR}"
)

target_link_libraries(rnoh_app PUBLIC rnoh)

💡 相比空白模板,新增了 RNOH_GENERATED_DIRTTSNativeModule.cpptarget_include_directories

5.2 PackageProvider.cpp

修改 entry/src/main/cpp/PackageProvider.cpp

#include "RNOH/PackageProvider.h"
#include "generated/RNOHGeneratedPackage.h"

using namespace rnoh;

std::vector<std::shared_ptr<Package>> PackageProvider::getPackages(Package::Context ctx) {
    return {
        std::make_shared<RNOHGeneratedPackage>(ctx),
    };
}

💡 RNOHGeneratedPackage 是 Codegen 自动生成的 C++ Package,内部注册了 TTSNativeModule 的方法映射表,是 JS 调用 ArkTS 的 C++ 通道。


阶段六:App 业务代码与 Bundle 构建

6.1 App.tsx
import React, { useState, useEffect } from 'react';
import {
  View,
  Text,
  TextInput,
  TouchableOpacity,
  StyleSheet,
  ScrollView,
  Alert,
} from 'react-native';
import Tts from '@react-native-ohos/react-native-tts';

function App() {
  const [text, setText] = useState('你好,鸿蒙!欢迎使用 React Native 语音合成功能。');
  const [status, setStatus] = useState('就绪');
  const [isSpeaking, setIsSpeaking] = useState(false);
  const [rate, setRate] = useState(1.0);
  const [pitch, setPitch] = useState(1.0);

  useEffect(() => {
    Tts.getInitStatus().then(() => {
      setStatus('TTS 引擎已就绪');
    }).catch((err) => {
      setStatus('TTS 引擎初始化失败: ' + JSON.stringify(err));
    });

    const onStart = Tts.addEventListener('tts-start', (event) => {
      setIsSpeaking(true);
      setStatus('正在朗读...');
    });

    const onFinish = Tts.addEventListener('tts-finish', (event) => {
      setIsSpeaking(false);
      setStatus('朗读完成');
    });

    const onError = Tts.addEventListener('tts-error', (event) => {
      setIsSpeaking(false);
      setStatus('朗读出错');
    });

    const onCancel = Tts.addEventListener('tts-cancel', (event) => {
      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);
  };

  return (
    <View style={styles.container}>
      <Text style={styles.title}>语音合成 Demo</Text>

      <View style={styles.statusBar}>
        <Text style={styles.statusText}>状态:{status}</Text>
      </View>

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

const styles = StyleSheet.create({
  container: {
    flex: 1,
    padding: 20,
    backgroundColor: '#f5f5f5',
    justifyContent: 'center',
  },
  title: {
    fontSize: 24,
    fontWeight: 'bold',
    textAlign: 'center',
    marginBottom: 20,
    color: '#333',
  },
  statusBar: {
    backgroundColor: '#e3f2fd',
    padding: 10,
    borderRadius: 8,
    marginBottom: 15,
  },
  statusText: {
    fontSize: 14,
    color: '#1565c0',
    textAlign: 'center',
  },
  input: {
    backgroundColor: '#fff',
    borderWidth: 1,
    borderColor: '#ddd',
    borderRadius: 8,
    padding: 12,
    fontSize: 16,
    minHeight: 80,
    marginBottom: 15,
    textAlignVertical: 'top',
  },
  controlRow: {
    flexDirection: 'row',
    alignItems: 'center',
    marginBottom: 10,
    justifyContent: 'center',
  },
  label: {
    fontSize: 16,
    color: '#555',
    width: 100,
  },
  smallBtn: {
    backgroundColor: '#607d8b',
    width: 36,
    height: 36,
    borderRadius: 18,
    alignItems: 'center',
    justifyContent: 'center',
    marginHorizontal: 5,
  },
  btnRow: {
    flexDirection: 'row',
    justifyContent: 'center',
    marginTop: 15,
  },
  btn: {
    paddingHorizontal: 30,
    paddingVertical: 12,
    borderRadius: 8,
    marginHorizontal: 10,
    minWidth: 100,
    alignItems: 'center',
  },
  speakBtn: {
    backgroundColor: '#4caf50',
  },
  stopBtn: {
    backgroundColor: '#f44336',
  },
  btnDisabled: {
    backgroundColor: '#ccc',
  },
  btnText: {
    color: '#fff',
    fontSize: 16,
    fontWeight: 'bold',
  },
});

export default App;
6.2 构建 JS Bundle
cd AwesomeProject
npx react-native bundle-harmony --dev false

输出:

[INFO] Redirected imports to 1 harmony-specific third-party package(s):
[INFO] • react-native-tts → @react-native-ohos/react-native-tts

info Created harmony/entry/src/main/resources/rawfile/bundle.harmony.js
6.3 复制 Bundle 到 OHOS 工程
cp AwesomeProject/harmony/entry/src/main/resources/rawfile/bundle.harmony.js \
   Myrndemo/entry/src/main/resources/rawfile/bundle.harmony.js

阶段七:构建部署与验证

7.1 构建 HAP
cd Myrndemo
export RNOH_C_API_ARCH=1

/Applications/DevEco-Studio.app/Contents/tools/hvigor/bin/hvigorw \
  --mode module -p module=entry@default -p product=default assembleHap --no-daemon

构建成功输出:

> hvigor BUILD SUCCESSFUL in 45 s 642 ms
7.2 部署到设备
hdc install Myrndemo/entry/build/default/outputs/default/entry-default-signed.hap
7.3 启动应用
hdc shell aa start -a EntryAbility -b com.nutpi.rndemo

7.4 验证日志
hdc hilog -x | grep -iE "RNOH|TTS|tts"

成功日志:

A00001/com.huawei.hmsapp.hiai.tts/HiAI_TtsMaintenanceReportUtil: TTS callInfo:
  [{"appName":"com.nutpi.rndemo","appVersion":"1.0.0"},
   {"moduleName":"tts","interfaceName":"isBusy","serviceName":"TtsAbility",
    "runtime":"0","resultCode":"0","detailMessage":"query isBusy success"}]

A00001/com.huawei.hmsapp.hiai.tts/HiAI_TtsSpeakListener: onTtsStart,
  requestId: bc28b9ca-96e8-4b4d-bba1-31ebcb77db56

A00001/com.huawei.hmsapp.hiai.tts/HiAI_OfflineTtsService: synthesis complete,
  sequence: 14 audioSize: 175546 requestId: bc28b9ca-96e8-4b4d-bba1-31ebcb77db56

A00001/com.huawei.hmsapp.hiai.tts/HiAI_TtsSpeakListener: onTtsComplete,
  requestId: bc28b9ca-96e8-4b4d-bba1-31ebcb77db56, msg: synthesis complete

四、完整文件变更清单

文件路径 变更类型 说明
AwesomeProject/package.json 修改 添加 RNOH + TTS 依赖
AwesomeProject/metro.config.js 新增 Harmony Metro 配置
AwesomeProject/App.tsx 修改 TTS Demo 业务代码
Myrndemo/entry/src/main/ets/tts/RNTTSPackage.ts 新增 RN Package 注册
Myrndemo/entry/src/main/ets/tts/RNTTSTurboModule.ts 新增 TurboModule 桥接
Myrndemo/entry/src/main/ets/tts/TextToSpeechManager.ts 新增 TTS 引擎核心
Myrndemo/entry/src/main/ets/tts/AudioPlayer.ts 新增 音频渲染
Myrndemo/entry/src/main/ets/tts/Logger.ts 新增 日志工具
Myrndemo/entry/src/main/ets/RNPackagesFactory.ets 修改 注册 RNTTSPackage
Myrndemo/entry/src/main/ets/generated/* Codegen 生成 ETS 桥接类型
Myrndemo/entry/src/main/cpp/CMakeLists.txt 修改 添加 generated 源文件
Myrndemo/entry/src/main/cpp/PackageProvider.cpp 修改 注册 RNOHGeneratedPackage
Myrndemo/entry/src/main/cpp/generated/* Codegen 生成 C++ 桥接代码
Myrndemo/entry/oh-package.json5 修改 添加 RNOH 依赖
Myrndemo/entry/src/main/resources/rawfile/bundle.harmony.js 新增 JS Bundle

五、关键决策说明

决策 1:源码集成 vs .har 依赖

选择:源码直接集成

理由

  • .har 路径依赖(file:./libs/rn_tts.har)在不同构建环境下经常出现路径解析错误
  • 源码集成便于调试和定制(如修改 TTS 引擎参数)
  • 避免了 .har 版本与 RNOH 框架版本不兼容的问题

决策 2:React Native 版本降级至 0.82.1

选择:将 RN 从 0.84.1 降级到 0.82.1

理由

  • @rnoh/react-native-openharmony 0.82.30 仅兼容 RN 0.82.x
  • 版本不匹配导致 TurboModule(如 NativePerformanceCxx)找不到,应用白屏

决策 3:Codegen 必须手动执行

选择:使用 npx react-native codegen-harmony 手动生成

理由

  • RNOH 0.82.x 不支持 autolink,需要手动执行 Codegen
  • Codegen 生成的 TM.TTSNativeModule.Spec 接口是 TurboModule 实现的类型约束,确保方法签名三端一致
  • C++ 侧的 RNOHGeneratedPackage 也由 Codegen 生成,包含方法映射表

决策 4:使用 ResourceJSBundleProvider 优先

选择:Bundle 加载优先使用 ResourceJSBundleProvider

理由

  • MetroJSBundleProvider 需要连接 Metro 开发服务器,在无开发环境时会阻塞启动
  • ResourceJSBundleProvider 从应用资源直接加载,离线可用
  • 使用 AnyJSBundleProvider 按优先级尝试多种加载方式

六、踩坑与排障记录

问题 1:应用白屏,无 RNOH 日志

现象:应用启动后白屏,hdc hilog 无任何 RNOH 输出

原因MetroJSBundleProvider 在列表中排第一,Metro 服务器不可用时阻塞了整个初始化

解决:调整 Index.etsAnyJSBundleProvider 的顺序,将 ResourceJSBundleProvider 放在最前面

问题 2:TurboModule 找不到(NativePerformanceCxx、RNCSafeAreaContext)

现象:RNOH 初始化后报错,找不到 TurboModule

原因:RN 0.84.1 的 TurboModule 列表与 RNOH 0.82.30 不匹配

解决:将 React Native 降级到 0.82.1,确保版本对齐

问题 3:缺少 @ppd/ffrt 依赖

现象:C++ 构建阶段链接失败

原因@rnoh/react-native-openharmony 0.82.30 依赖 @ppd/ffrt,需在 oh-package.json5 中显式声明

解决:在 oh-package.json5dependencies 中添加 "@ppd/ffrt": "1.1.5"

问题 4:Codegen 未执行导致 TM 类型缺失

现象:ArkTS 编译报错,TM.TTSNativeModule 未定义

原因:未执行 react-native codegen-harmony 命令,generated/ 目录不存在

解决:执行 Codegen 命令生成桥接代码

问题 5:C++ 构建找不到 generated 头文件

现象:C++ 编译报 RNOHGeneratedPackage.h: No such file or directory

原因CMakeLists.txt 未添加 target_include_directories 包含 generated 目录

解决:在 CMakeLists.txt 中添加:

target_include_directories(rnoh_app PUBLIC
    "${CMAKE_CURRENT_SOURCE_DIR}"
    "${RNOH_GENERATED_DIR}"
)

七、遗留问题

问题 状态 说明
仅支持中文语音合成 已知限制 鸿蒙 TTS 引擎当前仅支持 zh-CN,详见 issue#3
边合成边播放不可用 已知限制 当前采用合成完成再播放模式,详见 issue#7
setDucking 不支持 已知限制 鸿蒙平台无对应 API,详见 issue#4
setDefaultVoice 不支持 已知限制 鸿蒙 TTS 音色不可修改,详见 issue#6
skipTransformonWordBoundary 参数无效 已知限制 参数不参与实际逻辑,详见 issue#8

八、总结

react-native-tts 集成到 HarmonyOS NEXT 平台的核心流程可概括为 “三端对齐 + Codegen 桥接”

  1. JS 端:安装 @react-native-ohos/react-native-tts,Metro 自动重定向 import
  2. ArkTS 端:源码集成 5 个 ArkTS 文件,实现 TurboModule 接口,调用鸿蒙原生 TTS API
  3. C++ 端:Codegen 自动生成桥接代码,CMakeLists.txt 和 PackageProvider.cpp 注册 Package

最关键的三个注意事项:

  • 版本对齐:RN 版本、RNOH 版本、三方库版本必须严格匹配
  • Codegen 必执行:不执行 Codegen 就没有 TM.TTSNativeModule 类型和 C++ 方法映射表
  • Bundle 加载顺序ResourceJSBundleProvider 必须优先于 MetroJSBundleProvider

参考文档

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐