在 Flutter 鸿蒙项目里接入文本转语音的完整思路

适合谁看

  • 正在给鸿蒙 Flutter 项目接 TTS 的人

  • 想理解 TTS 引擎生命周期管理的人

  • 想知道 TTS 文本预处理该怎么做的

  • 想理解 TTS 和页面状态如何协同的人

问题背景

TTS 接入看起来简单,但实际会遇到这些问题:

  • 鸿蒙 TTS 引擎什么时候创建、什么时候销毁?

  • 播报参数(语速、音量、音调)怎么配置?

  • AI 回复的 Markdown 格式会不会被读出来?

  • 页面退出时播报还在继续怎么办?

  • 用户连续点击"播报"怎么处理?

  • 引擎创建失败怎么兜底?

这些问题如果没想清楚,TTS 要么体验很差,要么成为页面的负担。

项目中的真实场景

食界探味当前的 TTS 接入涉及 3 层:

文件 职责
鸿蒙原生层 TextToSpeechPlugin.ets 引擎管理、播报执行、回调处理
Flutter Channel 层 text_to_speech_channel.dart 统一接口封装
业务层 协调器 + 页面 文本预处理、状态管理、UI 交互

核心实现

先说结论:

TTS 接入的完整思路是:鸿蒙管引擎、Flutter 管接口、业务管体验。三层各司其职,才能让 TTS 真正服务于产品。

一、鸿蒙原生层------引擎管理和播报执行

1.1 插件结构

鸿蒙侧的 TTS 插件实现了 FlutterPlugin 接口:

复制代码
// TextToSpeechPlugin.ets

import { textToSpeech } from '@kit.CoreSpeechKit';

export default class TextToSpeechPlugin implements FlutterPlugin, MethodCallHandler {
  private channel: MethodChannel | null = null;
  private ttsEngine: textToSpeech.TextToSpeechEngine | null = null;
  private pendingResult: MethodResult | null = null;

  onAttachedToEngine(binding: FlutterPluginBinding): void {
    this.channel = new MethodChannel(
      binding.getBinaryMessenger(),
      'com.foodvoyage.text_to_speech'
    );
    this.channel.setMethodCallHandler(this);
  }
}

通过 MethodChannel('com.foodvoyage.text_to_speech') 和 Flutter 通信,支持两个方法:

  • speak --- 播报文本

  • stop --- 停止播报

1.2 引擎创建------懒加载 + 单例
复制代码
private createEngine(): Promise<void> {
  return new Promise((resolve, reject) => {
    if (this.ttsEngine) {
      resolve();  // 已创建则直接返回
      return;
    }

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

    textToSpeech.createEngine(initParams, (err, engine) => {
      if (!err) {
        this.ttsEngine = engine;
        resolve();
      } else {
        reject(err);
      }
    });
  });
}

关键设计:

  • 懒加载 --- 只在第一次调用 speak 时创建引擎,不在插件初始化时创建

  • 单例复用 --- 已创建则直接复用,不重复创建

  • Promise 封装 --- 回调式 API 转为 async/await,方便 Flutter 侧调用

引擎参数说明:

参数 说明
language zh-CN 中文
person 0 默认发音人
online 1 在线模式(音质更好)
style interaction-broadcast 广播风格,适合推荐场景
locate CN 中国区
1.3 播报执行------回调监听 + 参数配置
复制代码
private setupListenerAndSpeak(text: string): void {
  if (!this.ttsEngine) return;

  // 1. 设置回调监听
  const speakListener: textToSpeech.SpeakListener = {
    onStart: (requestId, response) => {
      console.info(TAG, `onStart requestId: ${requestId}`);
    },
    onComplete: (requestId, response) => {
      console.info(TAG, `onComplete requestId: ${requestId}`);
      if (this.pendingResult) {
        this.pendingResult.success(null);  // 通知 Flutter 播报完成
        this.pendingResult = null;
      }
    },
    onStop: (requestId, response) => {
      console.info(TAG, `onStop requestId: ${requestId}`);
      if (this.pendingResult) {
        this.pendingResult.success(null);  // 通知 Flutter 停止完成
        this.pendingResult = null;
      }
    },
    onData: (requestId, audio, response) => {
      console.info(TAG, `onData requestId: ${requestId}, sequence: ${response.sequence}`);
    },
    onError: (requestId, errorCode, errorMessage) => {
      console.error(TAG, `onError code: ${errorCode}, msg: ${errorMessage}`);
      if (this.pendingResult) {
        this.pendingResult.error('TTS_ERROR', errorMessage, null);
        this.pendingResult = null;
      }
    }
  };

  this.ttsEngine.setListener(speakListener);

  // 2. 配置播报参数
  const speakParams: textToSpeech.SpeakParams = {
    requestId: `tts_${Date.now()}`,
    extraParams: {
      'queueMode': 0,        // 不排队,直接播报
      'speed': 1,            // 正常语速
      'volume': 2,           // 音量
      'pitch': 1,            // 正常音调
      'languageContext': 'zh-CN',
      'audioType': 'pcm',
      'soundChannel': 3,
      'playType': 1
    }
  };

  // 3. 发起播报
  this.ttsEngine.speak(text, speakParams);
}

