适合谁看
想读懂
TextToSpeechPlugin.ets的开发者想写命令型鸿蒙原生插件的人
想理解 TTS 引擎生命周期管理的人
问题背景
TTS 插件常见的问题不是"能不能播报",而是:
引擎是否重复创建
完成和停止如何区分
页面多次点击播报按钮时状态怎么收口
pendingResult 挂起后怎么回收
这些都决定了插件是否可维护。
项目中的真实场景
食界探味的 TTS 插件位于:
app/ohos/entry/src/main/ets/plugins/TextToSpeechPlugin.ets
Flutter 侧对应:
app/lib/core/platform/text_to_speech_channel.dart
插件提供两个方法:
方法 | 作用 | 类型 |
|---|---|---|
| 播报文本 | 命令型(异步,等待完成) |
| 停止播报 | 命令型(立即返回) |
核心实现
一、插件结构——3 个关键字段
export default class TextToSpeechPlugin implements FlutterPlugin, MethodCallHandler { private channel: MethodChannel | null = null; // Flutter 通信通道 private ttsEngine: textToSpeech.TextToSpeechEngine | null = null; // TTS 引擎 private pendingResult: MethodResult | null = null; // 挂起的回调结果这 3 个字段构成了整个插件的核心状态:
字段 | 职责 | 生命周期 |
|---|---|---|
| 和 Flutter 通信 | 插件 attach 时创建,detach 时清空 |
| 鸿蒙 TTS 引擎 | 首次 speak 时创建,可复用 |
| 一次播报的回调句柄 | speak 时设置,完成/停止/出错时回收 |
pendingResult是整个插件最关键的设计——它把"一次播报命令"的生命周期收成了一个可追踪的对象。
二、插件生命周期——attach 和 detach
onAttachedToEngine(binding: FlutterPluginBinding): void { this.channel = new MethodChannel( binding.getBinaryMessenger(), 'com.foodvoyage.text_to_speech' ); this.channel.setMethodCallHandler(this); } onDetachedFromEngine(binding: FlutterPluginBinding): void { if (this.channel) { this.channel.setMethodCallHandler(null); // 清空处理器 } this.shutdownEngine(); // 释放 TTS 引擎 }onAttachedToEngine:Flutter 引擎启动时调用,创建 MethodChannel 并注册处理器。
onDetachedFromEngine:Flutter 引擎销毁时调用,做两件事:
清空 MethodChannel 处理器,防止野指针调用
释放 TTS 引擎,归还音频资源
这两个方法是鸿蒙 Flutter 插件的标准生命周期,必须正确实现。
三、方法分发——onMethodCall
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(); // 未知方法返回 notImplemented break; } }简洁的路由分发。未知方法返回result.notImplemented(),让 Flutter 侧能收到明确的错误,而不是挂起。
四、handleSpeak()——命令型方法的完整流程
private async handleSpeak(call: MethodCall, result: MethodResult): Promise<void> { // 1. 提取参数 const text = call.argument('text') as string; // 2. 参数校验(尽早返回) if (!text || text.length === 0) { result.error('INVALID_ARGUMENT', '播报文本不能为空', null); return; } // 3. 保存 pendingResult this.pendingResult = result; // 4. 创建引擎 + 播报 try { await this.createEngine(); this.setupListenerAndSpeak(text); } catch (err) { this.pendingResult = null; const error = err as BusinessError; result.error('TTS_ERROR', `文本转语音启动失败: ${error.message}`, null); } }关键设计点:
参数校验在最前面— TTS 属于命令型能力,参数错误应该尽早返回,而不是拖到引擎层。
pendingResult 在校验后保存— 如果参数为空,直接result.error()返回,不保存 pendingResult。
异常时清理 pendingResult—catch块里先把this.pendingResult = null,再调result.error(),避免重复回收。
五、引擎创建——懒加载 + 单例复用
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) { console.info(TAG, 'TTS engine created successfully'); this.ttsEngine = engine; resolve(); } else { console.error(TAG, `Failed to create TTS engine: ${err.message}`); reject(err); } }); }); }懒加载— 只在第一次调用speak时创建引擎,不在插件初始化时创建。这避免了用户从未使用 TTS 时浪费资源。
单例复用— 已创建则直接返回。TTS 引擎创建成本较高(需要初始化音频通道、加载语音模型),复用能显著提升性能。
Promise 封装— 鸿蒙textToSpeech.createEngine是回调式 API,用 Promise 包装后可以 async/await,方便handleSpeak串行调用。
引擎参数说明:
参数 | 值 | 为什么选这个值 |
|---|---|---|
|
| 中文用户 |
|
| 默认发音人 |
|
| 在线模式音质更好 |
|
| 广播风格,适合推荐场景 |
|
| 中国区服务器 |
六、监听器——5 个回调的职责分工
setupListenerAndSpeak()注册了 5 个监听器回调:
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); // 通知 Flutter:出错 this.pendingResult = null; } } };5 个回调的职责分工:
回调 | 触发时机 | 处理方式 | 重要性 |
|---|---|---|---|
| 播报开始 | 只记日志 | 低 |
| 播报自然完成 | 回传 success + 清空 pendingResult | 高 |
| 被用户主动停止 | 回传 success + 清空 pendingResult | 高 |
| 音频数据流 | 只记日志 | 低 |
| 出错 | 回传 error + 清空 pendingResult | 高 |
关键点:onComplete 和 onStop 都要回收 pendingResult。用户手动停止时,Flutter 侧的await speak()也需要返回,不能挂起。
七、pendingResult 的生命周期——一次播报的完整追踪
pendingResult是整个插件最关键的设计。它追踪的是"一次播报命令"的完整生命周期:
handleSpeak() → this.pendingResult = result ← 保存回调句柄 → createEngine() → setupListenerAndSpeak() → speak(text, params) ← 发起播报 │ ├─ onComplete: ← 播报自然完成 │ pendingResult.success(null) │ pendingResult = null ← 生命周期结束 │ ├─ onStop: ← 用户手动停止 │ pendingResult.success(null) │ pendingResult = null ← 生命周期结束 │ └─ onError: ← 出错 pendingResult.error(...) pendingResult = null ← 生命周期结束这个设计的好处:
Flutter 侧的 await 一定会返回— 无论是完成、停止还是出错,pendingResult 都会被回收
不会出现回调泄漏— 每次播报都有明确的结束点
多次播报不会冲突— 新的 speak 会覆盖旧的 pendingResult
但有一个潜在问题:如果用户快速连续点击播报按钮,旧的 pendingResult 会被覆盖,Flutter 侧的旧 await 会永远挂起。当前页面层通过_isSpeaking状态防止了这种情况。
八、handleStop()——主动停止的完整逻辑
private handleStop(result: MethodResult): void { try { if (this.ttsEngine) { this.ttsEngine.stop(); } result.success(null); // 立即返回成功 } catch (err) { const error = err as BusinessError; result.error('TTS_ERROR', `停止播报失败: ${error.message}`, null); } }注意 handleStop 和 handleSpeak 的区别:
维度 | handleSpeak | handleStop |
|---|---|---|
返回时机 | 播报完成后才返回 | 立即返回 |
pendingResult | 需要等监听器回收 | 直接 result.success() |
异步性 | async | 同步 |
stop 是同步返回的——调用后立即告诉 Flutter"停止指令已发送"。实际的停止效果由鸿蒙 TTS 引擎异步处理,停止完成后触发onStop回调。
这意味着 Flutter 侧调用stop()后,不需要await等待停止完成,可以立即更新 UI。
九、播报参数——setupListenerAndSpeak() 的后半段
const extraParam: Record<string, Object> = { 'queueMode': 0, // 不排队,新播报直接开始 'speed': 1, // 正常语速 'volume': 2, // 音量 'pitch': 1, // 正常音调 'languageContext': 'zh-CN', 'audioType': 'pcm', 'soundChannel': 3, 'playType': 1 }; const speakParams: textToSpeech.SpeakParams = { requestId: `tts_${Date.now()}`, // 唯一标识,用于追踪 extraParams: extraParam }; this.ttsEngine.speak(text, speakParams);关键参数:
参数 | 值 | 说明 |
|---|---|---|
|
| 不排队——如果正在播报,新播报直接打断旧的 |
|
| 正常语速(1.0 倍) |
|
| 音量级别 |
|
| 正常音调 |
|
| 唯一标识,用于日志追踪和回调匹配 |
requestId用时间戳生成,保证每次播报都有唯一标识。在调试时可以通过 requestId 追踪一次播报的完整生命周期。
十、引擎销毁——shutdownEngine()
private shutdownEngine(): void { try { if (this.ttsEngine) { this.ttsEngine.shutdown(); this.ttsEngine = null; console.info(TAG, 'TTS engine shutdown'); } } catch (err) { console.error(TAG, `shutdown error: ${JSON.stringify(err)}`); } }shutdown 做两件事:
this.ttsEngine.shutdown()— 释放鸿蒙 TTS 引擎占用的音频通道和内存this.ttsEngine = null— 清空引用,方便后续重新创建
shutdown 本身也包了 try-catch,防止引擎状态异常时导致插件崩溃。
在鸿蒙设备上,TTS 引擎是重资源。如果不 shutdown,引擎会一直占用音频通道,其他应用可能无法使用 TTS/ASR 功能。
关键代码位置
文件 | 作用 |
|---|---|
| 鸿蒙 TTS 插件(本文核心) |
| Flutter TTS 通道 |
代码结构全景图
TextToSpeechPlugin │ ├─ 字段 │ ├─ channel: MethodChannel ← Flutter 通信 │ ├─ ttsEngine: TtsEngine ← 鸿蒙 TTS 引擎 │ └─ pendingResult: MethodResult ← 播报回调句柄 │ ├─ 生命周期 │ ├─ onAttachedToEngine() ← 创建 channel │ └─ onDetachedFromEngine() ← 清空 channel + shutdown 引擎 │ ├─ 方法分发 │ └─ onMethodCall() │ ├─ 'speak' → handleSpeak() │ └─ 'stop' → handleStop() │ ├─ handleSpeak() │ ├─ 参数校验(空文本 → error) │ ├─ 保存 pendingResult │ ├─ createEngine()(懒加载 + 单例) │ └─ setupListenerAndSpeak() │ ├─ 注册 5 个监听器 │ │ ├─ onStart → 日志 │ │ ├─ onComplete → success + 清空 pending │ │ ├─ onStop → success + 清空 pending │ │ ├─ onData → 日志 │ │ └─ onError → error + 清空 pending │ └─ speak(text, params) │ ├─ handleStop() │ └─ ttsEngine.stop() → 立即 success │ └─ shutdownEngine() └─ ttsEngine.shutdown() + null常见坑
每次播报都重建引擎— 开销大,应该懒加载 + 单例复用
stop 不回收 pendingResult— Flutter 侧的 await 永远挂起
onComplete 和 onStop 只处理一个— 两种路径都要回收 pendingResult
异常时不清理 pendingResult— 导致重复回收或泄漏
没有 shutdownEngine— 鸿蒙端音频通道和内存不释放
queueMode 设为排队— 多次点击播报会排队执行,体验差
requestId 不唯一— 调试时无法区分不同播报的生命周期
onDetachedFromEngine 不调 shutdownEngine— 插件卸载后引擎还在运行
可复用模板
鸿蒙命令型插件模板
export default class CommandPlugin implements FlutterPlugin, MethodCallHandler { private channel: MethodChannel | null = null; private engine: SomeEngine | null = null; private pendingResult: MethodResult | null = null; onAttachedToEngine(binding: FlutterPluginBinding): void { this.channel = new MethodChannel(binding.getBinaryMessenger(), 'com.yourapp.command'); this.channel.setMethodCallHandler(this); } onDetachedFromEngine(binding: FlutterPluginBinding): void { this.channel?.setMethodCallHandler(null); this.shutdownEngine(); } onMethodCall(call: MethodCall, result: MethodResult): void { switch (call.method) { case 'execute': this.handleExecute(call, result); break; case 'cancel': this.handleCancel(result); break; default: result.notImplemented(); } } private async handleExecute(call: MethodCall, result: MethodResult): Promise<void> { const param = call.argument('param') as string; if (!param) { result.error('EMPTY', '参数为空', null); return; } this.pendingResult = result; try { await this.ensureEngine(); this.setupListenerAndExecute(param); } catch (err) { this.pendingResult = null; result.error('ERROR', `${err.message}`, null); } } private handleCancel(result: MethodResult): void { this.engine?.cancel(); result.success(null); } private async ensureEngine(): Promise<void> { if (this.engine) return; // 创建引擎... } private setupListenerAndExecute(param: string): void { this.engine?.setListener({ onComplete: () => { this.pendingResult?.success(null); this.pendingResult = null; }, onCancel: () => { this.pendingResult?.success(null); this.pendingResult = null; }, onError: (_, msg) => { this.pendingResult?.error('ERROR', msg); this.pendingResult = null; }, }); this.engine?.execute(param); } private shutdownEngine(): void { this.engine?.shutdown(); this.engine = null; } }pendingResult 回收检查清单
每个命令型方法必须检查: □ 参数校验失败时是否清理了 pendingResult? □ 引擎创建失败时是否清理了 pendingResult? □ onComplete 时是否回收了 pendingResult? □ onCancel/onStop 时是否回收了 pendingResult? □ onError 时是否回收了 pendingResult? □ 新命令进来时是否覆盖了旧的 pendingResult?本篇总结
TextToSpeechPlugin的重点在于生命周期控制,而不是 API 数量。核心设计是:
pendingResult 追踪一次播报的完整生命周期— speak 时保存,完成/停止/出错时回收
引擎懒加载 + 单例复用— 首次 speak 时创建,后续复用
5 个监听器各有分工— onComplete/onStop/onError 都要回收 pendingResult
stop 是同步返回的— 不需要 Flutter 侧 await 等待
shutdownEngine 在 onDetachedFromEngine 时调用— 确保引擎资源释放
这种"命令型能力"的插件结构很稳定,可以复用到其他鸿蒙原生能力的接入中。