适合谁看

  • 想读懂 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

插件提供两个方法:

方法

作用

类型

speak

播报文本

命令型(异步,等待完成)

stop

停止播报

命令型(立即返回)

核心实现

一、插件结构——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 个字段构成了整个插件的核心状态:

字段

职责

生命周期

channel

和 Flutter 通信

插件 attach 时创建,detach 时清空

ttsEngine

鸿蒙 TTS 引擎

首次 speak 时创建,可复用

pendingResult

一次播报的回调句柄

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 引擎销毁时调用,做两件事:

  1. 清空 MethodChannel 处理器,防止野指针调用

  2. 释放 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。

异常时清理 pendingResultcatch 块里先把 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 串行调用。

引擎参数说明:

参数

为什么选这个值

language

zh-CN

中文用户

person

0

默认发音人

online

1

在线模式音质更好

style

interaction-broadcast

广播风格,适合推荐场景

locate

CN

中国区服务器

六、监听器——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 个回调的职责分工:

回调

触发时机

处理方式

重要性

onStart

播报开始

只记日志

onComplete

播报自然完成

回传 success + 清空 pendingResult

onStop

被用户主动停止

回传 success + 清空 pendingResult

onData

音频数据流

只记日志

onError

出错

回传 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      ← 生命周期结束

这个设计的好处:

  1. Flutter 侧的 await 一定会返回 — 无论是完成、停止还是出错,pendingResult 都会被回收

  2. 不会出现回调泄漏 — 每次播报都有明确的结束点

  3. 多次播报不会冲突 — 新的 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);

关键参数:

参数

说明

queueMode

0

不排队——如果正在播报,新播报直接打断旧的

speed

1

正常语速(1.0 倍)

volume

2

音量级别

pitch

1

正常音调

requestId

tts_时间戳

唯一标识,用于日志追踪和回调匹配

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 做两件事:

  1. this.ttsEngine.shutdown() — 释放鸿蒙 TTS 引擎占用的音频通道和内存

  2. this.ttsEngine = null — 清空引用,方便后续重新创建

shutdown 本身也包了 try-catch,防止引擎状态异常时导致插件崩溃。

在鸿蒙设备上,TTS 引擎是重资源。如果不 shutdown,引擎会一直占用音频通道,其他应用可能无法使用 TTS/ASR 功能。

关键代码位置

文件

作用

app/ohos/entry/src/main/ets/plugins/TextToSpeechPlugin.ets

鸿蒙 TTS 插件(本文核心)

app/lib/core/platform/text_to_speech_channel.dart

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 数量。核心设计是:

  1. pendingResult 追踪一次播报的完整生命周期 — speak 时保存,完成/停止/出错时回收

  2. 引擎懒加载 + 单例复用 — 首次 speak 时创建,后续复用

  3. 5 个监听器各有分工 — onComplete/onStop/onError 都要回收 pendingResult

  4. stop 是同步返回的 — 不需要 Flutter 侧 await 等待

  5. shutdownEngine 在 onDetachedFromEngine 时调用 — 确保引擎资源释放

这种"命令型能力"的插件结构很稳定,可以复用到其他鸿蒙原生能力的接入中。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