适合谁看
-
正在给鸿蒙 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;
}
}
引擎在两个时机销毁:
-
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)
→ 按钮从"停止播报"变回"语音播报"
关键代码位置
| 文件 | 作用 |
|---|---|
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 停止)
食界探味当前的实现之所以稳定,关键在于:
-
引擎懒加载 + 单例复用,不浪费资源
-
_stripForTts()确保播报内容干净 -
页面 dispose 必须停止 TTS,避免后台播放
-
流式输出完成前不允许触发播报
-
每个环节都有异常兜底,不会卡死页面
在鸿蒙设备上,TTS 引擎是重资源。"用完即释放、出错有兜底、退出必停止"是三个必须遵守的原则。
