HarmonyOS应用《民族图鉴》开发第34篇:TTSEngineService——语音引擎封装与降级策略

一、TTS 技术原理深度解析

在开始封装 TTS 服务之前,我们有必要先了解一下 TTS(Text-to-Speech,文本转语音)技术的底层原理。理解了原理,才能更好地设计和使用 TTS 服务。

1.1 TTS 技术的三个核心阶段

现代 TTS 系统通常由三个核心阶段组成:

复制代码
文本输入 → 文本分析 → 韵律生成 → 语音合成 → 音频输出
阶段一:文本分析(Text Analysis)

文本分析是 TTS 的第一步,负责把 raw text 转换成语言学上的结构化表示。

主要任务:

任务 说明 示例
文本归一化 把数字、符号、缩写转换成完整文字 "123" → "一百二十三"
分词 把句子切分成词语(中文尤其重要) "民族图鉴" → "民族", "图鉴"
词性标注 标注每个词的词性(名词/动词/形容词) "汉族" → 名词
韵律标注 标注重音、停顿、语调模式 逗号处短停,句号处长停
多音字处理 根据上下文确定多音字的正确发音 "快乐" vs "音乐"

为什么民族图鉴需要关注文本分析?

因为 56 个民族的名称中有很多生僻字和多音字,比如:

  • "仡佬族"的"仡"(gē)
  • "畲族"的"畲"(shē)
  • "僳僳族"的"僳"(sù)
  • "达斡尔族"的"斡"(wò)

如果 TTS 引擎读错了民族名称,用户体验会大打折扣。好的 TTS 引擎应该支持自定义发音词典,我们可以把 56 个民族的正确发音提前配置好。

阶段二:韵律生成(Prosody Generation)

韵律是指语音的"旋律"------语速、音调、节奏、重音等。韵律决定了合成语音听起来自不自然。

韵律的关键要素:

要素 说明 调节范围
语速(Speed) 每秒说多少个字 0.5x ~ 2.0x
音调(Pitch) 声音的高低频率 0.5x ~ 2.0x
音量(Volume) 声音的大小 0 ~ 15(或 0~100)
重音(Stress) 哪些词需要强调 自动检测 + 手动标注
停顿(Pause) 词间/句间的停顿时长 自动预测

民族图鉴中的韵律设计:

在民族介绍的朗读中,我们需要考虑:

  • 民族名称适当放慢、加重,让用户听清楚
  • 节日、习俗等关键词适当强调
  • 段落之间有足够的停顿,给用户消化时间
  • 整体语速适中,偏慢一点(因为是知识类内容)
阶段三:语音合成(Speech Synthesis)

语音合成是最后一步,把韵律参数转换成实际的音频波形。

主流合成技术对比:

技术 说明 音质 自然度 资源占用
拼接合成(Unit Selection) 从语音库中挑选单元拼接 一般 大(语音库大)
参数合成(Parametric) HMM 等统计模型生成参数 一般 一般
神经网络合成(Neural TTS) Tacotron、WaveNet 等深度模型 很高 很高 大(算力要求高)
端到端合成(End-to-End) 文本直接生成音频 最高 最自然 最大

HarmonyOS 的 Core Speech Kit 采用的是云端神经网络 TTS(在线模式)+ 本地参数化 TTS(离线模式)的混合方案。

1.2 TTS 技术的评价指标

怎么衡量一个 TTS 系统好不好?主要看三个维度:

1. 自然度(Naturalness)

  • 听起来像不像真人说话?
  • 有没有机器感、金属感?
  • 语调是否自然流畅?

常用 MOS 分(Mean Opinion Score)衡量,1-5 分:

  • 5分:完全像真人
  • 4分:比较自然,偶尔有机器感
  • 3分:能听懂,但明显是机器
  • 2分:听起来很吃力
  • 1分:完全听不懂

2. 清晰度(Intelligibility)

  • 每个字都能听清楚吗?
  • 多音字、生僻字读得对不对?
  • 快速朗读时还能听清吗?

3. 表现力(Expressiveness)

  • 能不能表达不同的情感(高兴、悲伤、严肃)?
  • 能不能模仿不同的说话风格(新闻播报、故事讲述、对话)?
  • 能不能控制重音、停顿等细节?

🎯 民族图鉴的 TTS 选型考量 :对于文化科普类应用,清晰度 > 自然度 > 表现力。首先要保证每个民族名称、专有名词都读对、读清楚,其次才是听起来自然不自然。情感表现力反而是最不重要的------毕竟是知识类内容,平实的朗读风格就够了。

二、为什么需要 TTS 服务

TTS(Text-to-Speech,文本转语音)是民族图鉴应用的一个重要辅助功能

  • 无障碍支持:视力不好的用户可以"听"民族介绍
  • 学习辅助:听民族名称、节日名称的标准发音
  • 沉浸体验:浏览文化内容时配上朗读,体验更好
  • 多语言:中文/英文切换,自动切换对应语音

但是,直接调用系统 TTS 能力会有很多问题:

问题 表现
初始化复杂 创建引擎、设置监听器、配置参数... 一大堆样板代码
状态混乱 播放中、空闲、错误... 各个页面自己维护状态
错误处理缺失 引擎初始化失败就崩了,页面直接白屏
无法测试 模拟器上没有 TTS 能力,开发调试全靠真机
代码重复 每个页面都写一遍创建/播放/停止的逻辑
长文本支持差 原生 API 对超长文本支持不好,需要自己分段
多语言切换麻烦 切换语言要重建引擎,逻辑复杂

解决方案:封装 TTSEngineService,把 TTS 的复杂性藏在服务内部。

三、系统能力:Core Speech Kit 深度解析

HarmonyOS 提供了系统级的语音能力,位于 @kit.CoreSpeechKit 包中。这是一个功能强大的语音服务套件,包含了 TTS、ASR(语音识别)、声纹识别等多种能力。

typescript 复制代码
import { textToSpeech } from '@kit.CoreSpeechKit';

3.1 Core Speech Kit 整体架构

Core Speech Kit 采用"引擎框架 + 多引擎适配"的架构:

复制代码
┌─────────────────────────────────────────┐
│          应用层 (Your App)              │
├─────────────────────────────────────────┤
│       Core Speech Kit 框架层            │
│  ┌──────────┬──────────┬──────────┐    │
│  │  TTS API │ ASR API  │  其他... │    │
│  └──────────┴──────────┴──────────┘    │
├─────────────────────────────────────────┤
│         引擎适配层 (Engine Adapter)     │
├─────────────────────────────────────────┤
│  ┌──────────┬──────────┬──────────┐    │
│  │ 华为TTS  │ 第三方  │ 离线引擎  │    │
│  │ 云端引擎 │ 引擎    │          │    │
│  └──────────┴──────────┴──────────┘    │
└─────────────────────────────────────────┘

