
一、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(语音识别)、声纹识别等多种能力。
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 |
民族图鉴的选择策略:
// 优先使用在线引擎(音质好)
// 如果网络不可用或初始化失败,再考虑降级
const initParams: textToSpeech.CreateEngineParams = {
language: 'zh-CN',
person: 0,
online: 1, // 优先在线
extraParams: {
'style': 'interaction-broadcast',
'locate': 'CN',
'name': 'EthnicTTS'
}
};
💡 最佳实践:对于知识类、内容类应用,优先用在线引擎保证音质。同时要有降级方案,网络不好时也能用。对于工具类、指令类应用,离线引擎可能更合适(响应快、不需要网络)。
3.4 创建引擎详解
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 必须用单例。
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 必须单例?
-
引擎资源有限:创建多个 TTS 引擎实例浪费系统资源
- 每个引擎实例都要占用内存
- 在线引擎还要维护网络连接
- 系统可能限制同时存在的引擎数量
-
状态统一管理:全局只有一个播放状态,不会"多个声音一起响"
- 详情页在朗读,用户点了列表页的另一个朗读按钮怎么办?
- 如果是两个实例,两个声音会叠在一起
- 单例模式下,新请求会被防重入逻辑挡住
-
配置统一:语速、音量、音色等设置全局一致
- 用户在设置页改了语速,所有页面都生效
- 不用每个页面都同步配置
-
音频焦点统一管理:后面加音乐播放时,统一处理音频焦点
- TTS 和音乐不能同时响
- 单例模式下,服务层可以统一协调
⚠️ 注意:ArkTS 中的单例和纯 JS/TS 的单例有一点不同——ArkTS 对静态成员的支持有一些限制,需要确保单例实现符合 ArkTS 的语法规范。上面的写法是经过验证的,可以放心使用。
4.2 初始化时机与流程
TTS 引擎什么时候初始化?这是一个重要的架构决策。
可选方案对比:
| 方案 | 说明 | 优点 | 缺点 |
|---|---|---|---|
| 应用启动时初始化 | EntryAbility.onCreate 中 init | 使用时已经就绪,响应快 | 增加启动时间,可能用不上 |
| 首次使用时初始化 | 第一次调用 speak 时才 init | 不影响启动速度 | 首次使用有延迟 |
| 预加载(空闲时) | 启动后空闲时间后台初始化 | 兼顾启动速度和使用体验 | 实现复杂 |
民族图鉴的选择:应用启动时初始化
理由:
- TTS 是重要功能,用户很可能会用到
- 启动时初始化,进入详情页就能直接用,体验更好
- 初始化是异步的,不会阻塞主线程太久
// 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 初始化方法详解
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; // 标记为就绪(降级模式)
}
}
关键点:
- 不抛异常:初始化失败不会抛出,应用不会因为 TTS 用不了就崩了
- 自动降级:失败了进入 demo mode(模拟模式),功能还能"假装"工作
- 都标记 ready:不管成功失败,
isEngineReady都设为 true,上层不用区分
🎯 设计思想:优雅降级(Graceful Degradation)——核心功能(民族图鉴浏览)不受影响,TTS 只是锦上添花的增强功能。用不了就用不了,不能让它拖垮整个应用。
4.4 回调转 Promise
原生 createEngine 是回调风格,我们包装成 Promise:
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?
- 代码更简洁:async/await 比回调嵌套好读太多了
- 错误处理更统一:try-catch 统一捕获,不用每个回调都判断 err
- 便于组合:Promise 可以用 Promise.all、Promise.race 等组合
- 符合现代 JS/TS 开发习惯:现在大家都习惯用 async/await 了
📝 ArkTS 注意事项:ArkTS 对 Promise 的支持是比较完善的,async/await 都可以正常使用。但是要注意,不要在 UI 线程做太重的异步操作,避免卡顿。
五、状态管理与状态机
5.1 状态枚举
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?
- 降级模式需要:demo mode 下没有真实引擎,状态要自己管
- 访问同步:
getState()是同步方法,不用等引擎回调 - 防重入控制:在 speak() 开头直接判断状态,不用问引擎
- 统一抽象:真实模式和降级模式对外表现一致
- 状态可预测:自己维护状态机,状态流转是确定的,不会出现"引擎说在播但实际没声音"这种尴尬情况
- 便于测试:单元测试时可以直接设置状态,不用依赖真实引擎
5.3 防重入
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 监听器设置
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 回调设计
export interface TTSCallback {
onStart?: () => void;
onComplete?: () => void;
onStop?: () => void;
onError?: (code: number, message: string) => void;
}
为什么用回调而不是 Promise?
因为 TTS 播放是一个持续过程,有多个时间点:
- 开始播放(onStart)
- 播放完成(onComplete)
- 被停止(onStop)
- 出错了(onError)
Promise 只能表示"成功/失败"一次,没法表示"开始了… 然后完成了"这样的过程。
使用方式:
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 状态,就不够用了。
更灵活的方案:观察者模式(监听者模式)
// 多个观察者可以同时监听
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 可选链的妙用
this.currentCallback?.onStart?.();
这一行有两个可选链 ?.:
currentCallback?.onStart:如果 currentCallback 是 null/undefined,什么都不做?.():如果 onStart 是 undefined(用户没传),什么都不做
好处是——用户传不传回调、传哪几个回调,都不用写 if 判断,直接调用就行。
七、配置管理:语速、音调、音量
7.1 配置接口详解
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 默认配置
private config: TTSConfig = {
language: 'zh-CN',
speed: 1.0,
pitch: 1.0,
volume: 10
};
为什么这样设置默认值?
- 语言默认中文:因为应用主要面向中文用户
- 语速默认 1.0:标准语速,用户可以自己调
- 音调默认 1.0:最自然的音调
- 音量默认 10:中等偏上,保证能听清
7.3 更新配置
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"。这样调用方可以只传要改的字段:
// 只改语速
ttsService.updateConfig({ speed: 1.5 });
// 只改语言
ttsService.updateConfig({ language: 'en-US' });
// 三个都改
ttsService.updateConfig({ language: 'zh-CN', speed: 1.2, volume: 8 });
不用为每种组合写一个方法,一个方法搞定。
7.4 语言切换与引擎重建
重要:语言变了之后,需要重建 TTS 引擎,因为引擎是绑定语言的。
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;
}
}
重建流程:
- 停止当前播放(如果正在播)
- 销毁旧引擎
- 创建新引擎(用新的语言配置)
- 失败则降级到 demo mode
⚠️ 注意:重建引擎是异步操作,期间调用 speak 可能会有问题。可以加个"正在重建"的状态,期间的请求要么排队要么走降级模式。
7.5 配置生效时机
注意:updateConfig 只是改了内存里的 this.config,下一次 speak() 的时候才会生效。当前正在播放的不会被打断。
这是合理的设计——用户在设置页调整语速,不用立即打断正在听的内容,下次朗读时自动用新语速。
那如果想立即生效呢?
可以提供一个 applyConfigImmediately 方法,改完配置后停止当前播放,重新开始。但一般不建议这样做,体验不好。
八、核心方法:speak 与 stop
8.1 speak 方法详解
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 方法
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
private generateRequestId(): string {
return `tts_${Date.now()}_${Math.random().toString(36).substring(2, 8)}`;
}
每个朗读请求都有一个唯一 ID,用于日志追踪和问题排查。
格式:tts_时间戳_随机字符串,既保证唯一性,又能从 ID 看出请求时间。
为什么需要请求 ID?
- 日志追踪:出问题时可以根据 requestId 找到对应的日志
- 多请求区分:如果同时有多个请求(虽然我们防重入了),可以区分是哪个
- 链路追踪:和后端的 traceId 对应起来,全链路排查问题
九、长文本朗读的实现方案
民族图鉴中,有些民族的介绍文字可能比较长(几千字),而 TTS 引擎对单次朗读的文本长度是有限制的(一般是几千到几万字符)。这时候就需要分段朗读。
9.1 为什么需要长文本支持
| 问题 | 说明 |
|---|---|
| 长度限制 | 单次朗读文本过长,引擎可能报错或截断 |
| 进度丢失 | 用户听到一半退出了,下次进来要从头开始 |
| 无法定位 | 用户想从某一段开始听,只能从头等 |
| 没有高亮 | 听的时候不知道读到哪儿了 |
| 暂停/继续 | 原生 API 可能不支持暂停,或者支持不好 |
9.2 分段策略
长文本朗读的第一步是把长文本切成合适的小段。
分段原则:
- 按句子分:优先在句号、问号、感叹号处切分
- 按段落分:段落之间自然停顿,适合作为分段点
- 长度适中:每段 50-200 字,太长了不好控制,太短了切换频繁
- 避免中间切断:不要在一个句子中间切断
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 断点续播
用户听到一半退出了,下次进来可以从上次的位置继续听。
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 高亮跟随
听的时候,正在读的那段文字高亮显示,让用户知道读到哪儿了。
// 页面中使用
@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 引擎也要跟着切换。
// 在 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 降级模式实现
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);
}
降级模式的设计目标:
- 对外表现一致:同样有 onStart、onComplete、onStop 回调
- 状态流转一致:同样有 IDLE → SPEAKING → IDLE 的状态变化
- 时间合理:根据文本长度估算时长,不是固定值
- 可被停止:调用 stop() 能提前结束,和真实引擎行为一致
- 可检测:提供
isDemoModeActive()方法,上层可以判断并提示用户
为什么要做得这么"真"?
因为页面层不需要区分"真实模式"还是"降级模式"。如果降级模式行为和真实模式不一样,页面就得写两套逻辑——那就失去了封装的意义。
🎯 架构思想:面向接口编程——不管底层是真实 TTS 还是模拟的,对外的接口(方法签名、回调、状态)完全一致。上层代码只依赖抽象,不依赖具体实现。
11.4 降级模式的使用场景
| 场景 | 用途 |
|---|---|
| 开发调试 | 模拟器上也能测 UI 交互(播放按钮状态变化等) |
| 自动化测试 | 测试用例里不用依赖真实 TTS 引擎 |
| 设备不支持 | 低端设备用不了 TTS,至少 UI 是正常的 |
| 演示环境 | 没有语音能力的环境也能完整演示功能 |
| 单元测试 | mock TTS 服务,专注测试业务逻辑 |
11.5 用户提示
降级模式虽然能保证功能正常,但用户可能会困惑"怎么没声音?"。所以需要适当提示用户。
// 页面中使用
if (ttsService.isDemoModeActive()) {
// 提示用户当前为模拟模式
showToast('当前设备不支持语音朗读,使用模拟模式');
}
提示策略:
- 不要一进来就弹窗提示,太打扰
- 用户第一次点击朗读按钮时,用 toast 轻量提示
- 设置页可以显示"TTS 状态:可用/降级"
- 提供"如何开启 TTS"的帮助说明
十二、资源释放与生命周期
12.1 shutdown 方法
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 页面联动的最佳实践
// 详情页中的朗读按钮
@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 服务中最容易出现内存泄漏的地方:
-
回调引用页面:currentCallback 持有页面引用,页面销毁了没清空
- 解决:页面 aboutToDisappear 时 stop,回调中不要持有页面强引用
-
定时器没清理:demo mode 用了 setTimeout,stop 的时候没清掉
- 解决:记录 timerId,stop 时 clearTimeout
-
监听器没移除:如果用了观察者模式,页面销毁了没移除监听
- 解决:页面 onDisappear 时 removeListener
十五、性能优化与最佳实践
15.1 初始化时机优化
不要在应用启动的同步流程里初始化 TTS,会拖慢启动速度。
// ❌ 不好:阻塞启动
async onCreate() {
await TTSEngineService.getInstance().init(); // 等它完成
}
// ✅ 好:后台异步初始化
onCreate() {
TTSEngineService.getInstance().init(); // 不用 await
}
15.2 预加载策略
如果知道用户接下来可能要用到 TTS,可以提前初始化好。
比如:
- 进入民族列表页时,预初始化 TTS
- 用户滑动到某张卡片时,预加载对应的文本(如果是长文本)
但不要过度预加载——用不上反而浪费资源。
15.3 避免频繁创建销毁引擎
引擎创建是有成本的(时间、资源),不要频繁创建销毁。
不好的做法:每次进入详情页都创建引擎,离开就销毁
好的做法:应用启动时创建一次,全局共用,退出时再销毁
15.4 日志规范
TTS 服务的日志要打全、打好,出问题时好排查。
// ✅ 好的日志:有 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,看看音乐播放服务又有哪些设计巧思。
116

被折叠的 条评论
为什么被折叠?



