# 【三国志 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

最容易遗漏的是异常分支。很多实现只在 onStartonComplete 里清理标志,但在真机上最常出现的恰恰是 onStoponError

更稳的做法是把清理逻辑收成一个统一方法:

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,但 onStoponComplete 里没校验。这样旧请求虽然不会重新开始播,但仍然可能把新请求的状态误改掉。

更稳的模板应该是:

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 回调返回时对应的 requestId
  • onComplete 是否仍指向当前激活请求
  • 用户点击暂停/拖动进度时,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 刷新抖动。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