架构优势:

  • 统一 API:不管底层用什么引擎,上层调用方式一致
  • 灵活切换:可以根据网络、设备能力动态选择引擎
  • 可扩展:新引擎只需要实现适配层,不需要改上层代码
  • 多音色支持:不同引擎提供不同的音色选择

3.2 核心概念详解

概念 说明 对应民族图鉴的使用场景
TextToSpeechEngine TTS 引擎实例,所有操作都通过它 全局单例,整个 app 共用一个
CreateEngineParams 创建引擎的参数(语言、音色、在线/离线等) 启动时根据当前语言创建
SpeakParams 朗读参数(请求ID、额外参数等) 每次 speak 时指定语速音量
SpeakListener 状态监听器(开始/完成/停止/错误) 页面监听状态更新 UI

3.3 在线引擎 vs 离线引擎

Core Speech Kit 支持两种引擎模式:

对比维度 在线引擎(online: 1) 离线引擎(online: 0)
技术方案 云端神经网络 TTS 本地参数化 TTS
音质 高,接近真人 一般,明显机器感
自然度 很好,语调自然 一般,比较平
响应速度 有网络延迟(200-500ms) 快,几乎即时响应
流量消耗 有,每次朗读都要请求
网络依赖 必须联网 不需要网络
多语言支持 多,中英日韩等都有 少,通常只有中英
音色选择 多,多种音色可选 少,通常只有 1-2 种
包体积影响 无,不用打包语音数据 大,语音库几百 MB

民族图鉴的选择策略:

typescript 复制代码
// 优先使用在线引擎(音质好)
// 如果网络不可用或初始化失败,再考虑降级
const initParams: textToSpeech.CreateEngineParams = {
  language: 'zh-CN',
  person: 0,
  online: 1,  // 优先在线
  extraParams: {
    'style': 'interaction-broadcast',
    'locate': 'CN',
    'name': 'EthnicTTS'
  }
};

💡 最佳实践:对于知识类、内容类应用,优先用在线引擎保证音质。同时要有降级方案,网络不好时也能用。对于工具类、指令类应用,离线引擎可能更合适(响应快、不需要网络)。

3.4 创建引擎详解

typescript 复制代码
const initParams: textToSpeech.CreateEngineParams = {
  language: 'zh-CN',   // 语言
  person: 0,            // 音色:0=女(聆小珊), 1=男(凌飞哲), 2=英文(Laura)
  online: 1,            // 1=在线引擎, 0=离线引擎
  extraParams: {
    'style': 'interaction-broadcast',
    'locate': 'CN',
    'name': 'EthnicTTS'
  }
};

textToSpeech.createEngine(initParams, (err, engine) => {
  if (!err) {
    // 引擎创建成功
  }
});

参数详解:

参数 类型 说明 可选值
language string 语言代码 zh-CN, en-US, ja-JP 等
person number 音色编号 0=女声,1=男声,具体取决于引擎
online number 是否在线引擎 1=在线,0=离线
extraParams Object 额外参数 因引擎而异

extraParams 常用配置:

key 说明 示例值
style 朗读风格 interaction-broadcast(交互播报)
locate 区域定位 CN(中国大陆)
name 引擎实例名称 EthnicTTS(用于日志追踪)

注意createEngine回调式 API,不是 Promise。所以我们需要把它包装成 Promise,方便 async/await 使用。

四、单例与初始化

4.1 单例模式深入

和 StorageService 一样,TTS 服务也是单例。让我们深入理解一下为什么 TTS 必须用单例。

typescript 复制代码
export class TTSEngineService {
  private static instance: TTSEngineService;
  private ttsEngine: textToSpeech.TextToSpeechEngine | null = null;
  private currentState: TTSState = TTSState.IDLE;
  private isEngineReady: boolean = false;
  private isDemoMode: boolean = false;
  private currentCallback: TTSCallback | null = null;

  private constructor() {}

  public static getInstance(): TTSEngineService {
    if (!TTSEngineService.instance) {
      TTSEngineService.instance = new TTSEngineService();
    }
    return TTSEngineService.instance;
  }
}

为什么 TTS 必须单例?

  1. 引擎资源有限:创建多个 TTS 引擎实例浪费系统资源

    • 每个引擎实例都要占用内存
    • 在线引擎还要维护网络连接
    • 系统可能限制同时存在的引擎数量
  2. 状态统一管理:全局只有一个播放状态,不会"多个声音一起响"

    • 详情页在朗读,用户点了列表页的另一个朗读按钮怎么办?
    • 如果是两个实例,两个声音会叠在一起
    • 单例模式下,新请求会被防重入逻辑挡住
  3. 配置统一:语速、音量、音色等设置全局一致

    • 用户在设置页改了语速,所有页面都生效
    • 不用每个页面都同步配置
  4. 音频焦点统一管理:后面加音乐播放时,统一处理音频焦点

    • TTS 和音乐不能同时响
    • 单例模式下,服务层可以统一协调

⚠️ 注意:ArkTS 中的单例和纯 JS/TS 的单例有一点不同------ArkTS 对静态成员的支持有一些限制,需要确保单例实现符合 ArkTS 的语法规范。上面的写法是经过验证的,可以放心使用。

4.2 初始化时机与流程

TTS 引擎什么时候初始化?这是一个重要的架构决策。

可选方案对比:

方案 说明 优点 缺点
应用启动时初始化 EntryAbility.onCreate 中 init 使用时已经就绪,响应快 增加启动时间,可能用不上
首次使用时初始化 第一次调用 speak 时才 init 不影响启动速度 首次使用有延迟
预加载(空闲时) 启动后空闲时间后台初始化 兼顾启动速度和使用体验 实现复杂

民族图鉴的选择:应用启动时初始化

理由:

  • TTS 是重要功能,用户很可能会用到
  • 启动时初始化,进入详情页就能直接用,体验更好
  • 初始化是异步的,不会阻塞主线程太久
typescript 复制代码
// EntryAbility.ets 中
export default class EntryAbility extends UIAbility {
  async onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): Promise<void> {
    // 其他初始化...
    
    // 异步初始化 TTS(不 await,不阻塞启动)
    TTSEngineService.getInstance().init();
  }
}

💡 技巧:初始化不 await,让它在后台异步执行。这样既不阻塞启动,又能在用户用到之前大概率完成初始化。如果用户点朗读时还没初始化完,可以加个 loading 或者直接走降级模式。

4.3 初始化方法详解

