【口算王|10】HarmonyOS ArkTS 语音报题实战:协调朗读、作答和页面生命周期

给练习页加一个"点击播放"入口并不难,难的是让语音状态与题目状态保持一致。用户可能在引擎初始化期间关闭弹窗,可能连续点击两道题,也可能在朗读尚未结束时退出页面。如果代码只调用一次 speak(),没有处理停止、完成、错误和页面销毁,上一题的声音就可能在下一题继续播放,甚至页面退出后仍占用语音引擎。

另一个常见误解是把文字转语音、语音识别和语音作答混为一谈。文字转语音只负责把题目文本朗读出来,不会监听麦克风,也不会识别用户答案。若应用没有申请麦克风权限、没有语音识别服务和答案映射,就不能把功能描述成"语音答题"。

本文基于口算王项目 D:\huawei\one16-11 的真实源码,复核 PracticePage.etsMathModels.etsThemeConstants.etsmodule.json5 与题库数据。包名 com.jiaweikang.one16 是本文草稿核验使用的唯一标记。当前代码保留了旧 audio 题型的点击朗读能力:优先尝试离线 TTS,失败后尝试在线引擎,并始终显示文字提示弹窗作为降级路径;当前默认题库没有 type === 'audio'audioHint 数据,所以正常口算流程不会显示该入口。本文讨论的是这条可复核的兼容链路及其工程完善方法,不把它写成默认题库已经启用的语音功能。

本文重点解决五个问题:

  • 明确当前语音入口的真实触发条件和能力边界;
  • 拆解离线优先、在线回退、文字兜底的初始化流程;
  • 协调朗读请求、弹窗、切题、作答与页面退出;
  • 用请求标识避免旧回调覆盖新题状态;
  • 建立无引擎、无网络、快速切换和生命周期测试矩阵。

一、语音入口只为旧 audio 题型渲染

练习页并不会给每一道口算题显示播放按钮。真实条件是:

ts 复制代码
if (
  this.currentQ()!.type === 'audio' &&
  this.currentQ()!.audioHint
) {
  // 渲染提示卡和点击播放入口
}

两个条件必须同时满足:

  1. Question.type 等于 audio
  2. Question.audioHint 有值。

题目模型只把 audioHint 声明为可选字段:

ts 复制代码
export interface Question {
  id: string
  bankId: string
  chapterId: string
  type: string
  stem: string
  audioHint?: string
  options: Option[]
  answer: string
  analysis: string
  example?: string
}

对当前源码执行题库扫描时,默认 mock 题库没有发现 type: 'audio',也没有实际 audioHint 数据。toggleAudioPreview() 上方注释也明确写着"兼容旧数据的提示弹窗,当前口算题库默认不会触发"。

因此准确描述是:项目保留了旧语音提示题型的兼容实现,但默认口算题库暂未启用。不能仅凭页面里存在 TTS 代码,就宣称所有题目都支持自动报题。

二、它是文字转语音,不是语音识别

页面导入的是:

ts 复制代码
import textToSpeech from
  '@hms.ai.textToSpeech'

代码把文字交给语音引擎输出声音,没有创建录音器,没有读取麦克风,也没有把用户说出的内容转换成答案。module.json5 中的:

json5 复制代码
"requestPermissions": []

进一步说明当前模块没有声明麦克风权限。作答仍然通过 ArkUI 选项点击完成:

ts 复制代码
private selectOption(key: string): void {
  if (this.mode === 'wrongAnalysis') return
  if (this.showAnalysis) return

  this.selectedKey = key
  this.showAnalysis = true
  // 记录正确或错误
}

功能边界可以整理为:

能力 当前是否存在 证据
点击后朗读提示文字 有兼容代码 ttsEngine.speak()
TTS 失败时看文字提示 showAudioDialog
自动朗读每一道普通口算题 UI 只匹配 audio + audioHint
语音识别用户答案 没有 ASR 或录音链路
麦克风作答 权限列表为空
根据声纹判断儿童身份 没有采集与模型