播报参数说明:

参数 说明
queueMode 0 不排队,新播报直接开始
speed 1 正常语速(1.0)
volume 2 音量级别
pitch 1 正常音调(1.0)
playType 1 播放类型
1.4 引擎销毁------及时释放资源
复制代码
private shutdownEngine(): void {
  if (this.ttsEngine) {
    this.ttsEngine.shutdown();
    this.ttsEngine = null;
  }
}

引擎在两个时机销毁:

  1. Flutter 插件卸载时 --- onDetachedFromEngine 调用 shutdownEngine()

  2. 不需要时手动调用 --- 当前实现中不主动销毁,依赖插件生命周期

在鸿蒙设备上,TTS 引擎是比较重的资源(占用音频通道和内存)。及时释放能避免资源泄漏。

1.5 异常处理------每个环节都有兜底
复制代码
// 播报文本为空
if (!text || text.length === 0) {
  result.error('INVALID_ARGUMENT', '播报文本不能为空', null);
  return;
}

// 引擎创建失败
try {
  await this.createEngine();
} catch (err) {
  result.error('TTS_ERROR', `文本转语音启动失败: ${error.message}`, null);
}

// 播报失败
try {
  this.ttsEngine.speak(text, speakParams);
} catch (err) {
  this.pendingResult.error('TTS_ERROR', `播报失败: ${error.message}`, null);
}

每个环节都有错误回传给 Flutter,确保页面不会因为 TTS 出错而卡死。

二、Flutter Channel 层------统一接口封装

Flutter 侧的 Channel 封装非常简洁:

复制代码
// text_to_speech_channel.dart

class TextToSpeechChannel {
  static const _channel = MethodChannel('com.foodvoyage.text_to_speech');

  /// 播报文本,播报完成后返回
  static Future<void> speak(String text) async {
    await _channel.invokeMethod<void>('speak', {'text': text});
  }

  /// 停止播报
  static Future<void> stop() async {
    await _channel.invokeMethod<void>('stop');
  }
}

两个方法:

  • speak(text) --- 发起播报,阻塞到播报完成才返回

  • stop() --- 停止播报

注意 speak 是阻塞的------Flutter 侧调用后会等待鸿蒙引擎播报完成(或出错)。这意味着页面可以在 await 之后做清理工作。

这个 Channel 封装是平台无关的。同一套接口在 Android 端走 Android TTS,在 iOS 端走 AVSpeechSynthesizer,在鸿蒙端走 CoreSpeechKit。业务层完全不感知底层平台差异。

三、协调器层------文本预处理和状态管理

3.1 文本预处理------_stripForTts()

AI 回复中可能包含 Markdown 格式、emoji、表格符号等,这些如果直接丢给 TTS 引擎会被读出来(比如"井号""星号")。所以播报前必须清理:

复制代码
// ai_explore_coordinator.dart

String _stripForTts(String text) {
  var result = text;
  result = result.replaceAll(RegExp(r'\*{1,3}'), '');           // 移除加粗/斜体
  result = result.replaceAll(RegExp(r'^#{1,6}\s*', multiLine: true), ''); // 移除标题
  result = result.replaceAll(RegExp(r'^[-*+]\s+', multiLine: true), '');  // 移除列表
  result = result.replaceAll(RegExp(r'\[([^\]]*)\]\([^)]*\)'), r'$1');   // 移除链接
  result = result.replaceAll(RegExp(r'`[^`]*`'), '');            // 移除代码
  result = result.replaceAll(RegExp(                             // 移除 emoji
    r'[\u{1F300}-\u{1F9FF}...]',
    unicode: true,
  ), '');
  result = result.replaceAll('|', '');                           // 移除表格竖线
  result = result.replaceAll(RegExp(r'-{2,}'), '');              // 移除横线
  result = result.replaceAll(RegExp(r'\n{3,}'), '\n\n');        // 压缩空行
  return result.trim();
}