typescript 复制代码
public async init(): Promise<void> {
  try {
    await this.createEngine();
    this.isEngineReady = true;
    hilog.info(DOMAIN, 'TTSService', 'TTS Engine initialized successfully');
  } catch (e) {
    hilog.warn(DOMAIN, 'TTSService',
      `TTS init failed, fallback to demo mode: ${JSON.stringify(e)}`);
    this.isDemoMode = true;
    this.isEngineReady = true;  // 标记为就绪(降级模式)
  }
}

关键点:

  1. 不抛异常:初始化失败不会抛出,应用不会因为 TTS 用不了就崩了
  2. 自动降级:失败了进入 demo mode(模拟模式),功能还能"假装"工作
  3. 都标记 ready :不管成功失败,isEngineReady 都设为 true,上层不用区分

🎯 设计思想优雅降级(Graceful Degradation)------核心功能(民族图鉴浏览)不受影响,TTS 只是锦上添花的增强功能。用不了就用不了,不能让它拖垮整个应用。

4.4 回调转 Promise

原生 createEngine 是回调风格,我们包装成 Promise:

typescript 复制代码
private createEngine(): Promise<void> {
  return new Promise((resolve, reject) => {
    const initParams: textToSpeech.CreateEngineParams = {
      language: this.config.language,
      person: 0,
      online: 1,
      extraParams: {
        'style': 'interaction-broadcast',
        'locate': 'CN',
        'name': 'EthnicTTS'
      }
    };

    textToSpeech.createEngine(initParams, (err, engine) => {
      if (!err) {
        this.ttsEngine = engine;
        this.setupListener();
        resolve();
      } else {
        reject(new Error(`createEngine failed: code=${err.code}`));
      }
    });
  });
}

这是一个非常常见的模式------把回调式 API 包装成 Promise,这样上层就可以用 async/await 写同步风格的代码了。

为什么要转 Promise?

  1. 代码更简洁:async/await 比回调嵌套好读太多了
  2. 错误处理更统一:try-catch 统一捕获,不用每个回调都判断 err
  3. 便于组合:Promise 可以用 Promise.all、Promise.race 等组合
  4. 符合现代 JS/TS 开发习惯:现在大家都习惯用 async/await 了

📝 ArkTS 注意事项:ArkTS 对 Promise 的支持是比较完善的,async/await 都可以正常使用。但是要注意,不要在 UI 线程做太重的异步操作,避免卡顿。

五、状态管理与状态机

5.1 状态枚举

typescript 复制代码
export enum TTSState {
  IDLE = 'idle',        // 空闲
  SPEAKING = 'speaking', // 播放中
  ERROR = 'error'       // 错误
}

三种状态,构成一个简单的状态机:

复制代码
        speak()            stop()
  IDLE ─────────→ SPEAKING ─────────→ IDLE
    ↑                │
    │  onComplete    │ onError
    └────────────────┴────────→ ERROR
                          ↑
                          │
                    recover() / reset()
                          │
                         IDLE

更完整的状态机(进阶版):

如果要支持长文本、暂停、缓冲等功能,状态机会更复杂:

复制代码
           ┌──────────┐
           │   IDLE   │ (空闲)
           └────┬─────┘
                │ speak()
                ▼
           ┌──────────┐
     ┌────→│ LOADING  │ (加载中)
     │     └────┬─────┘
     │          │ onStart
     │          ▼
     │     ┌──────────┐
     │     │ SPEAKING │ (播放中)
     │     └────┬─────┘
     │          │ pause()
     │          ▼
     │     ┌──────────┐
     │     │ PAUSED   │ (已暂停)
     │     └────┬─────┘
     │          │ resume()
     │          └───────→ SPEAKING
     │
     │  stop() / onComplete / onError
     └─────────────────────→ IDLE / ERROR

民族图鉴目前用简化版的三状态机就够了,因为我们的场景比较简单:

  • 不需要暂停/继续(用户停了就重新读)
  • 文本不算太长,加载时间可以忽略
  • 出错了就回到空闲状态,不用单独的恢复流程

5.2 为什么需要自己管理状态

原生引擎也有状态,但我们为什么还要自己维护 currentState

  1. 降级模式需要:demo mode 下没有真实引擎,状态要自己管
  2. 访问同步getState() 是同步方法,不用等引擎回调
  3. 防重入控制:在 speak() 开头直接判断状态,不用问引擎
  4. 统一抽象:真实模式和降级模式对外表现一致
  5. 状态可预测:自己维护状态机,状态流转是确定的,不会出现"引擎说在播但实际没声音"这种尴尬情况
  6. 便于测试:单元测试时可以直接设置状态,不用依赖真实引擎

5.3 防重入

typescript 复制代码
public speak(text: string, callback?: TTSCallback): void {
  // 空文本直接返回
  if (!text || text.trim().length === 0) {
    return;
  }

  // 防重入:正在播放时忽略新请求
  if (this.isSpeaking()) {
    hilog.warn(DOMAIN, 'TTSService', 'speak called while already speaking, ignored');
    return;
  }

  // ... 真正的播放逻辑
}

防重入的重要性:用户连点两下朗读按钮,总不能两个声音叠在一起吧?在服务层就把这件事挡住,页面层不用操心。

防重入的常见策略:

策略 说明 适用场景
忽略新请求 正在执行就忽略新的 TTS 朗读、支付等"同时只能一个"的操作
排队执行 新请求加入队列,等前面完成再执行 消息发送、数据上报
取消旧请求 新请求来了就取消旧的,执行新的 搜索联想、数据刷新
合并请求 把多个请求合并成一个 批量上传、批量查询

TTS 用"忽略新请求"是最合适的,因为:

  • 朗读是"独占式"操作,两个一起响体验很差
  • 用户点了新的朗读,应该先手动 stop 再开始新的
  • 排队的话,用户可能已经离开页面了还在朗读,体验更差

💡 经验之谈:任何"不能同时执行多个"的操作,都要在服务层做防重入。不要相信页面会老老实实调用------用户可能连点,代码可能有 bug,多一层防护总是好的。

六、事件监听与回调

6.1 监听器设置

typescript 复制代码
private setupListener(): void {
  if (!this.ttsEngine) return;

  const listener: textToSpeech.SpeakListener = {
    onStart: (requestId, response) => {
      this.currentState = TTSState.SPEAKING;
      this.currentCallback?.onStart?.();
    },

    onComplete: (requestId, response) => {
      this.currentState = TTSState.IDLE;
      this.currentCallback?.onComplete?.();
    },

    onStop: (requestId, response) => {
      this.currentState = TTSState.IDLE;
      this.currentCallback?.onStop?.();
    },

    onError: (requestId, errorCode, errorMessage) => {
      this.currentState = TTSState.ERROR;
      this.currentCallback?.onError?.(errorCode, errorMessage);
    }
  };

  this.ttsEngine.setListener(listener);
}

