语音阅读不是给文字旁边加一个播放图标。一次可靠朗读至少涉及四份状态:当前朗读哪段文本、语音引擎是否可用、哪个请求拥有播放权、页面或系统中断后应该停止还是恢复。如果 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
练习页导入:
ts
import textToSpeech from '@hms.ai.textToSpeech'
并持有:
ts
private ttsEngine?: textToSpeech.TextToSpeechEngine = undefined
@State activeAudioQuestionId: string = ''
@State audioStatusText: string = ''
@State showAudioDialog: boolean = false
@State audioDialogText: string = ''
@State audioDialogStem: string = ''
这些状态只覆盖"当前音频题是否活跃、提示文字和弹窗内容",还不是通用法条朗读会话。
三、离线优先、在线兜底是真实实现
ensureTtsEngine() 先创建离线引擎:
ts
this.ttsEngine = await textToSpeech.createEngine({
language: 'zh-CN',
person: 0,
online: 0
})
失败后再尝试 online: 1。两次都失败时显示"当前设备语音引擎不可用"。module.json5 声明了 ohos.permission.INTERNET,因此在线兜底与清单一致。
但是否在线、是否会发送文本、数据如何处理,需要在产品隐私材料中按真实 SDK 行为说明,不能只因为"优先离线"就把整个功能描述为完全离线。
四、当前题库没有可触发的 audio 数据
题目卡只有在:
ts
question.type === 'audio' && question.audioHint
时才显示语音入口。然而模拟题源中没有 type: 'audio' 的题目。更关键的是,getQuestions() 对历史 audio 类型执行重分类:
ts
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。
五、先决定语音是题型还是通用能力
法律内容朗读更适合作为所有题目、解析和法条详情的通用辅助能力,而不是一种独立题型。可以在内容模型上声明是否允许朗读:
ts
interface ReadableContent {
contentId: string
title: string
paragraphs: ReadableParagraph[]
speechEnabled: boolean
}
interface ReadableParagraph {
id: string
text: string
}
UI 根据 speechEnabled 和文本非空决定是否展示按钮,不再依赖 Question.type === 'audio'。
六、audioText 当前做了最小清洗
ts
private audioText(question: Question): string {
return (question.audioHint || question.stem)
.replace(/["""]/g, '')
.trim()
}
它优先使用 audioHint,否则读题干,并移除引号。这适合短句示例。真正法条正文可能包含条号、括号、顿号和引用,不能一律删除标点,否则语义与停顿都会改变。
七、朗读文本要与屏幕文本可追溯
建议在内容层显式生成:
ts
interface SpeechText {
contentId: string
paragraphId: string
displayText: string
speakText: string
}
displayText 保留原文;speakText 只做经过测试的朗读规范化,例如把"第143条"转换为更自然的停顿。用户仍能知道声音对应哪一段原文。
八、当前点击会先打开弹窗
toggleAudioPreview() 在初始化引擎之前就设置弹窗和文字:
ts
this.showAudioDialog = true
this.audioDialogText = this.audioText(question)
即使 TTS 不可用,用户仍能看到文字提示,不会被阻塞在空页面。这是合理的降级策略。
但按钮"再听一次"只有在 ttsEngine 已存在时才执行;首次初始化失败后,按钮不会再次调用初始化,也没有明确重试反馈。
九、当前播放前会停止旧请求
ts
if (this.ttsEngine.isBusy()) {
this.ttsEngine.stop()
}
this.ttsEngine.speak(text, { requestId })
这避免两段声音同时播放。法条阅读也应坚持"单一播放所有者",新请求开始前明确停止旧请求。
十、requestId 已存在,但没有参与状态归属
首次播放使用 ${question.id}_${Date.now()},重播使用 replay_${Date.now()}。监听器能收到 requestId,但当前回调没有比较它是否仍是当前请求:
ts
onComplete: (requestId: string) => {
this.activeAudioQuestionId = ''
this.audioStatusText = ''
}
如果旧请求的 onStop 或 onComplete 晚到,它可能清空新请求的 UI 状态。可靠状态机必须校验回调归属。
十一、为播放会话保存当前请求
ts
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。
十二、状态转换要显式
ts
function canTransition(
from: SpeechPhase,
to: SpeechPhase
): boolean {
const allowed: Record<SpeechPhase, 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() 可能同时执行。可以缓存初始化 Promise:
ts
private enginePromise?: Promise<textToSpeech.TextToSpeechEngine>
private ensureEngine(): Promise<textToSpeech.TextToSpeechEngine> {
if (this.ttsEngine) {
return Promise.resolve(this.ttsEngine)
}
if (this.enginePromise) {
return this.enginePromise
}
this.enginePromise = this.createPreferredEngine()
return this.enginePromise
}
成功后保存引擎,失败后清空 Promise,允许用户重试。
十四、离线与在线模式要进入可观察状态
ts
type SpeechEngineMode = 'offline' | 'online'
interface SpeechEngineState {
mode: SpeechEngineMode
ready: boolean
}
如果离线失败但在线成功,UI 可以用简短提示说明当前依赖网络。没有网络时提供重试,不要持续静默失败。
十五、错误回调当前只显示统一文案
现有 onError 会设置"语音播放失败,请检查系统语音服务"。对用户而言足够简洁,但诊断层仍应保留错误码、请求 ID、引擎模式和内容 ID。
不能把私密文本、用户身份或完整法规输入写入外部日志。日志只记录必要元数据。
十六、页面离开会正确释放引擎
ts
aboutToDisappear(): void {
if (this.ttsEngine) {
this.ttsEngine.stop()
this.ttsEngine.shutdown()
this.ttsEngine = undefined
}
}
这能避免页面离开后继续朗读,也释放原生资源。对当前练习页而言,"离开即停止"是清晰策略。
十七、中断恢复与页面恢复不是一回事
系统音频中断可能来自电话、其他媒体、蓝牙设备变化或音频焦点调整;页面中断可能来自路由跳转、进入后台或组件销毁。
产品需要分别定义:
- 临时系统中断:允许恢复;
- 用户主动暂停:等待用户操作;
- 页面离开:停止并释放;
- 内容切换:停止旧段并播放新段;
- 应用后台:默认暂停或停止。
不能把所有场景都映射成 stop() 后自动重播。
十八、恢复位置需要段落级设计
现有 TTS 调用只提交整段字符串,源码没有字级进度回调或当前位置记录。文章不伪造"精确恢复到第 128 个字"。
最稳妥的基础方案是把长法条拆成短段,每次提交一个段落。中断时记录当前 paragraphId,恢复时从该段重新开始。这样无需依赖未确认的字级 API。
十九、段落队列模型
ts
interface SpeechQueue {
contentId: string
paragraphs: ReadableParagraph[]
currentIndex: number
}
function currentParagraph(
queue: SpeechQueue
): ReadableParagraph | undefined {
return queue.paragraphs[queue.currentIndex]
}
一段完成后,只有当前请求仍有效且用户没有暂停,才推进到下一段。
二十、完成回调要驱动队列而非直接清空
ts
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 官方接口为准。
可以先定义项目内适配器:
ts
interface SpeechEngineAdapter {
prepare(): Promise<void>
speak(requestId: string, text: string): void
stop(): void
release(): void
isSpeaking(): boolean
}
若平台没有可靠暂停,就用"停止并从当前段重新开始"实现可解释降级。
二十二、系统中断事件也应通过适配器
不同系统版本与音频能力的中断回调可能不同,页面不应直接依赖具体事件名。适配器把平台事件归一化为:
ts
type InterruptionEvent =
| 'temporaryLoss'
| 'permanentLoss'
| 'mayResume'
| 'routeChanged'
服务层再决定状态迁移。文章不声称当前项目已经监听这些事件。
二十三、自动恢复必须满足三个条件
只有同时满足以下条件才自动恢复:
- 中断是临时的,系统允许恢复;
- 用户没有在中断期间主动停止;
- 页面与内容仍然有效。
可以保存 resumeToken:
ts
interface ResumeToken {
sessionId: string
contentId: string
paragraphId: string
userStopped: boolean
}
任何条件不成立,就回到 idle 并保留可手动重播入口。
二十四、关闭弹窗当前会停止播放
closeAudioDialog() 调用 ttsEngine.stop(),然后清空弹窗和活跃状态。这个交互符合用户预期:关闭即停止。
通用法条阅读如果提供后台继续播放,必须明确改变产品规则,并增加系统媒体控制、通知和生命周期能力。当前源码没有这些功能,不能延伸宣称。
二十五、重播按钮需要可用状态
当前"再听一次"按钮始终显示,即使引擎不存在,点击也没有效果。应根据状态渲染:
ts
Button(this.session.phase === 'error' ? '重试' : '再听一次')
.enabled(this.session.phase !== 'preparing')
.onClick(() => {
this.retryOrReplay()
})
引擎不可用时重试初始化;准备中禁用重复点击;朗读中可以显示"重新开始"或"停止"。
二十六、UI 状态要与引擎回调同步
不要在调用 speak() 前就永久设置为 speaking。可以先进入 preparing,收到 onStart 且 requestId 匹配后再进入 speaking。
如果调用抛错或 onError 到达,进入 error;onStop 只有在当前请求仍有效时才清理。这样页面不会出现"显示播放但没有声音"的长期假状态。
二十七、同一引擎应由服务层拥有
把 TextToSpeechEngine 直接放在页面里实现简单,但题目页、法条详情页和收藏复习页都需要朗读时,会产生多个引擎与重复监听器。
建议由生命周期明确的 SpeechService 持有引擎,页面只订阅 SpeechSession。服务是应用级还是页面级,要取决于是否允许跨页面继续播放;当前需求更适合页面级,离开即释放。
二十八、服务接口保持窄而清晰
ts
interface SpeechService {
play(content: ReadableContent, paragraphId?: string): Promise<void>
pause(): void
resume(): Promise<void>
stop(): void
release(): void
state(): SpeechSession
}
页面不接触原生引擎,也不需要知道离线或在线创建细节。测试时可以替换为假引擎。
二十九、长文本要控制单次请求大小
法条、司法解释或案例解析可能很长。应按自然段、句号或配置边界切分,但不能在条号、金额和法律名称中间任意截断。
ts
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 可能不再存在。恢复令牌应带 contentVersion:
ts
interface 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 法条朗读、暂停进度、系统音频中断监听或自动恢复。