【知律|08】HarmonyOS ArkTS 语音阅读实战:管理法条朗读状态和中断恢复

语音阅读不是给文字旁边加一个播放图标。一次可靠朗读至少涉及四份状态:当前朗读哪段文本、语音引擎是否可用、哪个请求拥有播放权、页面或系统中断后应该停止还是恢复。如果 UI 显示"正在播放",引擎却已经停止;或者上一条请求的完成回调清掉了下一条请求的状态,功能就会呈现为难复现的偶发错误。

本文基于知律项目 D:\huawei\one19-11、包名 com.jiaweikang.one19 的真实源码,复核 brief 指向的 BankDetailPage.ets,并继续追踪项目中唯一的 TTS 实现 PracticePage.etsQuestion 模型、MockBanks.etsmodule.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 = ''
}

如果旧请求的 onStoponComplete 晚到,它可能清空新请求的 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 猜测

当前引擎使用了 speakstopshutdownisBusy,源码没有暂停或继续调用。是否支持原生 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'

服务层再决定状态迁移。文章不声称当前项目已经监听这些事件。

二十三、自动恢复必须满足三个条件

只有同时满足以下条件才自动恢复:

  1. 中断是临时的,系统允许恢复;
  2. 用户没有在中断期间主动停止;
  3. 页面与内容仍然有效。

可以保存 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 题型,把朗读能力挂到可读文本。

第二步,抽取 SpeechServiceSpeechSession,使用 requestId 校验回调归属。

第三步,在题目解析或法条详情中加入播放、停止和错误重试,继续采用离开即释放。

第四步,把长文本拆成稳定段落,实现段落级中断恢复。

第五步,在确认平台中断 API 和产品需要后,再接入临时中断自动恢复,不提前承诺后台播放。

三十七、发布前闭环检查

逐项确认:

  • 朗读入口对应真实可达内容;
  • BankDetailPage 未实现的能力不写成现状;
  • 离线与在线引擎模式可区分;
  • 在线行为与 INTERNET 权限、隐私材料一致;
  • 初始化失败可重试;
  • requestId 决定状态回调归属;
  • 新请求会停止旧请求;
  • 页面离开释放引擎;
  • 用户停止后不会自动恢复;
  • 中断恢复至少保存稳定段落 ID;
  • 长文本不会一次性无边界提交;
  • 深浅色、小窗、平板、2in1 与无障碍均检查;
  • 不虚构播放量、成功率、耗时或设备覆盖数据。

三十八、结语

知律已经写出一套可参考的 TTS 基础代码:离线引擎优先,失败后尝试在线;播放前停止旧请求;监听开始、完成、停止和错误;弹窗在语音不可用时仍显示文字;页面离开时停止并释放资源。这些都是真实可复核的工程基础。

它当前也有清晰缺口:brief 指向的题库详情页没有朗读入口,题库没有 audio 题,重分类还会让语音条件不可达,现有状态不支持暂停、进度或中断恢复。把朗读从"音频题特例"提升为"可读内容服务",再用请求归属、段落队列和生命周期规则统一管理,才能让法条语音阅读在 HarmonyOS 多设备场景中稳定、可测试、可解释。


本文部分内容由 AI 辅助整理。所有现状判断均基于 D:\huawei\one19-11com.jiaweikang.one19 的本地源码复核;示例改造代码用于说明工程方案,不代表当前版本已经实现 BankDetailPage 法条朗读、暂停进度、系统音频中断监听或自动恢复。

相关推荐
woshihuanglaoshi3 小时前
十二商品二十流水:鸿蒙进销存种子数据装满预警看板
学习·华为·harmonyos
YM52e6 小时前
六十条商品铺满三页:鸿蒙分页列表种子数据与加载效果
华为·harmonyos
todoitbo7 小时前
把蓝耘接入鸿蒙健康教练:一句话打卡,模型估热量,网关记账
华为·ai·harmonyos·蓝耘·第三方api
2501_919749038 小时前
华为鸿蒙复盘统计记账APP—小羊统计
华为·harmonyos·鸿蒙
math_hongfan8 小时前
事务下单与状态流转:ArkTS 的 JOIN 联表在鸿蒙订单里实战
android·学习·华为·harmonyos
math_hongfan9 小时前
一对多的数据库姻缘:ArkTS 为鸿蒙订单设计外键与明细表
数据库·华为·harmonyos
Deepsek20059 小时前
鸿蒙系统回退至4.3操作指南(手机端操作)
经验分享·科技·华为·微信·智能手机·harmonyos
2501_9197490310 小时前
华为鸿蒙免费外卖记录APP—小羊外卖
华为·harmonyos·鸿蒙
云端漫步198719 小时前
HarmonyOS 互动卡片实战进阶:配置详解与双触发机制全链路实践
华为·harmonyos