在 Flutter 鸿蒙项目里接入文本转语音的完整思路
2026/6/14 19:58:52 网站建设 项目流程

适合谁看

  • 正在给鸿蒙 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 引擎是重资源。"用完即释放、出错有兜底、退出必停止"是三个必须遵守的原则。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询