监听器的四个回调时机:

回调 触发时机 对应状态变化
onStart 开始播放时 IDLE → SPEAKING
onComplete 正常播放完成时 SPEAKING → IDLE
onStop 被主动停止时 SPEAKING → IDLE
onError 播放出错时 SPEAKING → ERROR

为什么 onComplete 和 onStop 要分开?

因为两者的语义不一样:

  • onComplete:读完了,是"正常结束"
  • onStop:被打断了,是"异常结束"

页面可以根据不同的结束原因做不同的处理。比如:

  • onComplete:自动播放下一段(长文本场景)
  • onStop:什么都不做(用户主动停的)

6.2 回调设计

typescript 复制代码
export interface TTSCallback {
  onStart?: () => void;
  onComplete?: () => void;
  onStop?: () => void;
  onError?: (code: number, message: string) => void;
}

为什么用回调而不是 Promise?

因为 TTS 播放是一个持续过程,有多个时间点:

  • 开始播放(onStart)
  • 播放完成(onComplete)
  • 被停止(onStop)
  • 出错了(onError)

Promise 只能表示"成功/失败"一次,没法表示"开始了... 然后完成了"这样的过程。

使用方式:

typescript 复制代码
ttsService.speak('你好,世界', {
  onStart: () => {
    console.log('开始朗读了');
    this.isPlaying = true;
  },
  onComplete: () => {
    console.log('朗读结束');
    this.isPlaying = false;
  },
  onError: (code, msg) => {
    console.error('朗读出错:', code, msg);
    this.isPlaying = false;
  }
});

6.3 观察者模式的思考

现在的回调设计是"单次回调"------每次 speak 传一个 callback,只对这一次朗读有效。这对于简单场景够用了,但如果有多个地方都想监听 TTS 状态,就不够用了。

更灵活的方案:观察者模式(监听者模式)

typescript 复制代码
// 多个观察者可以同时监听
export class TTSEngineService {
  private listeners: Set<TTSListener> = new Set();

  addListener(listener: TTSListener): void {
    this.listeners.add(listener);
  }

  removeListener(listener: TTSListener): void {
    this.listeners.delete(listener);
  }

  private notifyStart(): void {
    this.listeners.forEach(listener => listener.onStart?.());
  }
}

两种方案对比:

方案 优点 缺点 适用场景
单次回调 简单、直接、用完即走 只能有一个监听者 简单页面,只需要更新按钮状态
观察者模式 支持多监听者、解耦 实现复杂、需要管理订阅 复杂应用,多个页面/组件都要响应状态变化

民族图鉴目前用单次回调就够了,因为只有详情页的朗读按钮需要监听状态。如果以后要加"全局播放控制栏"等功能,可以考虑升级成观察者模式。

6.4 可选链的妙用

typescript 复制代码
this.currentCallback?.onStart?.();

这一行有两个可选链 ?.

  • currentCallback?.onStart:如果 currentCallback 是 null/undefined,什么都不做
  • ?.():如果 onStart 是 undefined(用户没传),什么都不做

好处是------用户传不传回调、传哪几个回调,都不用写 if 判断,直接调用就行。

七、配置管理:语速、音调、音量

7.1 配置接口详解

typescript 复制代码
export interface TTSConfig {
  language: string;   // 语言: 'zh-CN', 'en-US'
  speed: number;      // 语速: 0.5~2.0, 默认 1.0
  pitch: number;      // 音调: 0.5~2.0, 默认 1.0
  volume: number;     // 音量: 0~15, 默认 10
}

三个可调参数的详细说明:

参数 范围 默认值 说明 民族图鉴建议
speed(语速) 0.5 ~ 2.0 1.0 每秒读多少字,值越大越快 知识类内容建议 0.8 ~ 1.0,慢一点让用户消化
pitch(音调) 0.5 ~ 2.0 1.0 声音的高低,值越高越尖 默认 1.0 就好,不用调
volume(音量) 0 ~ 15 10 声音大小 跟随系统音量就好,不用单独调

7.2 默认配置

typescript 复制代码
private config: TTSConfig = {
  language: 'zh-CN',
  speed: 1.0,
  pitch: 1.0,
  volume: 10
};

为什么这样设置默认值?

  • 语言默认中文:因为应用主要面向中文用户
  • 语速默认 1.0:标准语速,用户可以自己调
  • 音调默认 1.0:最自然的音调
  • 音量默认 10:中等偏上,保证能听清

7.3 更新配置

typescript 复制代码
public updateConfig(partial: Partial<TTSConfig>): void {
  if (partial.language !== undefined) {
    this.config.language = partial.language;
    // 语言变了需要重建引擎
    this.recreateEngine();
  }
  if (partial.speed !== undefined) {
    this.config.speed = partial.speed;
  }
  if (partial.pitch !== undefined) {
    this.config.pitch = partial.pitch;
  }
  if (partial.volume !== undefined) {
    this.config.volume = partial.volume;
  }
}

为什么不直接用 Object.assign?

因为 ArkTS 对 JavaScript 的动态特性支持有限,Object.assign 可能不支持或者有性能问题。手动逐字段赋值虽然啰嗦,但最稳。

Partial 的妙用:

Partial<TTSConfig> 表示"所有字段都是可选的 TTSConfig"。这样调用方可以只传要改的字段:

typescript 复制代码
// 只改语速
ttsService.updateConfig({ speed: 1.5 });

// 只改语言
ttsService.updateConfig({ language: 'en-US' });

// 三个都改
ttsService.updateConfig({ language: 'zh-CN', speed: 1.2, volume: 8 });

不用为每种组合写一个方法,一个方法搞定。

7.4 语言切换与引擎重建

重要:语言变了之后,需要重建 TTS 引擎,因为引擎是绑定语言的。

typescript 复制代码
private async recreateEngine(): Promise<void> {
  // 先停掉当前播放
  this.stop();

  // 销毁旧引擎
  if (this.ttsEngine) {
    try {
      this.ttsEngine.shutdown();
    } catch (e) {
      hilog.error(DOMAIN, 'TTSService', `shutdown old engine failed: ${JSON.stringify(e)}`);
    }
    this.ttsEngine = null;
  }

  // 创建新引擎
  try {
    await this.createEngine();
    this.isDemoMode = false;
    hilog.info(DOMAIN, 'TTSService', 'Engine recreated successfully');
  } catch (e) {
    hilog.warn(DOMAIN, 'TTSService',
      `Recreate engine failed, fallback to demo mode: ${JSON.stringify(e)}`);
    this.isDemoMode = true;
  }
}