把边界写清楚不仅是技术准确性问题,也关系到权限和隐私说明。TTS 输出与麦克风输入是两条完全不同的能力链。

三、朗读文本优先取 audioHint

页面通过一个小函数生成朗读内容:

ts 复制代码
private audioText(question: Question): string {
  return (
    question.audioHint || question.stem
  )
    .replace(/["""]/g, '')
    .trim()
}

优先使用 audioHint,为空时回退到题干 stem,同时去掉中英文引号并清理首尾空格。这样可以避免引擎把引号读成不自然停顿,也允许旧题目用更适合听觉表达的提示文字。

不过当前 UI 的显示条件要求 audioHint 存在,所以正常点击入口不会触发 stem 回退。这个回退属于方法级防御,不等于所有普通题干都可点击朗读。

若未来要让全部题目支持报题,可以把"是否显示入口"和"读什么文本"拆开:

ts 复制代码
private canReadQuestion(
  question?: Question
): boolean {
  return question !== undefined &&
    this.audioText(question).length > 0
}

然后再定义题型朗读规则。例如选择题可读题干和选项,应用题只读题干,公式中的符号需转换成自然语言。不能简单把任意 ArkTS 字符串都交给 TTS,再假设可听懂。

四、引擎初始化采用离线优先、在线回退

ensureTtsEngine() 先检查现有实例,避免每次点击都重新创建:

ts 复制代码
private async ensureTtsEngine():
  Promise<boolean> {
  if (this.ttsEngine) return true

  try {
    this.ttsEngine =
      await textToSpeech.createEngine({
        language: 'zh-CN',
        person: 0,
        online: 0
      })
  } catch (_) {
    try {
      this.ttsEngine =
        await textToSpeech.createEngine({
          language: 'zh-CN',
          person: 0,
          online: 1
        })
    } catch (_) {
      this.audioStatusText =
        '当前设备语音引擎不可用'
      return false
    }
  }
  // 注册监听器
  return true
}

真实策略是:

  1. 已有引擎时直接复用;
  2. 优先创建中文离线引擎;
  3. 离线创建失败后尝试在线模式;
  4. 两次都失败时返回 false 并更新状态文字。

离线优先能减少网络依赖和等待,但是否成功仍取决于设备是否提供对应语言资源。在线回退也不能被理解为必然成功:当前 module.json5 没有声明网络权限,设备服务状态和系统能力也可能不同。源码没有执行网络能力预检,所以文章只能说"尝试在线引擎",不能承诺离线缺失时一定可播放。

五、文字弹窗是核心降级能力

点击提示卡时,页面先更新 UI,再后台尝试 TTS:

ts 复制代码
private async toggleAudioPreview(
  question: Question
): Promise<void> {
  this.activeAudioQuestionId = question.id
  this.audioStatusText = '正在播放示例'
  this.audioDialogText = this.audioText(question)
  this.audioDialogStem = question.stem
  this.showAudioDialog = true

  try {
    const ready = await this.ensureTtsEngine()
    if (ready && this.ttsEngine) {
      if (this.ttsEngine.isBusy()) {
        this.ttsEngine.stop()
      }
      this.ttsEngine.speak(
        this.audioText(question),
        {
          requestId:
            `${question.id}_${Date.now()}`
        }
      )
    }
  } catch (_) {
  }
}

顺序很关键:弹窗不会等待引擎创建完成。即使引擎不存在、在线回退失败或 speak() 抛出异常,用户仍然能看到 audioDialogText,不会因为语音能力不可用而失去题目提示。

这是一种"核心任务优先"的降级设计:

  • 核心任务:理解提示并继续作答;
  • 增强能力:听到语音;
  • 失败策略:增强能力失败,文字仍可用;
  • 页面结果:不阻断答题。

如果把弹窗显示放在 ensureTtsEngine() 成功之后,设备没有语音引擎时连文字提示也无法打开,兼容入口就会变成失败入口。

六、播放前停止旧请求

引擎已经忙碌时,代码先调用 stop()

ts 复制代码
if (this.ttsEngine.isBusy()) {
  this.ttsEngine.stop()
}

this.ttsEngine.speak(text, {
  requestId
})

这能避免两个提示同时朗读。用户重复点击"再听一次"或快速触发新内容时,旧任务先停,新任务再开始。

不过"调用 stop 后立即 speak"仍然需要关注回调顺序。旧请求可能触发 onStop,新请求随后触发 onStart。当前监听器不比较 requestId

ts 复制代码
onStop: (requestId: string) => {
  this.activeAudioQuestionId = ''
  this.audioStatusText = ''
}

如果旧请求的 onStop 晚于新请求的 onStart 到达,就可能把新题的播放状态清空。声音仍在播放,UI 却显示未播放。

更稳的方式是保存当前请求标识:

ts 复制代码
private activeTtsRequestId: string = ''

private isActiveRequest(
  requestId: string
): boolean {
  return requestId ===
    this.activeTtsRequestId
}

发起朗读时:

ts 复制代码
const requestId =
  `${question.id}_${Date.now()}`
this.activeTtsRequestId = requestId

this.ttsEngine.speak(text, {
  requestId
})

监听器只处理当前请求:

ts 复制代码
onComplete: (requestId: string) => {
  if (!this.isActiveRequest(requestId)) {
    return
  }
  this.activeTtsRequestId = ''
  this.activeAudioQuestionId = ''
  this.audioStatusText = ''
}

这不是为了增加复杂度,而是为了让异步回调不能修改已被后续操作替代的状态。

七、监听器把引擎事件映射成页面状态

当前代码注册四类事件:

ts 复制代码
this.ttsEngine.setListener({
  onStart: (requestId: string) => {
    this.audioStatusText = '正在播放示例'
  },
  onComplete: (requestId: string) => {
    this.activeAudioQuestionId = ''
    this.audioStatusText = ''
  },
  onStop: (requestId: string) => {
    this.activeAudioQuestionId = ''
    this.audioStatusText = ''
  },
  onError: (
    requestId: string,
    errorCode: number,
    errorMessage: string
  ) => {
    this.activeAudioQuestionId = ''
    this.audioStatusText =
      '语音播放失败,请检查系统语音服务'
  }
})

对应状态机可以写成:

事件 活跃题目 状态文字
点击 当前题 ID 正在播放示例
onStart 当前题 ID 正在播放示例
onComplete 清空 清空
onStop 清空 清空
onError 清空 播放失败
关闭弹窗 清空 清空
页面退出 引擎停止并销毁 页面不再接收状态

错误回调接收了 errorCodeerrorMessage,但当前没有记录或分级处理。发布版本不应把底层错误直接展示给儿童用户,不过开发日志可以记录错误码,便于区分引擎缺失、资源不可用、服务异常或请求参数问题。

八、关闭弹窗必须同时停止声音

文字提示弹窗提供遮罩点击、右上角"关闭"和"再听一次"。关闭动作执行:

ts 复制代码
private closeAudioDialog(): void {
  if (this.ttsEngine) {
    try {
      this.ttsEngine.stop()
    } catch (_) {
    }
  }
  this.showAudioDialog = false
  this.activeAudioQuestionId = ''
  this.audioStatusText = ''
}

如果只把 showAudioDialog 设为 false,声音仍会继续,用户会认为页面失控。视图状态和媒体状态必须一起收敛。

"再听一次"的源码只在引擎已存在时执行:

ts 复制代码
if (this.ttsEngine) {
  if (this.ttsEngine.isBusy()) {
    this.ttsEngine.stop()
  }
  this.ttsEngine.speak(
    this.audioDialogText,
    { requestId: `replay_${Date.now()}` }
  )
}

若首次初始化失败,按钮点击不会重新调用 ensureTtsEngine(),也没有额外提示。更完整的实现应复用统一朗读入口,让重试能够再次初始化,并在失败时保持明确状态。

ts 复制代码
private async replayAudio(): Promise<void> {
  const ready = await this.ensureTtsEngine()
  if (!ready || !this.ttsEngine) {
    this.audioStatusText =
      '语音暂不可用,请阅读文字提示'
    return
  }
  // 停旧请求并发起新请求
}

九、页面退出时 stop 与 shutdown 缺一不可

aboutToDisappear() 负责清理计时器和语音引擎:

ts 复制代码
aboutToDisappear(): void {
  if (this.timerId !== -1) {
    clearInterval(this.timerId)
  }

  if (this.ttsEngine) {
    try {
      this.ttsEngine.stop()
      this.ttsEngine.shutdown()
    } catch (_) {
    }
    this.ttsEngine = undefined
  }
}

stop() 终止当前朗读,shutdown() 释放引擎资源,随后把引用设为 undefined。下次重新进入页面时,ensureTtsEngine() 会创建新实例。

只调用 stop() 可能留下引擎资源;只调用 shutdown() 而不先停止,行为更难预测。页面生命周期是媒体能力的所有权边界:页面创建或首次使用时获得引擎,页面消失时释放。

异步初始化还有一个竞态:用户点击播放后立即退出,createEngine() 可能在页面消失后才完成。当前 aboutToDisappear() 执行时 ttsEngine 仍是空,随后异步结果写回页面字段。可增加页面活跃标识:

ts 复制代码
private pageActive: boolean = false

aboutToAppear(): void {
  this.pageActive = true
  // 原有参数加载
}

aboutToDisappear(): void {
  this.pageActive = false
  this.releaseTts()
}

创建完成后检查:

ts 复制代码
const engine =
  await textToSpeech.createEngine(config)

if (!this.pageActive) {
  engine.shutdown()
  return false
}

this.ttsEngine = engine

这样,页面已经退出的异步结果不会重新占用资源。

十、切题、答题和语音状态要统一

练习页不仅有"下一题",还有答题卡跳转、自动下一题、错题解析和考试模式。当前 goNext() 在进入新题时会清空:

ts 复制代码
this.selectedKey = ''
this.showAnalysis = false
this.activeAudioQuestionId = ''
this.audioStatusText = ''

答题卡点击也会清空 activeAudioQuestionId。但单纯清空 UI 状态不等于停止引擎。如果未来语音弹窗不再阻塞切题,或加入自动朗读,就应把"停止当前朗读"抽成统一方法:

ts 复制代码
private stopCurrentAudio(): void {
  if (this.ttsEngine) {
    try {
      if (this.ttsEngine.isBusy()) {
        this.ttsEngine.stop()
      }
    } catch (_) {
    }
  }
  this.activeTtsRequestId = ''
  this.activeAudioQuestionId = ''
  this.audioStatusText = ''
}

所有题目切换路径都先调用它:

  • 点击下一题;
  • 自动进入下一题;
  • 答题卡跳题;
  • 考试自动提交;
  • 返回上一页;
  • 关闭提示弹窗。

媒体状态不能散落在多个按钮里,否则新增一个导航路径就可能漏掉清理。

十一、UI 中的波形只是装饰

提示卡和弹窗使用固定高度数组绘制柱状波形:

ts 复制代码
ForEach(
  [3, 6, 10, 8, 5, 12, 7, 4, 9],
  (height: number, index: number) => {
    Column()
      .width(3)
      .height(height)
      .backgroundColor(Colors.PRIMARY)
  }
)

这些柱形不会读取真实音频振幅,也不会随播放进度变化。它们只是视觉提示,不能描述成实时声波、音量检测或语音分析。

若要表达真实播放状态,可以使用简单、可验证的状态动画:

  • idle:静态图标;
  • preparing:加载指示;
  • speaking:循环高度动画;
  • error:错误图标和文字;
  • completed:恢复静态状态。

即使做动画,也应称为播放状态动效,而不是实时音频波形,除非确实接入了音频振幅数据。

十二、无权限不代表无合规边界

当前模块没有请求麦克风权限,这与"只输出语音、不采集用户声音"的实现一致。也没有看到录音、声纹、上传语音或语音识别链路。

但在线 TTS 回退仍需要在发布前核对:

  • 在线模式是否需要网络权限;
  • 文字是否会离开设备;
  • 使用的系统或 HMS 服务是否涉及服务声明;
  • 离线不可用、网络不可用时是否仍可完成答题;
  • 隐私政策是否与真实数据流一致。

如果无法确认在线文本处理边界,保守方案是只启用离线引擎,并保留文字弹窗。不能为了提高播放成功率而悄悄增加网络能力,再继续把应用描述成完全离线。

本文不宣称在线回退当前必然可用。源码只体现了创建参数中的 online: 1 尝试,实际能力仍需在目标设备和发布配置上验证。

十三、audio 题型还缺少共享标签

questionTypeLabel() 目前只处理:

ts 复制代码
'add', 'sub', 'mul', 'div',
'mixed', 'word', 'speed'

没有 audio 分支,因此旧语音题进入练习页时,题型标签会走默认值"题目"。这不会阻止播放,但会让语义不完整。

如果决定恢复该题型,应同步补齐:

ts 复制代码
case 'audio':
  return '听题练习'

还要同步检查:

  • 分类目录是否需要出现该类型;
  • 搜索页是否允许筛选;
  • 题库计数是否统计它;
  • 题目图标和颜色是否有对应配置;
  • 测试数据是否真的包含 audioHint
  • 上架描述是否准确说明为 TTS 提示。

不能只在练习页增加一个分支,就认为新题型已经贯穿完整产品链。

十四、推荐的语音状态模型

当前页面用多个字符串和布尔值表达状态:

ts 复制代码
activeAudioQuestionId
audioStatusText
showAudioDialog
audioDialogText

功能继续扩展时,可以引入明确状态:

ts 复制代码
type AudioPhase =
  'idle' |
  'preparing' |
  'speaking' |
  'stopped' |
  'error'

interface AudioUiState {
  phase: AudioPhase
  questionId: string
  requestId: string
  message: string
}

状态转换集中处理:

ts 复制代码
private setAudioState(
  next: AudioUiState
): void {
  this.audioPhase = next.phase
  this.activeAudioQuestionId =
    next.questionId
  this.activeTtsRequestId =
    next.requestId
  this.audioStatusText =
    next.message
}

这样更容易回答"按钮此刻能否点击""旧回调能否更新 UI""关闭弹窗后是否仍在 preparing"等问题。状态模型不是为了追求形式,而是把异步边界写成可检查规则。

十五、常见故障与排查顺序

现象 优先检查 修复方向
普通口算题没有播放入口 题型是否为 audio 且有 audioHint 当前默认题库本就不会触发
点击后只有文字没有声音 离线与在线引擎是否都失败 保留文字提示并记录错误码
退出页面后仍在朗读 aboutToDisappear() 是否执行 stop + shutdown + undefined
新题播放但 UI 显示空闲 旧请求回调是否覆盖新状态 比较 requestId
重播按钮无反应 引擎是否初始化失败 重播时再次调用 ensureTtsEngine()
两段语音重叠 新请求前是否停止旧任务 isBusy()stop()
页面退出后引擎又创建 异步初始化是否晚返回 增加 pageActive 检查
题型标签只显示"题目" 标签映射是否缺少 audio 补齐共享题型配置
波形不随声音变化 固定高度数组是装饰 不描述成实时波形
误以为可语音答题 是否存在 ASR 和麦克风权限 明确当前只有 TTS 输出

排查时建议记录 questionIdrequestId、页面是否活跃、引擎是否存在、isBusy() 和最后一次回调。不要记录用户隐私文本或任何认证信息。

十六、可执行测试矩阵

真实设备测试至少覆盖:

场景 操作 预期
默认题库 打开普通加减乘除题 不出现旧 audio 提示入口
兼容题 注入 audio + audioHint 显示提示卡
离线引擎可用 点击播放 朗读并显示播放状态
离线不可用、在线可用 点击播放 尝试在线模式
两种引擎都不可用 点击播放 文字弹窗仍可阅读
连续点击 重复播放 旧任务停止,不重叠
快速关闭 播放后关闭弹窗 声音立即停止
快速退出 初始化期间返回 不残留引擎
旧回调晚到 新请求已开始 不清空新请求状态
重播 点击"再听一次" 使用同一文本重新朗读
无语音 阅读提示并选择答案 答题流程完整可用

还要检查手机、平板和 2in1:

  • 弹窗底部避开系统导航区;
  • 长提示文本不会挤出按钮;
  • 窄屏内部可滚动;
  • 横屏或小窗口中关闭按钮始终可见;
  • 外接键盘焦点能到达关闭与重播操作;
  • 深浅色下文字、图标和遮罩对比度可读。

十七、发布前复核清单

面向 HarmonyOS 5.0 及以上版本发布前,应确认:

  • 当前能力准确命名为文字转语音提示,而非语音识别;
  • 默认题库没有语音题时,不在介绍中声称全题库可朗读;
  • 离线引擎优先策略经过目标设备验证;
  • 在线回退的权限、网络和隐私边界已核对;
  • TTS 失败不阻断文字阅读和选项作答;
  • 每次新朗读前停止旧任务;
  • 回调用 requestId 隔离旧请求;
  • 页面退出执行 stop()shutdown()
  • 异步创建结果不会在页面退出后重新挂载;
  • 关闭、切题、答题卡跳题和自动提交都能停止朗读;
  • audio 题型恢复时同步补齐标签、分类和测试数据;
  • 固定柱状动效没有被描述成实时声波;
  • 无麦克风权限、无录音和无语音上传的事实与隐私材料一致;
  • 弹窗在手机、平板和 2in1 的安全区内可完整操作。

总结

语音报题不是一次 speak() 调用,而是一条由题目条件、文本准备、引擎创建、请求标识、回调状态、文字降级和生命周期释放共同组成的链路。口算王当前源码已经提供离线优先、在线尝试、文字弹窗、停止旧播放和页面退出销毁等基础能力,但它只服务于旧 audio 题型,默认题库并未触发。

继续完善时,应优先增加请求级状态隔离、页面活跃检查、统一停止入口和重试路径,再决定是否把朗读扩展到普通题型。只要声音失败时仍能理解题目、旧请求不能污染新题、页面退出后不残留资源,这项增强能力才真正服从答题主流程,而不是反过来干扰它。

本文由 AI 辅助整理,所有现有能力边界、代码路径与权限结论均依据项目真实源码复核。

相关推荐
贾伟康3 小时前
【口算王|08】HarmonyOS ArkTS 分类训练实战:让年级与运算分类参数保持一致
harmonyos·arkts·数据建模·路由参数·分类训练
光锥智能3 小时前
华为新麒麟芯片、鸿蒙7问世,手机底层竞赛升级
华为·智能手机·harmonyos
贾伟康4 小时前
【口算王|09】HarmonyOS ArkTS 学习统计实战:计算连续训练和正确率趋势
harmonyos·arkts·数据可视化·preferences·学习统计
lqj_本人4 小时前
Flutter 鸿蒙实战:用 wakelock_plus 三方库给阅读页加上屏幕常亮
flutter·华为·harmonyos
aqi0015 小时前
鸿蒙版本的JSBridge兼容与安卓配套的H5啦
android·华为·harmonyos·鸿蒙·移动应用
熊猫钓鱼>_>15 小时前
Flutter app_settings 鸿蒙适配实战:Intent 体系到 Want 的跨越
flutter·华为·harmonyos·openharmony·intent·want
贾伟康18 小时前
【句匠|20】HarmonyOS ArkTS AppGallery 发布复查实战:核对包名、版本、设备、素材和离线声明
harmonyos·arkts·应用上架·appgallery·发布审核
贾伟康19 小时前
【句匠|15】HarmonyOS ArkTS 本地状态持久化实战:让保存、删除和页面返回后的数据即时一致
harmonyos·arkts·数据持久化·appstorage·preferences
贾伟康1 天前
【句匠|16】HarmonyOS ArkTS 多设备布局实战:适配手机、平板和 PC/2in1 的窗口变化
harmonyos·arkts·arkui·响应式布局·多设备适配