【三国志 App 实战系列 09】HarmonyOS TTS 卡顿跳段排查:Core Speech Kit speak/stop 并发控制实战
# 【三国志 App 实战系列 09】TTS 并发控制与卡顿跳段问题复盘:一次 HarmonyOS 真机调试经验
系列第 9 篇。本文复盘项目中最棘手的一类问题:退后台后听书卡顿、跳段、重复读字,根因是 TTS 引擎 speak/stop 重入与异步回调时序。

一、问题现象
真机测试中出现过这些现象:
- 退后台后朗读卡顿
- 段落之间跳过
- 某几个字重复读
- 拖动进度后状态文本闪烁
- 暂停后恢复位置不稳定
这些问题表面像“定时器不准”,但根因往往是 TTS 引擎状态机被打乱。
如果把问题拆开看,会发现它不是单一故障,而是几个异步事件撞在了一起:
- 用户刚点了暂停,
stop()还没完全落地 - 新一段文字已经开始准备
speak() - 旧请求的
onStop和新请求的onStart先后抵达 - 页面状态、进度条状态和系统媒体状态同时尝试刷新
结果就是:日志看起来每个回调都“正常触发”,但组合到一起后,用户听到的却是跳段、重复字和卡顿。
这也是为什么这类问题在 CSDN 技术文里很值得展开。真正难的不是会不会调用 Core Speech Kit,而是如何把异步 TTS 行为约束成可预测的播放状态机。
二、为什么这个问题只在真机上更明显
模拟器里也能看到一些状态闪烁,但真机上问题更典型,主要因为真机会多出几类干扰:
- 切后台时系统调度更积极
- 锁屏和系统媒体控制会引入额外状态变更
- 设备负载波动会放大
stop -> onStop -> speak的时间差 - TTS 引擎释放资源不是严格同步完成
也就是说,桌面上看起来只是“偶尔重复播一个词”,到了真机就会变成可感知的听书体验问题。
2.1 这类问题的最小复现路径
项目里最容易复现问题的是下面这组操作:
1. 进入听书页开始播放 2. 快速点两次暂停/继续 3. 在一段朗读未结束时拖动进度 4. 立即切换到另一篇人物内容 5. 再从后台返回恢复播放
如果实现里没有把 stop()、speak()、onStop()、onComplete() 串行化,基本都会在这条路径上暴露出错乱状态。
三、错误模式:stop 后立刻 speak
很多人会写:
if (this.ttsEngine.isBusy()) {
this.ttsEngine.stop();
}
this.ttsEngine.speak(text, params);
问题是 stop() 并不是同步完成,它会异步触发 onStop。如果 onStop 没回来就 speak(),旧请求和新请求会叠加,出现卡带、重复或跳段。
这里最危险的不是“偶尔多调了一次 API”,而是你会得到一个逻辑上已经切到新段落、引擎层却还保留旧段上下文的中间态。用户层面看到的现象通常有三种:
- 进度条已经走到下一段,但耳朵里还是上一段的尾音
- 新段开始后旧段
onStop才回来,把状态误改成暂停 - 连续两段文本都被投喂给引擎,出现重复读字
四、增加 in-flight 标志
private ttsSpeakInFlight: boolean = false;
调用 speak 前置 true:
this.ttsSpeakInFlight = true;
this.ttsEngine.speak(segment.text, this.ttsSpeakParams(requestId));
任一回调清除:
onStart: (requestId: string) => {
this.ttsSpeakInFlight = false;
},
onComplete: (requestId: string) => {
this.ttsSpeakInFlight = false;
},
onStop: (requestId: string) => {
this.ttsSpeakInFlight = false;
},
onError: (requestId: string) => {
this.ttsSpeakInFlight = false;
}
这个标志不是为了“好看”,而是为了把 TTS 引擎当成一个一次只能安全接收一个关键命令的异步资源。只要还有一次 speak() 处于 in-flight,就不应该让新的播放动作直接落到引擎上。
4.1 哪些回调必须统一清理 in-flight
最容易遗漏的是异常分支。很多实现只在 onStart 或 onComplete 里清理标志,但在真机上最常出现的恰恰是 onStop 和 onError。
更稳的做法是把清理逻辑收成一个统一方法:
private finishTtsRequest(requestId: string): void {
if (requestId !== this.currentTtsRequestId) {
return;
}
this.ttsSpeakInFlight = false;
}
然后在所有回调里都走它:
onStart: (requestId: string) => this.finishTtsRequest(requestId),
onComplete: (requestId: string) => this.finishTtsRequest(requestId),
onStop: (requestId: string) => this.finishTtsRequest(requestId),
onError: (requestId: string) => this.finishTtsRequest(requestId)
这样能避免某条异常分支漏清理,导致页面永远认为“引擎还在忙”。
五、busy 时延迟重试,而不是强停
private scheduleCurrentAudioSegment(force: boolean, delayMs: number): void {
const token: number = ++this.delayedSegmentToken;
setTimeout(() => {
if (token !== this.delayedSegmentToken) {
return;
}
if (this.ttsSpeakInFlight || this.ttsEngine?.isBusy()) {
this.scheduleCurrentAudioSegment(force, 600);
return;
}
this.speakCurrentAudioSegment(force);
}, delayMs);
}
核心思想:让回调驱动状态清理,不要用 stop 强行打断。
5.1 为什么“强停再播”是放大器
不少项目的第一反应是:既然 TTS 忙,就先 stop() 再 speak()。这在同步播放器里可能没问题,但 TTS 是异步引擎,这种写法反而会放大问题:
- 旧请求还没退干净,新的
speak()已经提交 - 旧请求的
onStop晚到,把新状态打回去 - 高频点击下连续触发多次 stop,回调顺序更乱
所以“busy 就停掉重来”并不是修复方案,很多时候它本身就是 bug 来源。
六、区分主动 stop 和异常 stop
拖动进度、切换音频、用户暂停都会主动调用 stop。此时 onStop 不应该被识别成“被其他音频中断”。
private ttsStopExpected: boolean = false;
private stopTextToSpeech(): void {
this.ttsStopExpected = true;
this.ttsEngine?.stop();
}
回调中消费:
onStop: (requestId: string) => {
this.ttsSpeakInFlight = false;
if (this.ttsStopExpected) {
this.ttsStopExpected = false;
return;
}
this.handleUnexpectedTtsStop(requestId);
}
这样就解决了“朗读中 → 被其他音频暂停 → 朗读中”的文本闪烁。
6.1 UI 状态为什么会闪烁
如果没有这层“主动 stop”标记,onStop 到来时页面通常会以为发生了异常打断,于是会做这些事:
- 把按钮切回“播放”
- 把状态文案改成“已暂停”或“等待恢复”
- 触发一次恢复逻辑
但用户实际上只是拖动进度或切换内容,这样的 UI 反馈就是错的。状态抖动本质上来自事件语义不清,不是来自绘制层。
七、requestId 过滤过期回调
每段 TTS 都必须有唯一 requestId:
const requestId: string = this.selectedAudioId + '_' + Date.now().toString();
this.currentTtsRequestId = requestId;
回调第一步过滤:
if (requestId !== this.currentTtsRequestId) {
return;
}
否则旧段回调可能误改新段状态。
7.1 只过滤 start 不够,stop/complete 也要过滤
一个常见漏点是:开发者只在 onStart 里校验 requestId,但 onStop、onComplete 里没校验。这样旧请求虽然不会重新开始播,但仍然可能把新请求的状态误改掉。
更稳的模板应该是:
private isActiveRequest(requestId: string): boolean {
return requestId === this.currentTtsRequestId;
}
onComplete: (requestId: string) => {
if (!this.isActiveRequest(requestId)) {
return;
}
this.playNextSegment();
}
这样旧回调最多被安静丢弃,不会污染当前播放队列。
八、把播放动作收敛成状态机
TTS 并发问题最后都绕不开一个结论:不能让每个按钮直接碰引擎。页面真正应该操作的是状态机。
type TtsState =
| 'idle'
| 'starting'
| 'playing'
| 'pausing'
| 'stopping'
| 'recovering'
| 'error';
当你有了这个状态层后,play()、pause()、seek()、switchArticle() 都先改状态,再由状态决定能否真正调用引擎。
8.1 状态机带来的直接收益
- 用户连续点击时,不会每次都直接触发一次底层
speak/stop - 可以显式区分“正在停止”和“已经停止”
- 后台恢复时,可以先进入
recovering,而不是直接把按钮切到playing - 日志排查时更容易从状态转移图看出错序