重建流程:

  1. 停止当前播放(如果正在播)
  2. 销毁旧引擎
  3. 创建新引擎(用新的语言配置)
  4. 失败则降级到 demo mode

⚠️ 注意:重建引擎是异步操作,期间调用 speak 可能会有问题。可以加个"正在重建"的状态,期间的请求要么排队要么走降级模式。

7.5 配置生效时机

注意updateConfig 只是改了内存里的 this.config,下一次 speak() 的时候才会生效。当前正在播放的不会被打断。

这是合理的设计------用户在设置页调整语速,不用立即打断正在听的内容,下次朗读时自动用新语速。

那如果想立即生效呢?

可以提供一个 applyConfigImmediately 方法,改完配置后停止当前播放,重新开始。但一般不建议这样做,体验不好。

八、核心方法:speak 与 stop

8.1 speak 方法详解

typescript 复制代码
public speak(text: string, callback?: TTSCallback): void {
  // 1. 参数校验
  if (!text || text.trim().length === 0) {
    hilog.warn(DOMAIN, 'TTSService', 'speak called with empty text');
    return;
  }

  // 2. 防重入
  if (this.isSpeaking()) {
    hilog.warn(DOMAIN, 'TTSService', 'speak called while already speaking, ignored');
    return;
  }

  // 3. 保存回调
  this.currentCallback = callback || {};

  // 4. 降级模式处理
  if (this.isDemoMode || !this.ttsEngine) {
    this.speakInDemoMode(text);
    return;
  }

  // 5. 真实引擎播报
  try {
    const speakParams: textToSpeech.SpeakParams = {
      requestId: this.generateRequestId(),
      extraParams: { 'audioType': 'pcm' }
    };

    // 通过 extraParams 传递语速、音调和音量
    speakParams.extraParams!['speed'] = this.config.speed;
    speakParams.extraParams!['pitch'] = this.config.pitch;
    speakParams.extraParams!['volume'] = this.config.volume;

    this.ttsEngine.speak(text, speakParams);
  } catch (e) {
    hilog.error(DOMAIN, 'TTSService', `speak exception: ${JSON.stringify(e)}`);
    this.currentState = TTSState.ERROR;
    this.currentCallback?.onError?.(-1, JSON.stringify(e));
  }
}

speak 方法的五层防护:

层级 作用 防御什么问题
参数校验 空文本直接拒绝,不往下走 空指针、空字符串导致的异常
防重入 正在播放就忽略新请求 多个声音重叠、状态混乱
降级模式 引擎不可用就走模拟模式 引擎初始化失败、设备不支持
try-catch 引擎调用异常也不崩 引擎内部错误、状态异常
错误回调 出错了通知调用方 调用方不知道出错了,UI 不更新

8.2 stop 方法

typescript 复制代码
public stop(): void {
  if (this.isDemoMode) {
    this.currentState = TTSState.IDLE;
    this.currentCallback?.onStop?.();
    return;
  }

  if (this.ttsEngine) {
    try {
      this.ttsEngine.stop();
    } catch (e) {
      hilog.error(DOMAIN, 'TTSService', `stop exception: ${JSON.stringify(e)}`);
    }
  }
}

设计要点:

  • 降级模式也要能 stop(状态直接切回 IDLE,触发 onStop 回调)
  • 真实引擎 stop 也要 try-catch,防止在错误状态下调用 stop 又抛异常
  • stop 是幂等的:调用多次也不会有问题

8.3 生成请求 ID

typescript 复制代码
private generateRequestId(): string {
  return `tts_${Date.now()}_${Math.random().toString(36).substring(2, 8)}`;
}

每个朗读请求都有一个唯一 ID,用于日志追踪和问题排查。

格式:tts_时间戳_随机字符串,既保证唯一性,又能从 ID 看出请求时间。

为什么需要请求 ID?

  1. 日志追踪:出问题时可以根据 requestId 找到对应的日志
  2. 多请求区分:如果同时有多个请求(虽然我们防重入了),可以区分是哪个
  3. 链路追踪:和后端的 traceId 对应起来,全链路排查问题

九、长文本朗读的实现方案

民族图鉴中,有些民族的介绍文字可能比较长(几千字),而 TTS 引擎对单次朗读的文本长度是有限制的(一般是几千到几万字符)。这时候就需要分段朗读。

9.1 为什么需要长文本支持

问题 说明
长度限制 单次朗读文本过长,引擎可能报错或截断
进度丢失 用户听到一半退出了,下次进来要从头开始
无法定位 用户想从某一段开始听,只能从头等
没有高亮 听的时候不知道读到哪儿了
暂停/继续 原生 API 可能不支持暂停,或者支持不好

9.2 分段策略

长文本朗读的第一步是把长文本切成合适的小段。

分段原则:

  1. 按句子分:优先在句号、问号、感叹号处切分
  2. 按段落分:段落之间自然停顿,适合作为分段点
  3. 长度适中:每段 50-200 字,太长了不好控制,太短了切换频繁
  4. 避免中间切断:不要在一个句子中间切断
typescript 复制代码
interface TextSegment {
  id: number;           // 段序号
  text: string;         // 文本内容
  startIndex: number;   // 在原文中的起始位置
  endIndex: number;     // 在原文中的结束位置
}

function splitLongText(text: string, maxLen: number = 150): TextSegment[] {
  const segments: TextSegment[] = [];
  let currentIndex = 0;

  while (currentIndex < text.length) {
    // 取 maxLen 长度的文本
    let endIndex = Math.min(currentIndex + maxLen, text.length);

    // 如果没到末尾,往前找到最近的句号/问号/感叹号
    if (endIndex < text.length) {
      // 在 [currentIndex, endIndex] 范围内从后往前找句末标点
      let cutPoint = endIndex;
      for (let i = endIndex; i > currentIndex; i--) {
        const char = text[i];
        if (char === '。' || char === '!' || char === '?' ||
            char === '.' || char === '!' || char === '?') {
          cutPoint = i + 1; // 包含标点
          break;
        }
      }
      endIndex = cutPoint;
    }

    const segmentText = text.substring(currentIndex, endIndex);
    segments.push({
      id: segments.length,
      text: segmentText,
      startIndex: currentIndex,
      endIndex: endIndex
    });

    currentIndex = endIndex;
  }

  return segments;
}

9.3 断点续播

用户听到一半退出了,下次进来可以从上次的位置继续听。

typescript 复制代码
interface PlaybackProgress {
  textId: string;       // 文本唯一标识(比如民族ID)
  segmentIndex: number; // 当前读到第几段
  position: number;     // 在本段中的位置(可选,用于更精确的恢复)
  timestamp: number;    // 保存时间
}