清理前后的对比:

复制代码
清理前:
"**推荐理由**:这道菜的灵魂在于#食材新鲜\n- 口感鲜嫩\n- 适合夏天\n| 食材 | 用量 |"

清理后:
"推荐理由:这道菜的灵魂在于食材新鲜 口感鲜嫩 适合夏天 食材 用量"
3.2 播报方法------speakText()

协调器提供的播报方法:

复制代码
Future<void> speakText(String text) async {
  final cleaned = _stripForTts(text);
  if (cleaned.isEmpty) return;

  _isSpeaking = true;
  state = state.copyWith(status: AiSessionStatus.speaking);

  try {
    await TextToSpeechChannel.speak(cleaned);
  } catch (e) {
    AppLogger.error('[AI助手] TTS 出错: $e');
  } finally {
    _isSpeaking = false;
    state = state.copyWith(status: AiSessionStatus.idle);
  }
}

流程:

  1. 清理文本格式

  2. 设置 _isSpeaking = true + 状态切到 speaking

  3. 调用 TextToSpeechChannel.speak()(阻塞)

  4. 播报完成/出错后,在 finally 中重置状态

3.3 菜品导览词播报------speakDishNarration()

菜品详情页的播报内容不是 AI 生成的,而是协调器拼接的结构化文本:

复制代码
Future<void> speakDishNarration(Dish dish) async {
  final parts = <String>[];
  if (dish.soul.isNotEmpty) {
    parts.add('这道${dish.name}的灵魂在于${dish.soul}');
  }
  if (dish.flavorTags.isNotEmpty) {
    parts.add('它的风味特点是${dish.flavorTags.join("、")}');
  }
  if (dish.description.isNotEmpty) {
    parts.add(dish.description);
  }
  parts.add('如果你喜欢这种口味,可以继续探索更多${dish.ingredientName}的全球吃法');
  final narration = parts.join('。');
  await speakText(narration);
}

拼接逻辑:灵魂 → 风味标签 → 描述 → 引导语,每段用句号连接。最终通过 speakText() 播报,同样会经过 _stripForTts() 清理。

3.4 停止播报------stopSpeaking()
复制代码
Future<void> stopSpeaking() async {
  try {
    await TextToSpeechChannel.stop();
  } catch (_) {}
  _isSpeaking = false;
  state = state.copyWith(status: AiSessionStatus.idle);
}

停止后重置状态,页面 UI 会从"停止播报"变回"语音播报"。

四、页面层------TTS 交互和生命周期

4.1 播报按钮交互

AI 助手页的播报按钮在每条 AI 回复气泡下方:

复制代码
// ai_assistant_screen.dart

void _toggleSpeak(String text) async {
  if (_isSpeaking) {
    try {
      await TextToSpeechChannel.stop();
    } catch (_) {}
    if (mounted) setState(() => _isSpeaking = false);
  } else {
    setState(() => _isSpeaking = true);
    try {
      await TextToSpeechChannel.speak(text);
    } catch (_) {}
  }
}

按钮 UI 根据 _isSpeaking 状态切换图标和文字:

复制代码
// ai_message_bubble.dart

Icon(
  isSpeaking ? Icons.stop_circle_outlined : Icons.volume_up_rounded,
),
Text(isSpeaking ? '停止播报' : '语音播报'),
4.2 页面退出时停止 TTS

这是一个必须处理的边界情况:

复制代码
@override
void dispose() {
  if (_isSpeaking) {
    TextToSpeechChannel.stop().catchError((_) {});
  }
  _scrollController.dispose();
  _inputFocusNode.dispose();
  super.dispose();
}

如果不处理,用户退出 AI 页面后鸿蒙 TTS 引擎还会继续播放声音,体验很差。

.catchError((_) {}) 确保即使 stop() 失败也不会抛异常影响页面销毁。

4.3 流式输出和 TTS 的协调

TTS 播报只在流式输出完成后才能触发。气泡组件通过 isStreaming 参数控制:

复制代码
// 只有非流式状态才显示播报按钮
if (!isStreaming && text.isNotEmpty && onSpeak != null)
  GestureDetector(
    onTap: onSpeak,
    child: Text('语音播报'),
  ),

这保证了用户不会在文本还在生成时就触发播报。

