【化学实验室|10】HarmonyOS ArkTS 语音朗读服务实战:控制播放生命周期、页面返回与异常恢复
给阅读页加朗读功能时最容易低估的不是语音合成 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(): Promisevoid { 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()。可以保存初始化 Promiseprivate static engine?: textToSpeech.TextToSpeechEngine private static engineTask?: PromisetextToSpeech.TextToSpeechEngine private static async ensureEngine(): PromisetextToSpeech.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): Promisevoid { 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()需要独立请求 IDprivate 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 nRTTTS 却可能逐字符或不自然地读出符号。不要修改展示正文可以给章节增加可选朗读文本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 结果原因最终阶段页面提示manualidle已停止朗读switch_sectionpreparing正在切换段落switch_articlepreparing正在切换文章completecompleted朗读完成page_leaveidle不再向已离开页面通知字符串受限后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 推导页面无需再维护一份容易漂移的isSpeakingprivate 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 completeB 保持当前段页面返回speak、page_leave、onStop状态为 idle引擎报错onError(code)error可重试最后一段完成complete lastcompleted不越界空章节start empty articleerror不创建请求这张表就是会话令牌与状态机是否有效的验收依据。二十五、化学朗读还要检查内容准确性技术链路稳定后仍需人工听测化学内容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 源码为依据并由作者复核后发布。