class LongTextPlayer {
  private currentSegments: TextSegment[] = [];
  private currentSegmentIndex: number = 0;
  private currentTextId: string = '';

  // 保存进度
  private saveProgress(): void {
    const progress: PlaybackProgress = {
      textId: this.currentTextId,
      segmentIndex: this.currentSegmentIndex,
      position: 0,
      timestamp: Date.now()
    };
    StorageService.getInstance().saveTTSProgress(progress);
  }

  // 恢复进度
  public async resume(textId: string, fullText: string): Promise<boolean> {
    const progress = StorageService.getInstance().getTTSProgress(textId);
    if (!progress) return false;

    // 重新分段
    this.currentSegments = splitLongText(fullText);
    this.currentTextId = textId;
    this.currentSegmentIndex = progress.segmentIndex;

    // 从断点处开始播放
    this.playFromSegment(this.currentSegmentIndex);
    return true;
  }
}

9.4 高亮跟随

听的时候,正在读的那段文字高亮显示,让用户知道读到哪儿了。

typescript 复制代码
// 页面中使用
@Component
struct LongTextView {
  @State segments: TextSegment[] = [];
  @State currentSegmentIndex: number = -1;
  private ttsService: TTSEngineService = TTSEngineService.getInstance();

  build() {
    Column() {
      ForEach(this.segments, (segment, index) => {
        Text(segment.text)
          .fontColor(index === this.currentSegmentIndex ? '#409EFF' : '#333333')
          .backgroundColor(index === this.currentSegmentIndex ? '#ECF5FF' : 'transparent')
      })
    }
  }

  // 播放时更新当前段索引
  private playWithHighlight(segments: TextSegment[]): void {
    this.segments = segments;
    this.currentSegmentIndex = 0;

    // 自定义一个支持分段的播放方法
    this.playSegments(segments, 0);
  }

  private playSegments(segments: TextSegment[], index: number): void {
    if (index >= segments.length) return;

    this.currentSegmentIndex = index;

    this.ttsService.speak(segments[index].text, {
      onComplete: () => {
        // 本段读完,自动读下一段
        this.playSegments(segments, index + 1);
      }
    });
  }
}

📝 民族图鉴的实现考量:目前民族介绍的长度一般在几百字以内,用原生的单次朗读就够了。分段朗读、断点续播、高亮跟随这些功能可以作为 v2.0 的优化点。先把基础功能做扎实,再逐步增强。

十、多语言 TTS 支持

民族图鉴是一个面向多语言用户的应用,TTS 自然也要支持多语言。

10.1 支持的语言列表

语言 language 代码 推荐音色 说明
简体中文 zh-CN 聆小珊(女)/ 凌飞哲(男) 主要语言
英语 en-US Laura(女) 国际版默认
日语 ja-JP - 可选
韩语 ko-KR - 可选

民族图鉴优先支持中英双语,其他语言后续根据用户需求再加。

10.2 自动语言切换

当用户在设置中切换语言时,TTS 引擎也要跟着切换。

typescript 复制代码
// 在 I18nService 中监听语言变化
class I18nService {
  // ...

  public setLanguage(lang: string): void {
    // 切换应用语言
    // ...

    // 通知 TTS 服务切换语言
    TTSEngineService.getInstance().updateConfig({ language: lang });
  }
}

10.3 语言自动检测

有些时候,文本的语言和系统语言可能不一致------比如系统是中文,但要读一段英文。

理想方案:自动检测文本语言,选择对应的 TTS 引擎。

简化方案:根据文本内容简单判断,或者统一用中文引擎读(反正中文引擎也能读英文,只是口音重一点)。

民族图鉴目前采用简化方案------民族名称和介绍都是跟系统语言走的,不会出现"中文系统读英文内容"的情况。

10.4 方言支持(拓展)

中国有七大方言区,有些用户可能更习惯听方言版的民族介绍。

方言 说明 实现难度
粤语(广东话) 使用人数多,资源丰富
四川话 西南官话代表
东北话 东北官话 低(接近普通话)
闽南话 闽语代表
客家话 客家民系

实现方式:

  • 在线 TTS 引擎如果支持方言语色,直接配置
  • 不支持的话,可以用第三方 TTS 服务
  • 或者自己录制方言音频(成本高,但质量最好)

🎯 民族图鉴的特色点:56 个民族中有很多有自己的语言。如果能支持民族语言朗读(比如藏语、维吾尔语、蒙古语),那绝对是一大特色!但技术难度和成本也会高很多,需要专业的语料和合成模型。可以作为 v3.0 的长期规划。

十一、降级策略深度解析

这是整个 TTSEngineService 设计中最精彩的部分------优雅降级。

11.1 为什么需要降级

TTS 引擎不是什么时候都能用的:

  • 模拟器上可能没有 TTS 能力
  • 某些低端设备不支持
  • 网络不好时在线引擎用不了
  • 用户禁用了语音权限
  • 引擎初始化失败(各种未知原因)
  • 语言不支持(比如要读藏语但引擎没有)

如果 TTS 用不了应用就崩了,那就本末倒置了------TTS 只是增强功能,不是核心功能。

11.2 降级层级设计

我们可以设计多级降级策略,一级不行试下一级:

复制代码
Level 0: 真实在线引擎(最佳体验)
    ↓ 失败
Level 1: 真实离线引擎(无需网络)
    ↓ 失败
Level 2: 系统自带 TTS(如果 Core Speech Kit 不行)
    ↓ 失败
Level 3: Demo Mode 模拟模式(UI 正常交互)

民族图鉴目前实现了 Level 0 + Level 3,中间两级可以后续加。

11.3 降级模式实现

typescript 复制代码
private speakInDemoMode(text: string): void {
  hilog.info(DOMAIN, 'TTSService',
    `[Demo Mode] Playing: ${text.substring(0, 50)}...`);
  this.currentState = TTSState.SPEAKING;
  this.currentCallback?.onStart?.();

  // 模拟播放时长:根据文本长度估算(约3-8秒)
  const duration = Math.min(Math.max(text.length * 30, 2000), 8000);

  setTimeout(() => {
    if (this.currentState === TTSState.SPEAKING) {
      this.currentState = TTSState.IDLE;
      this.currentCallback?.onComplete?.();
    }
  }, duration);
}

降级模式的设计目标:

  1. 对外表现一致:同样有 onStart、onComplete、onStop 回调
  2. 状态流转一致:同样有 IDLE → SPEAKING → IDLE 的状态变化
  3. 时间合理:根据文本长度估算时长,不是固定值
  4. 可被停止:调用 stop() 能提前结束,和真实引擎行为一致
  5. 可检测 :提供 isDemoModeActive() 方法,上层可以判断并提示用户

