给阅读页加朗读功能时,最容易低估的不是语音合成 API,而是异步事件。用户点击“朗读”,引擎还在初始化;紧接着又点击“停止”或切换文章,旧的延迟任务、onStart、onComplete、onStop仍可能稍后到达。如果服务只保存一个isSpeaking布尔值,页面看起来像在工作,实际却可能出现重复播放、段落跳两次、停止后重新开口或返回列表仍继续发声。
本文基于化学实验室 1.0.5的真实 ArkTS 源码,拆解ReadingSpeechService.ets、ReadingDetailPage.ets与ReadingArticle.ets。当前服务通过@kit.CoreSpeechKit创建TextToSpeechEngine,支持按文章章节播放、手动下一段、停止、状态监听和错误提示。它已经使用activeRequestId过滤部分旧完成回调,也用stopReason区分手动停止、切段与播放完成,这些都是正确方向。
源码仍有几个真实竞争窗口:ensureEngine()没有初始化中的共享任务,快速点击可能重复创建引擎;每次播放前使用 120mssetTimeout(),但定时器没有会话身份;快速从文章 A 切到文章 B 时,A 的延迟任务可能读取已经被替换成 B 的静态字段并再次调用speak()。页面退出只执行setListener(undefined),没有停止引擎,也没有全局播放器接管。创建引擎配置为online: 1,失败文案却写“当前设备不支持离线朗读”,排查信息与实际配置不一致。
更稳的设计原则是:语音朗读不是把文本交给引擎就结束,而是用会话令牌把页面、文章、段落和异步回调绑定在同一条生命周期上。按钮发出命令,控制器维护状态机,会话守卫拒绝旧任务,系统引擎只负责执行speak/stop与回调。
本文唯一校验标记:CHEM-LAB-ONE12-TTS-SESSION-LIFECYCLE-20260730
本文会完成以下工程拆解:
- 还原当前 TTS 服务从初始化到切段的真实调用顺序。
- 把多个布尔值整理成可验证的播放阶段与状态快照。
- 用单例初始化任务避免并发创建引擎。
- 用
sessionId + requestId隔离旧定时器和旧回调。 - 明确手动停止、切段、文章切换、完成和错误五类收口。
- 让页面返回策略、按钮禁用与错误提示保持一致。
- 增加化学文本预处理、测试替身和故障排查矩阵。
一、先还原当前服务的状态字段
现有服务使用全静态字段保存单一播放会话:
private static engine?: textToSpeech.TextToSpeechEngine private static article?: ReadingArticle private static isSpeaking: boolean = false private static currentSectionIndex: number = -1 private static speechStatus: string = '点击朗读,播放当前文章' private static listener?: ReadingSpeechStateListener private static activeRequestId: string = '' private static stopReason: string = ''这些字段覆盖了引擎、内容、视图状态和回调隔离,但字段之间没有一个统一合法性约束。例如isSpeaking === true时,理论上必须同时满足article存在、currentSectionIndex >= 0、引擎已创建;当前代码只能依靠每个方法自觉维护。
把状态关系显式化后,才能回答“初始化中能不能再点”“停止中收到完成回调怎么办”“错误后下一段按钮是否可用”等问题。
二、一次正常朗读的真实执行链路
页面点击“朗读”后调用:
private async startSpeech(): Promise<void> { await ReadingSpeechService.startArticle(this.article) }服务依次执行:
ensureEngine()创建或复用引擎。- 以
switch原因停止旧播放。 - 保存新文章,将章节索引设为 0。
- 把状态改为“正在朗读”并通知页面。
- 延迟 120ms 调用
speakCurrentSection()。 - 引擎完成当前段后通过
onComplete推进下一段。
这条链路在单次点击下能够运行。真正的问题出现在两条链路重叠:引擎创建、停止事件、延迟任务和新请求都不是同步完成,单个isSpeaking无法标识它们属于哪一次用户操作。
三、把布尔值升级为播放阶段
可以先定义有限阶段,不急着引入大型状态机框架:
export type SpeechPhase = | 'idle' | 'preparing' | 'speaking' | 'stopping' | 'completed' | 'error' export interface SpeechSnapshot { phase: SpeechPhase articleId: string sectionIndex: number sectionCount: number sessionId: number requestId: string message: string errorCode?: number }页面不再分别读取isSpeaking、章节索引和文字状态,而是订阅一个不可变快照。preparing时禁用重复启动,speaking时允许“停止”和“下一段”,error时显示重试入口。
四、用统一提交函数保证状态字段一起变化
状态更新集中在一个方法中:
private static snapshot: SpeechSnapshot = { phase: 'idle', articleId: '', sectionIndex: -1, sectionCount: 0, sessionId: 0, requestId: '', message: '点击朗读,播放当前文章' } private static commit(next: SpeechSnapshot): void { ReadingSpeechController.snapshot = next ReadingSpeechController.listener?.(next) }如果 ArkTS 项目不希望每次手写完整对象,可以提供受控的updatePhase(),但不要允许页面直接修改内部字段。状态快照是控制器对 UI 的唯一输出契约。
五、引擎初始化必须共享同一个进行中任务
当前ensureEngine()只检查engine是否存在。两次快速点击都可能在引擎尚未赋值时进入createEngine()。可以保存初始化 Promise:
private static engine?: textToSpeech.TextToSpeechEngine private static engineTask?: Promise<textToSpeech.TextToSpeechEngine> private static async ensureEngine(): Promise<textToSpeech.TextToSpeechEngine> { if (ReadingSpeechController.engine) { return ReadingSpeechController.engine } if (ReadingSpeechController.engineTask) { return ReadingSpeechController.engineTask } ReadingSpeechController.engineTask = textToSpeech.createEngine({ language: 'zh-CN', person: 0, online: 1 }) try { const engine = await ReadingSpeechController.engineTask ReadingSpeechController.engine = engine ReadingSpeechController.bindEngineEvents(engine) return engine } finally { ReadingSpeechController.engineTask = undefined } }无论多少页面命令同时到达,引擎只创建一次。失败后清空engineTask,后续重试才会创建新任务。
六、会话令牌负责取消旧意图
每次开始新文章、手动停止或页面销毁,都递增会话编号:
private static sessionSeed: number = 0 private static nextSession(): number { ReadingSpeechController.sessionSeed += 1 return ReadingSpeechController.sessionSeed } private static isCurrent(sessionId: number): boolean { return ReadingSpeechController.snapshot.sessionId === sessionId }会话令牌不是安全凭证,只是本地递增身份。旧定时器、旧异步初始化和旧引擎事件在写状态前都要检查令牌。这样“停止”不必真的撤销已经进入 JavaScript 事件队列的回调,只要让它们失去更新权限即可。
七、启动文章时先建立会话,再等待引擎
startArticle()应在await前写入新会话:
static async startArticle(article: ReadingArticle): Promise<void> { if (article.sections.length === 0) { ReadingSpeechController.fail('文章没有可朗读段落') return } const sessionId = ReadingSpeechController.nextSession() ReadingSpeechController.commit({ phase: 'preparing', articleId: article.id, sectionIndex: 0, sectionCount: article.sections.length, sessionId, requestId: '', message: '正在准备朗读' }) try { await ReadingSpeechController.ensureEngine() if (!ReadingSpeechController.isCurrent(sessionId)) return ReadingSpeechController.article = article ReadingSpeechController.speakSection(sessionId, 0) } catch (_) { if (!ReadingSpeechController.isCurrent(sessionId)) return ReadingSpeechController.fail('朗读引擎初始化失败') } }如果等待引擎期间用户点击停止,sessionId已变化,初始化完成后不会再启动声音。
八、120ms 延迟任务也必须携带 sessionId
原实现使用setTimeout()等待停止动作完成,但闭包里没有旧会话身份:
setTimeout(() => { ReadingSpeechService.speakCurrentSection() }, 120)改成携带参数并保存定时器:
private static pendingTimer: number = -1 private static scheduleSection(sessionId: number, index: number): void { if (ReadingSpeechController.pendingTimer >= 0) { clearTimeout(ReadingSpeechController.pendingTimer) } ReadingSpeechController.pendingTimer = setTimeout(() => { ReadingSpeechController.pendingTimer = -1 if (!ReadingSpeechController.isCurrent(sessionId)) return ReadingSpeechController.speakSection(sessionId, index) }, 120) }清理定时器可以减少无效任务,会话检查则是最后防线。两者都需要,不能只依赖clearTimeout()。
九、requestId 负责隔离同一会话中的段落请求
会话内还会连续朗读多个段落,因此每次speak()需要独立请求 ID:
private static requestSeed: number = 0 private static createRequestId(sessionId: number, sectionIndex: number): string { ReadingSpeechController.requestSeed += 1 return `reading_${sessionId}_${sectionIndex}_${ReadingSpeechController.requestSeed}` }相比只使用Date.now(),递增序号不会在同一毫秒内重复。onComplete(requestId)必须与快照中的当前请求匹配,才允许推进下一段。
十、提交 speak 前再次验证文章和段落
朗读入口需要检查引擎、会话、文章和索引:
private static speakSection(sessionId: number, index: number): void { const engine = ReadingSpeechController.engine const article = ReadingSpeechController.article if (!engine || !article || !ReadingSpeechController.isCurrent(sessionId)) return const section = article.sections[index] if (!section) { ReadingSpeechController.complete(sessionId) return } const requestId = ReadingSpeechController.createRequestId(sessionId, index) const text = ReadingSpeechController.buildSpeechText(article, index) ReadingSpeechController.commit({ phase: 'speaking', articleId: article.id, sectionIndex: index, sectionCount: article.sections.length, sessionId, requestId, message: `正在朗读第 ${index + 1} 段` }) engine.speak(text, { requestId }) }状态先进入speaking,再调用系统引擎。若speak()同步抛错,立即转入错误状态并清空当前请求。
十一、不要在每个章节重复朗读文章标题
当前buildSectionSpeechText()对每个章节都返回“文章标题 + 章节标题 + 正文”。一篇文章有三个章节,文章标题会被读三遍。可以只在首段加入文章标题:
private static buildSpeechText(article: ReadingArticle, index: number): string { const section = article.sections[index] if (!section) return '' if (index === 0) { return `${article.title}。${section.title}。${section.body}` } return `${section.title}。${section.body}` }如果用户手动从第二段开始朗读,可以由调用参数决定是否补读文章标题,而不是把重复行为写死在文本构建器里。
十二、化学文本需要独立的朗读文本
屏幕上适合显示H₂O、CO₂↑、PV = nRT,TTS 却可能逐字符或不自然地读出符号。不要修改展示正文,可以给章节增加可选朗读文本:
export interface ReadingSection { id: string title: string body: string speechText?: string } function sectionSpeech(section: ReadingSection): string { return section.speechText ?? section.body }例如屏幕显示“CO₂ 通入石灰水”,朗读文本可以写成“二氧化碳通入石灰水”。这种映射应由内容编辑者核对,不建议用全局字符串替换猜测所有化学式读法。
十三、onComplete 只处理当前请求
现有代码已经检查requestId,应保留并扩展为会话快照校验:
onComplete: (requestId: string, response: textToSpeech.CompleteResponse) => { const current = ReadingSpeechController.snapshot if (current.phase !== 'speaking') return if (requestId !== current.requestId) return if (!ReadingSpeechController.isCurrent(current.sessionId)) return if (response.type === 1) { ReadingSpeechController.moveToNext(current.sessionId) } }response.type的含义应集中在适配器内,不要散落到页面。未来 SDK 事件定义变化时,只需要修改引擎适配层。
十四、onStart 没有 requestId 时更要依赖当前阶段
当前onStart只判断activeRequestId非空,然后把状态设为播放中。若旧请求的开始事件晚到,它无法直接知道属于哪个 request。控制器至少要检查当前阶段和会话是否仍等待播放:
onStart: () => { const current = ReadingSpeechController.snapshot if (current.phase !== 'preparing' && current.phase !== 'speaking') return if (current.requestId.length === 0) return ReadingSpeechController.commit({ ...current, phase: 'speaking', message: `正在朗读第 ${current.sectionIndex + 1} 段` }) }最关键的仍是:旧会话在启动新会话时已经失效,不能再覆盖新页面状态。
十五、停止原因要使用受限类型
原实现把stopReason定义为任意字符串。可以收紧为:
type StopReason = 'manual' | 'switch_section' | 'switch_article' | 'complete' | 'page_leave' private static stopReason?: StopReason不同原因对应不同 UI 结果:
| 原因 | 最终阶段 | 页面提示 |
|---|---|---|
| manual | idle | 已停止朗读 |
| switch_section | preparing | 正在切换段落 |
| switch_article | preparing | 正在切换文章 |
| complete | completed | 朗读完成 |
| page_leave | idle | 不再向已离开页面通知 |
字符串受限后,onStop不会因为拼写错误进入错误分支。
十六、手动停止先让旧会话失效
停止命令应先递增会话,再停止引擎:
static stop(reason: StopReason = 'manual'): void { const sessionId = ReadingSpeechController.nextSession() if (ReadingSpeechController.pendingTimer >= 0) { clearTimeout(ReadingSpeechController.pendingTimer) ReadingSpeechController.pendingTimer = -1 } ReadingSpeechController.stopReason = reason try { ReadingSpeechController.engine?.stop() } catch (_) { } ReadingSpeechController.commit({ phase: reason === 'complete' ? 'completed' : 'idle', articleId: '', sectionIndex: -1, sectionCount: 0, sessionId, requestId: '', message: reason === 'complete' ? '朗读完成' : '已停止朗读' }) }即使engine.stop()失败,旧定时器和旧回调也因 sessionId 变化而不能恢复声音状态。
十七、下一段命令要串行化
当前“下一段”按钮只在isSpeaking时启用,但连续点击仍可能连续调用playNextSection()。快照状态应在第一次点击后立刻进入preparing,按钮随即禁用:
static nextSection(): void { const current = ReadingSpeechController.snapshot if (current.phase !== 'speaking') return const nextIndex = current.sectionIndex + 1 if (nextIndex >= current.sectionCount) { ReadingSpeechController.stop('complete') return } ReadingSpeechController.commit({ ...current, phase: 'preparing', requestId: '', message: '正在切换下一段' }) ReadingSpeechController.stopEngineAndSchedule(current.sessionId, nextIndex) }命令是否可执行由阶段决定,而不是由按钮视觉状态决定。即使自动化测试直接调用方法,也不会跳过两段。
十八、页面只订阅与当前文章匹配的快照
静态服务的setListener()会立即推送当前状态。如果服务正在朗读文章 A,页面 B 刚出现,B 可能错误高亮相同索引。快照包含articleId后,页面可以过滤:
private onSpeechState(snapshot: SpeechSnapshot): void { if (snapshot.articleId !== this.article.id && snapshot.phase !== 'idle') { this.speechPhase = 'idle' this.speakingSectionIndex = -1 this.speechStatus = '点击朗读,播放当前文章' return } this.speechPhase = snapshot.phase this.speakingSectionIndex = snapshot.sectionIndex this.speechStatus = snapshot.message }这样页面不会消费其他文章的播放位置。若产品支持全局后台朗读,则应显示全局播放器,而不是让任意详情页冒充当前播放页。
十九、页面返回必须选择明确策略
现有aboutToDisappear()只解除监听,服务可能继续播放。当前应用没有全局播放条,更一致的策略是离开详情页即停止:
aboutToDisappear(): void { ReadingSpeechController.stop('page_leave') ReadingSpeechController.setListener(undefined) }如果以后要支持后台听读,应增加通知栏或全局控制条、播放文章标题、停止入口和生命周期说明,再把页面退出策略改为“转交全局播放器”。不能只因为静态服务还活着,就把偶然的后台声音当成功能。
二十、在线配置与失败文案必须一致
源码创建引擎时使用online: 1,异常却提示“不支持离线朗读”。更准确的状态应区分初始化和运行阶段:
private static fail(message: string, errorCode?: number): void { const current = ReadingSpeechController.snapshot ReadingSpeechController.commit({ ...current, phase: 'error', requestId: '', message, errorCode }) }建议文案:
- 创建引擎失败:朗读引擎初始化失败,请检查设备能力与网络状态。
onError:朗读中断,错误码xxxx,可重新尝试。- 空文本:当前段落没有可朗读内容。
- 文章无章节:文章暂不支持朗读。
错误码保留给日志和诊断区域,不要只吞掉异常。
二十一、按钮状态应直接由 phase 推导
页面无需再维护一份容易漂移的isSpeaking:
private canStart(): boolean { return this.speechPhase === 'idle' || this.speechPhase === 'completed' || this.speechPhase === 'error' } private canStop(): boolean { return this.speechPhase === 'preparing' || this.speechPhase === 'speaking' } private canNext(): boolean { return this.speechPhase === 'speaking' }preparing阶段主按钮显示“准备中”,允许停止但不能再次启动;error阶段显示“重试朗读”;completed阶段显示“重新朗读”。状态文字单独占一行,避免错误信息被两个按钮挤压后省略。
二十二、监听器最好返回取消函数
单个setListener()适合当前单页面,但生命周期调用必须成对。可以把接口改成订阅形式:
export type SpeechUnsubscribe = () => void static subscribe(listener: SpeechStateListener): SpeechUnsubscribe { ReadingSpeechController.listener = listener listener(ReadingSpeechController.snapshot) return () => { if (ReadingSpeechController.listener === listener) { ReadingSpeechController.listener = undefined } } }页面保存unsubscribeSpeech,在aboutToDisappear()调用。这样不会因为另一个页面后来替换监听器,旧页面退出时又把新监听器清空。
二十三、用引擎适配器做确定性测试
直接依赖系统 TTS 很难稳定复现事件顺序。定义最小接口:
interface SpeechEngineAdapter { speak(text: string, requestId: string): void stop(): void setEvents(events: SpeechEngineEvents): void } interface SpeechEngineEvents { onStart(): void onComplete(requestId: string, type: number): void onStop(): void onError(requestId: string, code: number): void }生产适配器包装TextToSpeechEngine,测试适配器手动触发事件。控制器测试无需真实设备发声,也能验证旧请求是否被忽略。
二十四、必须覆盖乱序事件场景
测试不能只跑“开始到完成”的理想路径,至少覆盖:
| 场景 | 事件顺序 | 预期结果 |
|---|---|---|
| 快速开始两次 | start A、start B、A 初始化完成 | 只播放 B |
| 开始后立即停止 | preparing、stop、旧 timer | 不调用 speak |
| 快速下一段 | next、next、旧 complete | 只推进一次 |
| 切换文章 | A speak、start B、A complete | B 保持当前段 |
| 页面返回 | speak、page_leave、onStop | 状态为 idle |
| 引擎报错 | onError(code) | error,可重试 |
| 最后一段完成 | complete last | completed,不越界 |
| 空章节 | start empty article | error,不创建请求 |
这张表就是会话令牌与状态机是否有效的验收依据。
二十五、化学朗读还要检查内容准确性
技术链路稳定后,仍需人工听测化学内容:
H₂O、CO₂、KMnO₄是否按预期朗读。- 上升、下降箭头是否需要转换为“气体生成”“沉淀生成”。
- 方程式等号、箭头和系数是否造成不自然停顿。
- 长标题是否只在首段出现一次。
- 中文与拉丁字母混排时语速是否可接受。
TTS 能成功回调不代表教学内容正确。化学式的语义朗读应通过speechText逐条核对,不能依赖自动替换推断。
二十六、常见故障与排查顺序
| 现象 | 优先检查 | 常见原因 | 修复方向 |
|---|---|---|---|
| 停止后再次开口 | sessionId/timer | 旧延迟任务仍执行 | 清 timer + 会话校验 |
| 快速点击创建多个引擎 | engineTask | 只检查已完成实例 | 共享初始化 Promise |
| 一次跳过两段 | phase/requestId | 连续 next 与旧 complete 同时推进 | preparing 阶段禁入 |
| 切换文章后高亮错位 | snapshot.articleId | 页面消费旧文章状态 | 按文章 ID 过滤 |
| 每段重复读标题 | 文本构建器 | 标题无条件拼接 | 只在首段或手动起播时加入 |
| 返回列表仍播放 | 页面生命周期 | 只解除监听未停止 | 明确 page_leave 策略 |
| 错误提示方向不对 | 引擎配置 | 在线模式显示离线文案 | 按阶段映射错误 |
| 错误后不能重试 | phase 收口 | 引擎任务或请求未清理 | error 状态清空活动请求 |
| 化学式发音异常 | speechText | 直接朗读展示字符串 | 增加人工核对文本 |
排查顺序应从会话 ID、请求 ID、阶段快照开始,再看引擎回调和页面显示。只看按钮颜色很难发现旧事件串台。
二十七、按最小风险顺序改造
建议分四步落地:
- 先增加
SpeechSnapshot和SpeechPhase,页面改为从快照推导按钮状态。 - 再加入
engineTask、sessionId和可取消定时器,解决重复初始化与旧任务。 - 收紧停止原因、页面退出策略和文章 ID 过滤,补齐错误反馈。
- 最后增加
speechText、测试适配器和乱序事件测试。
每一步都能用现有三段文章验证。不要同时重写 UI、内容模型和引擎适配,否则听到重复声音时很难定位是哪层引起。
二十八、源码事实与建议实现的边界
为了避免把方案设计误写成已经交付的能力,下面把ReadingSpeechService.ets中可直接复核的事实与本文建议严格分开。
| 观察维度 | 当前源码事实 | 建议实现 |
|---|---|---|
| 引擎配置 | 使用@kit.CoreSpeechKit的textToSpeech.createEngine,语言为zh-CN、person: 0、online: 1 | 把引擎创建封装为共享初始化任务,并为初始化失败提供与在线配置一致的提示 |
| 服务状态 | 以静态字段保存engine、article、isSpeaking、章节索引、状态文案、请求 ID 与停止原因 | 输出不可变SpeechSnapshot,用明确阶段替代多个相互依赖的布尔判断 |
| 回调过滤 | onComplete会比较回调requestId与activeRequestId | 增加sessionId + requestId双重守卫,隔离文章切换、页面离开和段落切换产生的旧事件 |
| 延迟播放 | 每段通过固定 120mssetTimeout后调用speakCurrentSection,当前没有保存定时器句柄 | 保存并取消定时器,同时在任务执行前校验会话身份 |
| 停止语义 | stopReason区分manual、switch与complete | 把停止原因建模为受限枚举,并统一收口状态、请求 ID、章节索引和定时器 |
| 分段推进 | 完成回调在response.type === 1且仍处于朗读状态时调用下一段 | 在preparing/speaking/stopping阶段限制重复推进,避免手动下一段与完成事件竞态 |
| 文本构建 | 每一段都拼接文章标题、章节标题和正文 | 引入人工校对的speechText,并决定文章标题只在首段还是每段朗读 |
| 页面协作 | 页面通过单一 listener 接收isSpeaking、章节索引和状态文案;离开时只解除 listener | 明确页面离开是停止播放还是转交全局播放器,并按文章 ID 过滤页面快照 |
| 错误提示 | 创建失败统一显示“当前设备不支持离线朗读”,但引擎配置实际为在线模式 | 按初始化、网络、合成、播放和参数阶段映射错误,避免错误方向误导排查 |
| 验证状态 | 当前源码可证明服务结构和调用路径存在 | 建议方案仍需完成编译、安装、真机朗读、乱序回调与页面返回测试后才能视为交付 |
本文给出的SpeechPhase、SpeechSnapshot、共享engineTask、可取消定时器、会话令牌、测试替身和化学专用speechText都是建议实现,当前仓库并未集成这些结构。本次精修没有执行 DevEco Studio 构建、真机安装或语音引擎运行测试,因此不能据此声称竞争窗口已经修复,也不能虚构设备兼容性和播放成功率。
总结
ReadingSpeechService已经具备引擎复用、请求 ID、停止原因和分段推进,是一条可工作的基础链路。真正需要补强的是“谁有权更新当前状态”:引擎初始化、120ms 延迟任务、停止事件和完成回调都可能晚于用户的新操作到达,单个布尔值无法表达这种先后关系。
更可靠的实现是用SpeechPhase表达阶段,用不可变SpeechSnapshot输出页面状态,用共享初始化任务避免重复引擎,用sessionId取消旧意图,用requestId隔离同一会话的段落请求,并明确页面离开时停止还是转交全局播放器。再配合化学专用speechText和乱序事件测试,朗读功能才不会停留在“按钮能出声”,而会成为可停止、可切换、可恢复、出错可解释的完整服务。
AI 辅助声明:本文由 AI 辅助完成源码梳理、结构校对与配图设计,技术结论均以文中列出的本地 ArkTS 源码为依据,并由作者复核后发布。