九、调试命令与日志设计
这类问题如果只靠耳朵听,很难确定到底是哪一层乱了。建议把以下命令直接作为排查入口写进文章:
hdc list targets
hdc shell hilog | Select-String -Pattern "TTS|CoreSpeech|AudioPage|AVSession"
hdc shell aa force-stop com.example.recordofthreekingdoms
hdc shell aa start -a EntryAbility -b com.example.recordofthreekingdoms
如果要验证是不是旧回调污染了新请求,日志至少要带这些字段:
hilog.info(0x0000, 'TTS', 'request=%{public}s state=%{public}s index=%{public}d expectedStop=%{public}s',
requestId,
this.runtime.state,
this.currentSegmentIndex,
String(this.ttsStopExpected));
9.1 重点看哪几类日志
speak()发起时的 requestId 和段索引onStop回调返回时对应的 requestIdonComplete是否仍指向当前激活请求- 用户点击暂停/拖动进度时,
ttsStopExpected是否先被置位
只要这几类日志连起来,就能很快判断问题是“旧回调串进来”还是“新请求发早了”。
十、常见误修复方式
项目里最容易把问题越修越大的做法,基本有下面几种:
| 误修复 | 结果 | 为什么不稳 |
|---|---|---|
| busy 就先 stop 再 speak | 更容易重复播 | stop 是异步,不是同步清场 |
| 只在 onStart 清理标志 | 页面长期卡在 busy | onStop/onError 也会成为结束点 |
| 不带 requestId 过滤 | 旧回调污染新队列 | 回调没有“归属判断” |
| 按钮直接调引擎 | 高频点击时失控 | 缺少状态机缓冲层 |
这张表很有必要写出来,因为很多读者第一反应都会踩这些坑。
十一、小结
TTS 并发问题最麻烦的地方在于:它不是每次都复现,往往只在快速点击、切换文章、后台恢复时出现。解决这类问题,需要把播放动作收敛成状态机,而不是让每个按钮直接调用 speak() 或 stop()。
type TtsState = 'idle' | 'starting' | 'playing' | 'pausing' | 'stopping' | 'error';
interface TtsRuntime {
state: TtsState;
requestId: string;
pendingText?: string;
}
按钮点击后先判断当前状态:
private async play(text: string) {
if (this.runtime.state === 'starting' || this.runtime.state === 'stopping') {
this.runtime.pendingText = text;
return;
}
const requestId = this.selectedAudioId + '_' + Date.now().toString();
this.runtime = { state: 'starting', requestId };
await this.ttsEngine?.speak(text, this.ttsSpeakParams(requestId));
}
调试 TTS 并发时,日志必须带上 requestId、状态和段索引:
hilog.info(0x0000, 'TTS', 'request=%{public}s state=%{public}s index=%{public}d',
this.runtime.requestId,
this.runtime.state,
this.currentSegmentIndex);
真正决定稳定性的,不是某个单独 API,而是你有没有把 stop -> 回调 -> 新 speak 这条链路视作一个完整的异步事务。只要事务边界没定义清楚,听书体验就一定会抖。
十二、工程实现与验收清单
| 用例 | 目的 |
|---|---|
| 连续点击播放/暂停 10 次 | 检查 in-flight 标志是否有效 |
| 播放中切换人物 | 检查旧回调是否污染新队列 |
| 后台返回后继续播放 | 检查状态是否恢复 |
| stop 后立即 speak | 检查是否有跳段 |
| 播放异常回调 | 检查 UI 是否能回到 idle |
12.1 发布前我会额外核对什么
- 进度拖动后,状态文案不会在“暂停/播放中”之间来回闪
- 快速切换两篇人物内容时,只会保留一个活跃 requestId
- 后台恢复后不会出现上一段尾字重新插播
- 真机高频点击下,日志里不会出现旧 requestId 改写新状态
- 代码块、调试命令、误修复复盘和验收表都足够支撑工程类文章阅读
TTS 长文本播放不是简单调用 speak()。真正稳定的实现,需要把引擎当成一个异步状态机:串行化、过滤过期回调、主动 stop 标记、延迟恢复。下一篇会讲 UI 进度条同步和 ForEach 刷新抖动。
更多推荐


所有评论(0)