为什么要做得这么"真"?

因为页面层不需要区分"真实模式"还是"降级模式"。如果降级模式行为和真实模式不一样,页面就得写两套逻辑------那就失去了封装的意义。

🎯 架构思想面向接口编程------不管底层是真实 TTS 还是模拟的,对外的接口(方法签名、回调、状态)完全一致。上层代码只依赖抽象,不依赖具体实现。

11.4 降级模式的使用场景

场景 用途
开发调试 模拟器上也能测 UI 交互(播放按钮状态变化等)
自动化测试 测试用例里不用依赖真实 TTS 引擎
设备不支持 低端设备用不了 TTS,至少 UI 是正常的
演示环境 没有语音能力的环境也能完整演示功能
单元测试 mock TTS 服务,专注测试业务逻辑

11.5 用户提示

降级模式虽然能保证功能正常,但用户可能会困惑"怎么没声音?"。所以需要适当提示用户。

typescript 复制代码
// 页面中使用
if (ttsService.isDemoModeActive()) {
  // 提示用户当前为模拟模式
  showToast('当前设备不支持语音朗读,使用模拟模式');
}

提示策略:

  • 不要一进来就弹窗提示,太打扰
  • 用户第一次点击朗读按钮时,用 toast 轻量提示
  • 设置页可以显示"TTS 状态:可用/降级"
  • 提供"如何开启 TTS"的帮助说明

十二、资源释放与生命周期

12.1 shutdown 方法

typescript 复制代码
public shutdown(): void {
  if (this.ttsEngine) {
    try {
      this.ttsEngine.shutdown();
      this.ttsEngine = null;
    } catch (e) {
      hilog.error(DOMAIN, 'TTSService', `shutdown exception: ${JSON.stringify(e)}`);
    }
  }
  this.currentState = TTSState.IDLE;
  this.currentCallback = null;
}

应用退出时要调用 shutdown() 释放引擎资源。TTS 引擎是系统资源,不用了要还给系统。

为什么要主动释放?

  • 节省系统资源(内存、CPU、网络连接)
  • 避免后台偷偷耗电
  • 给其他应用腾出 TTS 引擎资源
  • 良好的公民应用行为

12.2 生命周期管理

生命周期节点 操作 调用位置
应用启动 init() 初始化引擎 EntryAbility.onCreate
进入详情页 speak() 开始朗读 页面 aboutToAppear / 用户点击
离开详情页 stop() 停止播放 页面 aboutToDisappear
应用退后台 stop() 停止播放 EntryAbility.onBackground
应用回前台 可选:恢复播放 EntryAbility.onForeground
应用退出 shutdown() 释放引擎 EntryAbility.onDestroy

12.3 页面联动的最佳实践

typescript 复制代码
// 详情页中的朗读按钮
@Component
struct EthnicDetailPage {
  @State isPlaying: boolean = false;
  private ttsService: TTSEngineService = TTSEngineService.getInstance();

  // 播放/停止朗读
  private handleSpeakToggle(): void {
    if (this.isPlaying) {
      this.ttsService.stop();
      this.isPlaying = false;
    } else {
      this.ttsService.speak(this.ethnic.description, {
        onStart: () => { this.isPlaying = true; },
        onComplete: () => { this.isPlaying = false; },
        onStop: () => { this.isPlaying = false; },
        onError: () => { this.isPlaying = false; }
      });
    }
  }

  // 页面销毁时停止播放
  aboutToDisappear(): void {
    this.ttsService.stop();
  }
}

为什么要在页面销毁时 stop?

用户从详情页返回列表,总不能还在继续朗读吧?页面都没了,声音还在响,体验很差。

原则谁启动,谁停止------页面启动的播放,页面销毁时就要停掉。

⚠️ 内存泄漏提醒:如果页面销毁了不停止播放,不仅声音还在响,回调里还引用着页面的变量,可能导致页面无法被回收,造成内存泄漏。虽然 ArkTS 有 GC,但这种"正在运行的异步回调持有页面引用"的情况,还是要小心。

十三、架构设计总结

13.1 设计模式一览

模式 应用场景 作用
单例模式 全局一个 TTS 实例 统一管理、节省资源
外观模式 封装复杂的引擎 API 简化上层调用
状态模式 IDLE/SPEAKING/ERROR 清晰管理播放状态
策略模式 真实引擎 / Demo Mode 优雅降级
回调模式 onStart/onComplete 等 异步过程通知
适配器模式 回调转 Promise 统一异步编程模型

13.2 设计原则体现

原则 体现
单一职责 服务只做 TTS 相关的事
开闭原则 加新的降级方式不用改上层代码
依赖倒置 上层依赖 TTS 抽象,不依赖具体引擎
最小知识 页面只需要知道 speak/stop,不用知道引擎细节
优雅降级 TTS 不可用时不影响核心功能
防御式编程 参数校验、防重入、try-catch 多层防护

13.3 服务分层设计

复制代码
┌─────────────────────────────────────────┐
│          页面层 (Pages)                 │
│  详情页 / 设置页 / 列表页               │
├─────────────────────────────────────────┤
│       TTSEngineService(服务层)        │
│  ┌──────────────────────────────────┐  │
│  │  状态管理 / 配置管理 / 防重入    │  │
│  │  回调分发 / 降级策略 / 长文本    │  │
│  └──────────────────────────────────┘  │
├─────────────────────────────────────────┤
│    Core Speech Kit(系统能力层)        │
│  TextToSpeechEngine + SpeakListener    │
└─────────────────────────────────────────┘

各层职责:

  • 页面层:只管调用 speak/stop,更新 UI 状态
  • 服务层:封装复杂性,提供简洁易用的接口
  • 系统能力层:HarmonyOS 提供的底层 TTS 能力

十四、常见问题与避坑

14.1 在线 vs 离线引擎

类型 优点 缺点
在线引擎 音色多、质量好、支持多语言 需要网络、有延迟、耗流量
离线引擎 不用网络、响应快 音色少、质量一般、体积大

本项目选择 :优先在线引擎(online: 1)。如果离线效果更好,可以改成优先离线。

最佳实践:可以设计"智能切换"策略------WiFi 下用在线,移动网络下用离线,或者让用户自己选。

14.2 文本长度限制

TTS 引擎对单次朗读的文本长度有限制(一般是几千到几万字符)。超长文本怎么办?

方案:按句子/段落切片,依次朗读。详细实现参考第九章"长文本朗读的实现方案"。

本项目的民族介绍一般不会太长,暂时不用考虑。但如果以后加了"民族故事"之类的长文本内容,就需要了。