五、完整的 TTS 调用链路

复制代码
用户点击"语音播报"按钮
       │
       ▼
页面: _toggleSpeak(text)
  → setState(_isSpeaking = true)
       │
       ▼
协调器: speakText(text)
  → _stripForTts(text)          ← 清理 Markdown/emoji
  → state = speaking
       │
       ▼
Channel: TextToSpeechChannel.speak(cleaned)
  → MethodChannel.invokeMethod('speak', {text})
       │
       ▼  MethodChannel 通信
       │
鸿蒙: TextToSpeechPlugin.handleSpeak()
  → createEngine()              ← 首次创建 TTS 引擎
  → setupListenerAndSpeak()
    → setListener()             ← 注册回调
    → speak(text, params)       ← 发起播报
       │
       ▼  播报中...
       │
鸿蒙: speakListener.onComplete()
  → pendingResult.success(null) ← 通知 Flutter 完成
       │
       ▼  MethodChannel 回传
       │
Flutter: await 返回
       │
协调器: _isSpeaking = false
  → state = idle
       │
       ▼
页面: setState(_isSpeaking = false)
  → 按钮从"停止播报"变回"语音播报"

关键代码位置

文件 作用
app/ohos/entry/src/main/ets/plugins/TextToSpeechPlugin.ets 鸿蒙 TTS 插件
app/lib/core/platform/text_to_speech_channel.dart Flutter TTS 通道
app/lib/core/ai/ai_explore_coordinator.dart 文本预处理 + 状态管理
app/lib/features/ai_assistant/screens/ai_assistant_screen.dart 页面交互 + dispose 停止
app/lib/features/ai_assistant/widgets/ai_message_bubble.dart 播报按钮 UI

常见坑

  • 播报文本没有清理格式 --- 鸿蒙 TTS 引擎会把 Markdown 符号、emoji 读出来

  • 页面退出时不停止 TTS --- 鸿蒙端会出现后台播放声音

  • 没有处理引擎创建失败 --- 页面卡死,用户不知道发生了什么

  • 没有处理播报为空 --- _stripForTts 后文本可能变空,需要提前判断

  • 连续点击播报按钮 --- 没有防抖,可能导致多次播报同时进行

  • TTS 引擎不 shutdown --- 鸿蒙端内存泄漏

  • 播报参数不适合中文 --- languageContext 必须设为 zh-CN

  • 流式输出时允许触发播报 --- 文本还在变,播报内容不完整

可复用模板

鸿蒙 TTS 插件模板(TypeScript)

复制代码
import { textToSpeech } from '@kit.CoreSpeechKit';

class TtsPlugin implements FlutterPlugin, MethodCallHandler {
  private ttsEngine: textToSpeech.TextToSpeechEngine | null = null;
  private pendingResult: MethodResult | null = null;

  onAttachedToEngine(binding: FlutterPluginBinding): void {
    this.channel = new MethodChannel(
      binding.getBinaryMessenger(),
      'com.yourapp.text_to_speech'
    );
    this.channel.setMethodCallHandler(this);
  }

  onMethodCall(call: MethodCall, result: MethodResult): void {
    switch (call.method) {
      case 'speak': this.handleSpeak(call, result); break;
      case 'stop': this.handleStop(result); break;
      default: result.notImplemented();
    }
  }

  private async handleSpeak(call: MethodCall, result: MethodResult): Promise<void> {
    const text = call.argument('text') as string;
    if (!text) { result.error('EMPTY', '文本为空', null); return; }
    this.pendingResult = result;
    await this.createEngine();
    this.setupListenerAndSpeak(text);
  }

  private handleStop(result: MethodResult): void {
    this.ttsEngine?.stop();
    result.success(null);
  }

  private async createEngine(): Promise<void> {
    if (this.ttsEngine) return;
    return new Promise((resolve, reject) => {
      textToSpeech.createEngine({
        language: 'zh-CN', person: 0, online: 1,
        extraParams: { 'style': 'interaction-broadcast', 'locate': 'CN' }
      }, (err, engine) => {
        if (!err) { this.ttsEngine = engine; resolve(); }
        else reject(err);
      });
    });
  }

