【知律|08】HarmonyOS ArkTS 语音阅读实战:管理法条朗读状态和中断恢复
语音阅读不是给文字旁边加一个播放图标。一次可靠朗读至少涉及四份状态当前朗读哪段文本、语音引擎是否可用、哪个请求拥有播放权、页面或系统中断后应该停止还是恢复。如果 UI 显示“正在播放”引擎却已经停止或者上一条请求的完成回调清掉了下一条请求的状态功能就会呈现为难复现的偶发错误。本文基于知律项目D:\huawei\one19-11、包名com.jiaweikang.one19的真实源码复核 brief 指向的BankDetailPage.ets并继续追踪项目中唯一的 TTS 实现PracticePage.ets、Question模型、MockBanks.ets与module.json5。当前题库详情页没有朗读入口练习页已有离线优先、在线兜底的文本转语音引擎、监听器、重播与页面离开释放但题库中没有audio题加载逻辑还会把旧audio类型改成vocab并移除audioHint使现有语音 UI 实际不可达。文章将从这条真实边界出发设计法条朗读状态机与中断恢复契约。一、先确认 BankDetailPage 没有语音能力BankDetailPage展示题库封面、简介、重点标签、章节进度和随机练习、模拟考试入口。它没有导入hms.ai.textToSpeech没有引擎实例也没有播放、暂停或恢复状态。页面里的“文化提示”“题库简介”只是普通Text。因此当前版本不能宣称已经支持题库简介或法条朗读。二、项目唯一 TTS 实现在 PracticePage练习页导入import textToSpeech from hms.ai.textToSpeech并持有private ttsEngine?: textToSpeech.TextToSpeechEngine undefined State activeAudioQuestionId: string State audioStatusText: string State showAudioDialog: boolean false State audioDialogText: string State audioDialogStem: string 这些状态只覆盖“当前音频题是否活跃、提示文字和弹窗内容”还不是通用法条朗读会话。三、离线优先、在线兜底是真实实现ensureTtsEngine()先创建离线引擎this.ttsEngine await textToSpeech.createEngine({ language: zh-CN, person: 0, online: 0 })失败后再尝试online: 1。两次都失败时显示“当前设备语音引擎不可用”。module.json5声明了ohos.permission.INTERNET因此在线兜底与清单一致。但是否在线、是否会发送文本、数据如何处理需要在产品隐私材料中按真实 SDK 行为说明不能只因为“优先离线”就把整个功能描述为完全离线。四、当前题库没有可触发的 audio 数据题目卡只有在question.type audio question.audioHint时才显示语音入口。然而模拟题源中没有type: audio的题目。更关键的是getQuestions()对历史audio类型执行重分类if (raw.type audio) { return { type: vocab, stem: raw.stem, options: raw.options, answer: raw.answer, analysis: raw.analysis, example: raw.example } }这里不仅改成vocab还没有保留audioHint。因此即使未来旧题源加入 audio 数据加载后也不会进入现有语音 UI。五、先决定语音是题型还是通用能力法律内容朗读更适合作为所有题目、解析和法条详情的通用辅助能力而不是一种独立题型。可以在内容模型上声明是否允许朗读interface ReadableContent { contentId: string title: string paragraphs: ReadableParagraph[] speechEnabled: boolean } interface ReadableParagraph { id: string text: string }UI 根据speechEnabled和文本非空决定是否展示按钮不再依赖Question.type audio。六、audioText 当前做了最小清洗private audioText(question: Question): string { return (question.audioHint || question.stem) .replace(/[“”]/g, ) .trim() }它优先使用audioHint否则读题干并移除引号。这适合短句示例。真正法条正文可能包含条号、括号、顿号和引用不能一律删除标点否则语义与停顿都会改变。七、朗读文本要与屏幕文本可追溯建议在内容层显式生成interface SpeechText { contentId: string paragraphId: string displayText: string speakText: string }displayText保留原文speakText只做经过测试的朗读规范化例如把“第143条”转换为更自然的停顿。用户仍能知道声音对应哪一段原文。八、当前点击会先打开弹窗toggleAudioPreview()在初始化引擎之前就设置弹窗和文字this.showAudioDialog true this.audioDialogText this.audioText(question)即使 TTS 不可用用户仍能看到文字提示不会被阻塞在空页面。这是合理的降级策略。但按钮“再听一次”只有在ttsEngine已存在时才执行首次初始化失败后按钮不会再次调用初始化也没有明确重试反馈。九、当前播放前会停止旧请求if (this.ttsEngine.isBusy()) { this.ttsEngine.stop() } this.ttsEngine.speak(text, { requestId })这避免两段声音同时播放。法条阅读也应坚持“单一播放所有者”新请求开始前明确停止旧请求。十、requestId 已存在但没有参与状态归属首次播放使用${question.id}_${Date.now()}重播使用replay_${Date.now()}。监听器能收到requestId但当前回调没有比较它是否仍是当前请求onComplete: (requestId: string) { this.activeAudioQuestionId this.audioStatusText }如果旧请求的onStop或onComplete晚到它可能清空新请求的 UI 状态。可靠状态机必须校验回调归属。十一、为播放会话保存当前请求type SpeechPhase | idle | preparing | speaking | paused | interrupted | error interface SpeechSession { phase: SpeechPhase requestId: string contentId: string paragraphId: string text: string errorMessage: string }只有requestId session.requestId的回调才能修改当前状态。旧回调可以记录诊断但不能接管 UI。十二、状态转换要显式function canTransition( from: SpeechPhase, to: SpeechPhase ): boolean { const allowed: RecordSpeechPhase, SpeechPhase[] { idle: [preparing], preparing: [speaking, error, idle], speaking: [paused, interrupted, error, idle], paused: [speaking, idle, error], interrupted: [speaking, idle, error], error: [preparing, idle] } return allowed[from].includes(to) }按钮文案与可点击状态从phase推导而不是同时维护多个可能冲突的布尔值。十三、初始化过程需要防并发用户连续点击多个段落时两个createEngine()可能同时执行。可以缓存初始化 Promiseprivate enginePromise?: PromisetextToSpeech.TextToSpeechEngine private ensureEngine(): PromisetextToSpeech.TextToSpeechEngine { if (this.ttsEngine) { return Promise.resolve(this.ttsEngine) } if (this.enginePromise) { return this.enginePromise } this.enginePromise this.createPreferredEngine() return this.enginePromise }成功后保存引擎失败后清空 Promise允许用户重试。十四、离线与在线模式要进入可观察状态type SpeechEngineMode offline | online interface SpeechEngineState { mode: SpeechEngineMode ready: boolean }如果离线失败但在线成功UI 可以用简短提示说明当前依赖网络。没有网络时提供重试不要持续静默失败。十五、错误回调当前只显示统一文案现有onError会设置“语音播放失败请检查系统语音服务”。对用户而言足够简洁但诊断层仍应保留错误码、请求 ID、引擎模式和内容 ID。不能把私密文本、用户身份或完整法规输入写入外部日志。日志只记录必要元数据。十六、页面离开会正确释放引擎aboutToDisappear(): void { if (this.ttsEngine) { this.ttsEngine.stop() this.ttsEngine.shutdown() this.ttsEngine undefined } }这能避免页面离开后继续朗读也释放原生资源。对当前练习页而言“离开即停止”是清晰策略。十七、中断恢复与页面恢复不是一回事系统音频中断可能来自电话、其他媒体、蓝牙设备变化或音频焦点调整页面中断可能来自路由跳转、进入后台或组件销毁。产品需要分别定义临时系统中断允许恢复用户主动暂停等待用户操作页面离开停止并释放内容切换停止旧段并播放新段应用后台默认暂停或停止。不能把所有场景都映射成stop()后自动重播。十八、恢复位置需要段落级设计现有 TTS 调用只提交整段字符串源码没有字级进度回调或当前位置记录。文章不伪造“精确恢复到第 128 个字”。最稳妥的基础方案是把长法条拆成短段每次提交一个段落。中断时记录当前paragraphId恢复时从该段重新开始。这样无需依赖未确认的字级 API。十九、段落队列模型interface SpeechQueue { contentId: string paragraphs: ReadableParagraph[] currentIndex: number } function currentParagraph( queue: SpeechQueue ): ReadableParagraph | undefined { return queue.paragraphs[queue.currentIndex] }一段完成后只有当前请求仍有效且用户没有暂停才推进到下一段。二十、完成回调要驱动队列而非直接清空private onSpeechComplete(requestId: string): void { if (requestId ! this.session.requestId) { return } if (this.queue.currentIndex this.queue.paragraphs.length - 1) { this.queue.currentIndex this.playCurrentParagraph() return } this.session idleSession() }短题目可以只有一个段落法条详情可以包含多个段落使用同一会话逻辑。二十一、暂停能力需要适配器而不是 UI 猜测当前引擎使用了speak、stop、shutdown和isBusy源码没有暂停或继续调用。是否支持原生 pause/resume应以目标 HarmonyOS 版本与 TTS Kit 官方接口为准。可以先定义项目内适配器interface SpeechEngineAdapter { prepare(): Promisevoid speak(requestId: string, text: string): void stop(): void release(): void isSpeaking(): boolean }若平台没有可靠暂停就用“停止并从当前段重新开始”实现可解释降级。二十二、系统中断事件也应通过适配器不同系统版本与音频能力的中断回调可能不同页面不应直接依赖具体事件名。适配器把平台事件归一化为type InterruptionEvent | temporaryLoss | permanentLoss | mayResume | routeChanged服务层再决定状态迁移。文章不声称当前项目已经监听这些事件。二十三、自动恢复必须满足三个条件只有同时满足以下条件才自动恢复中断是临时的系统允许恢复用户没有在中断期间主动停止页面与内容仍然有效。可以保存resumeTokeninterface ResumeToken { sessionId: string contentId: string paragraphId: string userStopped: boolean }任何条件不成立就回到 idle 并保留可手动重播入口。二十四、关闭弹窗当前会停止播放closeAudioDialog()调用ttsEngine.stop()然后清空弹窗和活跃状态。这个交互符合用户预期关闭即停止。通用法条阅读如果提供后台继续播放必须明确改变产品规则并增加系统媒体控制、通知和生命周期能力。当前源码没有这些功能不能延伸宣称。二十五、重播按钮需要可用状态当前“再听一次”按钮始终显示即使引擎不存在点击也没有效果。应根据状态渲染Button(this.session.phase error ? 重试 : 再听一次) .enabled(this.session.phase ! preparing) .onClick(() { this.retryOrReplay() })引擎不可用时重试初始化准备中禁用重复点击朗读中可以显示“重新开始”或“停止”。二十六、UI 状态要与引擎回调同步不要在调用speak()前就永久设置为 speaking。可以先进入preparing收到onStart且 requestId 匹配后再进入speaking。如果调用抛错或onError到达进入 erroronStop只有在当前请求仍有效时才清理。这样页面不会出现“显示播放但没有声音”的长期假状态。二十七、同一引擎应由服务层拥有把TextToSpeechEngine直接放在页面里实现简单但题目页、法条详情页和收藏复习页都需要朗读时会产生多个引擎与重复监听器。建议由生命周期明确的SpeechService持有引擎页面只订阅SpeechSession。服务是应用级还是页面级要取决于是否允许跨页面继续播放当前需求更适合页面级离开即释放。二十八、服务接口保持窄而清晰interface SpeechService { play(content: ReadableContent, paragraphId?: string): Promisevoid pause(): void resume(): Promisevoid stop(): void release(): void state(): SpeechSession }页面不接触原生引擎也不需要知道离线或在线创建细节。测试时可以替换为假引擎。二十九、长文本要控制单次请求大小法条、司法解释或案例解析可能很长。应按自然段、句号或配置边界切分但不能在条号、金额和法律名称中间任意截断。function buildParagraphs( contentId: string, texts: string[] ): ReadableParagraph[] { return texts .map((text: string) text.trim()) .filter((text: string) text.length 0) .map((text: string, index: number) ({ id: ${contentId}_p${index 1}, text })) }每段有稳定 ID便于恢复与测试。三十、法条更新会使恢复位置失效如果正文版本变化旧paragraphId可能不再存在。恢复令牌应带contentVersioninterface SpeechBookmark { contentId: string contentVersion: string paragraphId: string }版本不一致时从开头开始并提示内容已更新。不要把旧段落位置硬套到新正文。三十一、无需保存敏感播放历史当前法律学习内容来自本地题库不需要上传朗读文本、播放位置或用户行为。若只要求页面内中断恢复会话放在内存即可。只有明确需要跨页面或重启恢复时才把SpeechBookmark存入 Preferences并说明清除规则。不要保存原始音频或完整用户输入。三十二、网络与隐私边界在线引擎兜底意味着语音生成可能依赖网络服务。上线前需要确认在线模式是否发送文本服务不可用时如何降级隐私政策和 SDK 清单是否一致应用离线定位是否与该行为冲突用户是否能选择只使用离线语音。这些信息必须来自真实 SDK 行为与官方说明不能靠推测填写。三十三、无障碍与语音阅读不是一回事TTS 按钮是应用内内容朗读系统读屏是无障碍能力。朗读功能不能替代组件的accessibilityText、焦点顺序和按钮语义。播放、暂停、重试按钮都要有清晰标签状态变化应能被用户感知但避免频繁播报干扰系统读屏。三十四、多设备布局要保持控制稳定BankDetailPage已在大屏使用左右双栏在手机使用单列并保留底部安全区。加入朗读控制后手机端可放在标题或段落旁的图标按钮平板双栏中控制与正在朗读文本保持同侧2in1 支持鼠标悬停说明和键盘焦点小窗下按钮不遮挡标题长法条滚动时当前段落可见。朗读状态变化不能让按钮宽度和卡片高度跳动。三十五、生命周期验收场景至少覆盖首次离线引擎成功离线失败、在线成功两种模式都失败后可重试快速连续点击两个段落旧请求停止回调晚于新请求开始关闭弹窗立即停止页面返回时 stop 与 shutdown 执行临时中断后符合条件才恢复用户主动停止后不自动恢复内容版本变化后不恢复到无效段落。三十六、渐进式落地顺序第一步修正数据入口不再依赖不可达的audio题型把朗读能力挂到可读文本。第二步抽取SpeechService与SpeechSession使用 requestId 校验回调归属。第三步在题目解析或法条详情中加入播放、停止和错误重试继续采用离开即释放。第四步把长文本拆成稳定段落实现段落级中断恢复。第五步在确认平台中断 API 和产品需要后再接入临时中断自动恢复不提前承诺后台播放。三十七、发布前闭环检查逐项确认朗读入口对应真实可达内容BankDetailPage 未实现的能力不写成现状离线与在线引擎模式可区分在线行为与 INTERNET 权限、隐私材料一致初始化失败可重试requestId 决定状态回调归属新请求会停止旧请求页面离开释放引擎用户停止后不会自动恢复中断恢复至少保存稳定段落 ID长文本不会一次性无边界提交深浅色、小窗、平板、2in1 与无障碍均检查不虚构播放量、成功率、耗时或设备覆盖数据。三十八、结语知律已经写出一套可参考的 TTS 基础代码离线引擎优先失败后尝试在线播放前停止旧请求监听开始、完成、停止和错误弹窗在语音不可用时仍显示文字页面离开时停止并释放资源。这些都是真实可复核的工程基础。它当前也有清晰缺口brief 指向的题库详情页没有朗读入口题库没有 audio 题重分类还会让语音条件不可达现有状态不支持暂停、进度或中断恢复。把朗读从“音频题特例”提升为“可读内容服务”再用请求归属、段落队列和生命周期规则统一管理才能让法条语音阅读在 HarmonyOS 多设备场景中稳定、可测试、可解释。---本文部分内容由 AI 辅助整理。所有现状判断均基于D:\huawei\one19-11中com.jiaweikang.one19的本地源码复核示例改造代码用于说明工程方案不代表当前版本已经实现 BankDetailPage 法条朗读、暂停进度、系统音频中断监听或自动恢复。