14.3 音频焦点

多个音频源(音乐、TTS、视频)同时播放会打架。系统有音频焦点机制------谁获得焦点谁播放,其他人暂停。

本项目因为比较简单,暂时没处理音频焦点。如果以后加了音乐播放,就要考虑 TTS 和音乐的焦点冲突问题。

音频焦点的处理策略:

场景 策略 体验
音乐播放中,TTS 开始 音乐暂停,TTS 说完后音乐继续 好("闪避"效果)
音乐播放中,TTS 开始 音乐音量降低,TTS 说完后恢复 更好("鸭子"效果)
TTS 播放中,音乐开始 音乐等 TTS 说完再播 一般

14.4 错误码处理

TTS 引擎的 onError 回调会返回错误码,不同的错误码代表不同原因:

  • 网络错误
  • 引擎忙
  • 文本过长
  • 不支持的语言
  • 权限不足
  • ...

可以根据错误码给用户不同的提示(比如网络错就提示"请检查网络",语言不支持就提示"暂不支持该语言")。本项目简化处理,统一回调错误码和信息。

常见错误码参考:

错误码 含义 处理建议
1001 网络错误 提示检查网络,尝试切换离线引擎
1002 引擎忙 稍后重试,或者排队等待
1003 文本过长 分段朗读
1004 不支持的语言 提示用户,降级到默认语言
1005 权限不足 引导用户开启权限
9999 未知错误 降级到 demo mode

14.5 内存泄漏

TTS 服务中最容易出现内存泄漏的地方:

  1. 回调引用页面:currentCallback 持有页面引用,页面销毁了没清空

    • 解决:页面 aboutToDisappear 时 stop,回调中不要持有页面强引用
  2. 定时器没清理:demo mode 用了 setTimeout,stop 的时候没清掉

    • 解决:记录 timerId,stop 时 clearTimeout
  3. 监听器没移除:如果用了观察者模式,页面销毁了没移除监听

    • 解决:页面 onDisappear 时 removeListener

十五、性能优化与最佳实践

15.1 初始化时机优化

不要在应用启动的同步流程里初始化 TTS,会拖慢启动速度。

typescript 复制代码
// ❌ 不好:阻塞启动
async onCreate() {
  await TTSEngineService.getInstance().init(); // 等它完成
}

// ✅ 好:后台异步初始化
onCreate() {
  TTSEngineService.getInstance().init(); // 不用 await
}

15.2 预加载策略

如果知道用户接下来可能要用到 TTS,可以提前初始化好。

比如:

  • 进入民族列表页时,预初始化 TTS
  • 用户滑动到某张卡片时,预加载对应的文本(如果是长文本)

但不要过度预加载------用不上反而浪费资源。

15.3 避免频繁创建销毁引擎

引擎创建是有成本的(时间、资源),不要频繁创建销毁。

不好的做法 :每次进入详情页都创建引擎,离开就销毁

好的做法:应用启动时创建一次,全局共用,退出时再销毁

15.4 日志规范

TTS 服务的日志要打全、打好,出问题时好排查。

typescript 复制代码
// ✅ 好的日志:有 requestId、有状态、有错误详情
hilog.info(DOMAIN, 'TTSService',
  `speak start: requestId=${requestId}, textLen=${text.length}`);

// ❌ 不好的日志:信息太少
hilog.info(DOMAIN, 'TTSService', 'speak called');

关键日志点:

  • 引擎初始化(成功/失败、耗时)
  • 每次 speak(requestId、文本长度、配置参数)
  • 状态变化(IDLE → SPEAKING → IDLE)
  • 错误发生(错误码、错误信息、requestId)
  • 降级触发(什么原因降级的)

十六、小结

技术点 核心要点 难度
TTS 原理 文本分析 → 韵律生成 → 语音合成 ⭐⭐⭐
系统能力 Core Speech Kit 的 textToSpeech 引擎 ⭐⭐⭐
单例模式 全局唯一 TTS 实例,统一管理 ⭐⭐
状态机 IDLE/SPEAKING/ERROR 三状态流转 ⭐⭐⭐
回调封装 onStart/onComplete/onStop/onError ⭐⭐⭐
配置管理 language/speed/pitch/volume + Partial ⭐⭐
长文本朗读 分段策略、断点续播、高亮跟随 ⭐⭐⭐⭐
多语言支持 中英切换、自动语言检测 ⭐⭐⭐
降级策略 Demo Mode 优雅降级,行为一致 ⭐⭐⭐⭐
资源释放 shutdown 释放引擎资源 ⭐⭐
设计模式 单例/外观/状态/策略 多种模式结合 ⭐⭐⭐⭐

TTSEngineService 是一个"麻雀虽小,五脏俱全"的服务------代码量不算大,但包含了服务层设计的很多核心思想:单例、状态管理、事件回调、优雅降级、资源释放...

把这个服务吃透,你就掌握了 HarmonyOS 服务封装的大半武功。

下一篇我们继续看 MusicService,看看音乐播放服务又有哪些设计巧思。

相关推荐
花开彼岸天~2 小时前
03 module.json5 配置文件全解:能力声明、权限申请与设备支持
华为·harmonyos·鸿蒙系统
ZZZMMM.zip3 小时前
基于鸿蒙HarmonyOS NEXT开发AI股票分析应用:智能投资新体验与鸿蒙Flutter框架跨端实践
人工智能·flutter·华为·harmonyos·鸿蒙
花开彼岸天~4 小时前
04 oh-package.json5 与依赖管理:ohpm 包管理器全解析
华为·harmonyos·鸿蒙系统
爸爸6196 小时前
06 项目目录结构与编码规范:三层架构与 DAO 模式实践
华为·架构·harmonyos·鸿蒙系统
不才难以繁此生7 小时前
HarmonyOS 端侧应用实战:中式美食如何把资源、路由、存储和视频拆菜串成一条工程链路
harmonyos·arkts·arkui·中式美食·端侧应用
爸爸6197 小时前
07 ArkTS 基础类型与接口定义:从 TypeScript 到鸿蒙的类型升级
javascript·华为·typescript·harmonyos·鸿蒙系统
绝世番茄7 小时前
鸿蒙 HarmonyOS ArkTS 音乐播放器界面布局深度学习指南
深度学习·华为·list·harmonyos·鸿蒙
不才难以繁此生8 小时前
HarmonyOS 实战|中式美食视频拆菜编辑面板:因子状态、草稿卡片与底部操作条
harmonyos·arkts·中式美食·arkui v2·视频拆菜
在书中成长8 小时前
HarmonyOS 小游戏《对战五子棋》开发第18篇 - 棋盘设计
算法·harmonyos