  private setupListenerAndSpeak(text: string): void {
    if (!this.ttsEngine) return;
    this.ttsEngine.setListener({
      onComplete: () => { this.pendingResult?.success(null); this.pendingResult = null; },
      onStop: () => { this.pendingResult?.success(null); this.pendingResult = null; },
      onError: (_, code, msg) => { this.pendingResult?.error('TTS_ERROR', msg); this.pendingResult = null; },
    });
    this.ttsEngine.speak(text, {
      requestId: `tts_${Date.now()}`,
      extraParams: { 'speed': 1, 'volume': 2, 'pitch': 1, 'queueMode': 0 }
    });
  }

  onDetachedFromEngine(): void {
    this.ttsEngine?.shutdown();
    this.ttsEngine = null;
  }
}

Flutter Channel 层模板

复制代码
class TextToSpeechChannel {
  static const _channel = MethodChannel('com.yourapp.text_to_speech');

  static Future<void> speak(String text) async {
    await _channel.invokeMethod<void>('speak', {'text': text});
  }

  static Future<void> stop() async {
    await _channel.invokeMethod<void>('stop');
  }
}

文本预处理模板

复制代码
String stripForTts(String text) {
  var result = text;
  result = result.replaceAll(RegExp(r'\*{1,3}'), '');
  result = result.replaceAll(RegExp(r'^#{1,6}\s*', multiLine: true), '');
  result = result.replaceAll(RegExp(r'\[([^\]]*)\]\([^)]*\)'), r'$1');
  result = result.replaceAll(RegExp(r'`[^`]*`'), '');
  result = result.replaceAll(RegExp(r'\n{3,}'), '\n\n');
  return result.trim();
}

页面层 TTS 模板

复制代码
// 状态
bool _isSpeaking = false;

// 触发
void toggleSpeak(String text) async {
  if (_isSpeaking) {
    await TextToSpeechChannel.stop();
    if (mounted) setState(() => _isSpeaking = false);
  } else {
    setState(() => _isSpeaking = true);
    try { await TextToSpeechChannel.speak(text); }
    catch (_) {}
  }
}

// 页面退出
@override
void dispose() {
  if (_isSpeaking) TextToSpeechChannel.stop().catchError((_) {});
  super.dispose();
}

本篇总结

在鸿蒙 + Flutter 下接入 TTS,完整思路是三层各司其职:

  • 鸿蒙原生层 --- 管引擎生命周期(创建、复用、销毁)和播报执行(参数配置、回调处理、异常兜底)

  • Flutter Channel 层 --- 统一接口封装(speak / stop),平台无关,业务层不感知底层差异

  • 业务层 --- 文本预处理(清理 Markdown/emoji)、状态管理(_isSpeaking + AiSessionStatus)、页面交互(按钮切换 + dispose 停止)

食界探味当前的实现之所以稳定,关键在于:

  1. 引擎懒加载 + 单例复用,不浪费资源

  2. _stripForTts() 确保播报内容干净

  3. 页面 dispose 必须停止 TTS,避免后台播放

  4. 流式输出完成前不允许触发播报

  5. 每个环节都有异常兜底,不会卡死页面

在鸿蒙设备上,TTS 引擎是重资源。"用完即释放、出错有兜底、退出必停止"是三个必须遵守的原则。

相关推荐
不羁的木木1 小时前
HarmonyOS AI开发提效工具:DevEco Code & DevEco CLI - 跨设备调试与AI应用部署
人工智能·华为·harmonyos·鸿蒙
ZJPRENO1 小时前
2026华为HDC AI 编程核心成果总结
华为·arkts
金启攻2 小时前
【鸿蒙原生应用开发实战】第一篇:项目搭建与首页开发 — 从零构建“宇宙探索“App
harmonyos
非凡大爹2 小时前
实验十三 华为三层交换机实现 VLAN 间通信实验指导书
网络·计算机网络·华为
坚果派·白晓明3 小时前
鸿蒙 PC应用集成 hwloc:3 大 NAPI & 编译坑详解
c语言·华为·ai编程·harmonyos·atomcode
不羁的木木3 小时前
HarmonyOS AI开发提效工具:DevEco Code & DevEco CLI - AOT编译加速AI应用启动
harmonyos·鸿蒙
木咺吟3 小时前
鸿蒙原生应用实战(三):塔罗牌App开发 — 牌阵解读与交互设计
harmonyos
不喝水就会渴3 小时前
HarmonyOS惰性加载性能优化技术详解(喵屿项目案例)
华为·性能优化·harmonyos
轻口味3 小时前
轻规划鸿蒙开发实战9:对接 Agent Framework Kit,用小艺智能体实现愿景项目体检与自动可行性打分
华为·harmonyos