在 Flutter 鸿蒙项目里接入文本转语音的完整思路
适合谁看
正在给鸿蒙 Flutter 项目接 TTS 的人
想理解 TTS 引擎生命周期管理的人
想知道 TTS 文本预处理该怎么做的
想理解 TTS 和页面状态如何协同的人
问题背景
TTS 接入看起来简单,但实际会遇到这些问题:
鸿蒙 TTS 引擎什么时候创建、什么时候销毁?
播报参数(语速、音量、音调)怎么配置?
AI 回复的 Markdown 格式会不会被读出来?
页面退出时播报还在继续怎么办?
用户连续点击"播报"怎么处理?
引擎创建失败怎么兜底?
这些问题如果没想清楚,TTS 要么体验很差,要么成为页面的负担。
项目中的真实场景
食界探味当前的 TTS 接入涉及 3 层:
层 | 文件 | 职责 |
|---|---|---|
鸿蒙原生层 |
| 引擎管理、播报执行、回调处理 |
Flutter Channel 层 |
| 统一接口封装 |
业务层 | 协调器 + 页面 | 文本预处理、状态管理、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 侧调用
引擎参数说明:
参数 | 值 | 说明 |
|---|---|---|
|
| 中文 |
|
| 默认发音人 |
|
| 在线模式(音质更好) |
|
| 广播风格,适合推荐场景 |
|
| 中国区 |
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); }播报参数说明:
参数 | 值 | 说明 |
|---|---|---|
|
| 不排队,新播报直接开始 |
|
| 正常语速(1.0) |
|
| 音量级别 |
|
| 正常音调(1.0) |
|
| 播放类型 |
1.4 引擎销毁——及时释放资源
private shutdownEngine(): void { if (this.ttsEngine) { this.ttsEngine.shutdown(); this.ttsEngine = null; } }引擎在两个时机销毁:
Flutter 插件卸载时—
onDetachedFromEngine调用shutdownEngine()不需要时手动调用— 当前实现中不主动销毁,依赖插件生命周期
在鸿蒙设备上,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); } }流程:
清理文本格式
设置
_isSpeaking = true+ 状态切到speaking调用
TextToSpeechChannel.speak()(阻塞)播报完成/出错后,在
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) → 按钮从"停止播报"变回"语音播报"关键代码位置
文件 | 作用 |
|---|---|
| 鸿蒙 TTS 插件 |
| Flutter TTS 通道 |
| 文本预处理 + 状态管理 |
| 页面交互 + dispose 停止 |
| 播报按钮 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 停止)
食界探味当前的实现之所以稳定,关键在于:
引擎懒加载 + 单例复用,不浪费资源
_stripForTts()确保播报内容干净页面 dispose 必须停止 TTS,避免后台播放
流式输出完成前不允许触发播报
每个环节都有异常兜底,不会卡死页面
在鸿蒙设备上,TTS 引擎是重资源。"用完即释放、出错有兜底、退出必停止"是三个必须遵守的原则。
