【口算王|10】HarmonyOS ArkTS 语音报题实战:协调朗读、作答和页面生命周期
证据边界:本文的“当前实现”来自本轮对本地口算王工程中
PracticePage.ets、MathModels.ets、题库数据与模块配置的静态复核。可以确认的是 TTS 引擎创建、文字回退弹窗、播放状态和生命周期清理代码;文中并发令牌、统一状态机、真机音频质量与性能数据属于建议或待验证内容。本轮未执行构建、安装、真机朗读、网络回退或音频测量。
给练习页加一个“点击播放”入口并不难,难的是让语音状态与题目状态保持一致。用户可能在引擎初始化期间关闭弹窗,可能连续点击两道题,也可能在朗读尚未结束时退出页面。如果代码只调用一次speak(),没有处理停止、完成、错误和页面销毁,上一题的声音就可能在下一题继续播放,甚至页面退出后仍占用语音引擎。
另一个常见误解是把文字转语音、语音识别和语音作答混为一谈。文字转语音只负责把题目文本朗读出来,不会监听麦克风,也不会识别用户答案。若应用没有申请麦克风权限、没有语音识别服务和答案映射,就不能把功能描述成“语音答题”。
本文基于口算王项目本地口算王工程(脱敏路径)的真实源码,复核PracticePage.ets、MathModels.ets、ThemeConstants.ets、module.json5与题库数据。包名com.jiaweikang.one16是本文草稿核验使用的唯一标记。当前代码保留了旧audio题型的点击朗读能力:优先尝试离线 TTS,失败后尝试在线引擎,并始终显示文字提示弹窗作为降级路径;当前默认题库没有type === 'audio'或audioHint数据,所以正常口算流程不会显示该入口。本文讨论的是这条可复核的兼容链路及其工程完善方法,不把它写成默认题库已经启用的语音功能。
本文重点解决五个问题:
- 明确当前语音入口的真实触发条件和能力边界;
- 拆解离线优先、在线回退、文字兜底的初始化流程;
- 协调朗读请求、弹窗、切题、作答与页面退出;
- 用请求标识避免旧回调覆盖新题状态;
- 建立无引擎、无网络、快速切换和生命周期测试矩阵。
一、语音入口只为旧 audio 题型渲染
练习页并不会给每一道口算题显示播放按钮。真实条件是:
if ( this.currentQ()!.type === 'audio' && this.currentQ()!.audioHint ) { // 渲染提示卡和点击播放入口 }两个条件必须同时满足:
Question.type等于audio;Question.audioHint有值。
题目模型只把audioHint声明为可选字段:
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 代码,就宣称所有题目都支持自动报题。
二、它是文字转语音,不是语音识别
页面导入的是:
import textToSpeech from '@hms.ai.textToSpeech'代码把文字交给语音引擎输出声音,没有创建录音器,没有读取麦克风,也没有把用户说出的内容转换成答案。module.json5中的:
"requestPermissions": []进一步说明当前模块没有声明麦克风权限。作答仍然通过 ArkUI 选项点击完成:
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
页面通过一个小函数生成朗读内容:
private audioText(question: Question): string { return ( question.audioHint || question.stem ) .replace(/["“”]/g, '') .trim() }优先使用audioHint,为空时回退到题干stem,同时去掉中英文引号并清理首尾空格。这样可以避免引擎把引号读成不自然停顿,也允许旧题目用更适合听觉表达的提示文字。
不过当前 UI 的显示条件要求audioHint存在,所以正常点击入口不会触发stem回退。这个回退属于方法级防御,不等于所有普通题干都可点击朗读。
若未来要让全部题目支持报题,可以把“是否显示入口”和“读什么文本”拆开:
private canReadQuestion( question?: Question ): boolean { return question !== undefined && this.audioText(question).length > 0 }然后再定义题型朗读规则。例如选择题可读题干和选项,应用题只读题干,公式中的符号需转换成自然语言。不能简单把任意 ArkTS 字符串都交给 TTS,再假设可听懂。
四、引擎初始化采用离线优先、在线回退
ensureTtsEngine()先检查现有实例,避免每次点击都重新创建:
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 }真实策略是:
- 已有引擎时直接复用;
- 优先创建中文离线引擎;
- 离线创建失败后尝试在线模式;
- 两次都失败时返回
false并更新状态文字。
离线优先能减少网络依赖和等待,但是否成功仍取决于设备是否提供对应语言资源。在线回退也不能被理解为必然成功:当前module.json5没有声明网络权限,设备服务状态和系统能力也可能不同。源码没有执行网络能力预检,所以文章只能说“尝试在线引擎”,不能承诺离线缺失时一定可播放。
五、文字弹窗是核心降级能力
点击提示卡时,页面先更新 UI,再后台尝试 TTS:
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():
if (this.ttsEngine.isBusy()) { this.ttsEngine.stop() } this.ttsEngine.speak(text, { requestId })这能避免两个提示同时朗读。用户重复点击“再听一次”或快速触发新内容时,旧任务先停,新任务再开始。
不过“调用 stop 后立即 speak”仍然需要关注回调顺序。旧请求可能触发onStop,新请求随后触发onStart。当前监听器不比较requestId:
onStop: (requestId: string) => { this.activeAudioQuestionId = '' this.audioStatusText = '' }如果旧请求的onStop晚于新请求的onStart到达,就可能把新题的播放状态清空。声音仍在播放,UI 却显示未播放。
更稳的方式是保存当前请求标识:
private activeTtsRequestId: string = '' private isActiveRequest( requestId: string ): boolean { return requestId === this.activeTtsRequestId }发起朗读时:
const requestId = `${question.id}_${Date.now()}` this.activeTtsRequestId = requestId this.ttsEngine.speak(text, { requestId })监听器只处理当前请求:
onComplete: (requestId: string) => { if (!this.isActiveRequest(requestId)) { return } this.activeTtsRequestId = '' this.activeAudioQuestionId = '' this.audioStatusText = '' }这不是为了增加复杂度,而是为了让异步回调不能修改已被后续操作替代的状态。
七、监听器把引擎事件映射成页面状态
当前代码注册四类事件:
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 | 清空 | 播放失败 |
| 关闭弹窗 | 清空 | 清空 |
| 页面退出 | 引擎停止并销毁 | 页面不再接收状态 |
错误回调接收了errorCode和errorMessage,但当前没有记录或分级处理。发布版本不应把底层错误直接展示给儿童用户,不过开发日志可以记录错误码,便于区分引擎缺失、资源不可用、服务异常或请求参数问题。
八、关闭弹窗必须同时停止声音
文字提示弹窗提供遮罩点击、右上角“关闭”和“再听一次”。关闭动作执行:
private closeAudioDialog(): void { if (this.ttsEngine) { try { this.ttsEngine.stop() } catch (_) { } } this.showAudioDialog = false this.activeAudioQuestionId = '' this.audioStatusText = '' }如果只把showAudioDialog设为false,声音仍会继续,用户会认为页面失控。视图状态和媒体状态必须一起收敛。
“再听一次”的源码只在引擎已存在时执行:
if (this.ttsEngine) { if (this.ttsEngine.isBusy()) { this.ttsEngine.stop() } this.ttsEngine.speak( this.audioDialogText, { requestId: `replay_${Date.now()}` } ) }若首次初始化失败,按钮点击不会重新调用ensureTtsEngine(),也没有额外提示。更完整的实现应复用统一朗读入口,让重试能够再次初始化,并在失败时保持明确状态。
private async replayAudio(): Promise<void> { const ready = await this.ensureTtsEngine() if (!ready || !this.ttsEngine) { this.audioStatusText = '语音暂不可用,请阅读文字提示' return } // 停旧请求并发起新请求 }九、页面退出时 stop 与 shutdown 缺一不可
aboutToDisappear()负责清理计时器和语音引擎:
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仍是空,随后异步结果写回页面字段。可增加页面活跃标识:
private pageActive: boolean = false aboutToAppear(): void { this.pageActive = true // 原有参数加载 } aboutToDisappear(): void { this.pageActive = false this.releaseTts() }创建完成后检查:
const engine = await textToSpeech.createEngine(config) if (!this.pageActive) { engine.shutdown() return false } this.ttsEngine = engine这样,页面已经退出的异步结果不会重新占用资源。
十、切题、答题和语音状态要统一
练习页不仅有“下一题”,还有答题卡跳转、自动下一题、错题解析和考试模式。当前goNext()在进入新题时会清空:
this.selectedKey = '' this.showAnalysis = false this.activeAudioQuestionId = '' this.audioStatusText = ''答题卡点击也会清空activeAudioQuestionId。但单纯清空 UI 状态不等于停止引擎。如果未来语音弹窗不再阻塞切题,或加入自动朗读,就应把“停止当前朗读”抽成统一方法:
private stopCurrentAudio(): void { if (this.ttsEngine) { try { if (this.ttsEngine.isBusy()) { this.ttsEngine.stop() } } catch (_) { } } this.activeTtsRequestId = '' this.activeAudioQuestionId = '' this.audioStatusText = '' }所有题目切换路径都先调用它:
- 点击下一题;
- 自动进入下一题;
- 答题卡跳题;
- 考试自动提交;
- 返回上一页;
- 关闭提示弹窗。
媒体状态不能散落在多个按钮里,否则新增一个导航路径就可能漏掉清理。
十一、UI 中的波形只是装饰
提示卡和弹窗使用固定高度数组绘制柱状波形:
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()目前只处理:
'add', 'sub', 'mul', 'div', 'mixed', 'word', 'speed'没有audio分支,因此旧语音题进入练习页时,题型标签会走默认值“题目”。这不会阻止播放,但会让语义不完整。
如果决定恢复该题型,应同步补齐:
case 'audio': return '听题练习'还要同步检查:
- 分类目录是否需要出现该类型;
- 搜索页是否允许筛选;
- 题库计数是否统计它;
- 题目图标和颜色是否有对应配置;
- 测试数据是否真的包含
audioHint; - 上架描述是否准确说明为 TTS 提示。
不能只在练习页增加一个分支,就认为新题型已经贯穿完整产品链。
十四、推荐的语音状态模型
当前页面用多个字符串和布尔值表达状态:
activeAudioQuestionId audioStatusText showAudioDialog audioDialogText功能继续扩展时,可以引入明确状态:
type AudioPhase = 'idle' | 'preparing' | 'speaking' | 'stopped' | 'error' interface AudioUiState { phase: AudioPhase questionId: string requestId: string message: string }状态转换集中处理:
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 输出 |
排查时建议记录questionId、requestId、页面是否活跃、引擎是否存在、isBusy()和最后一次回调。不要记录用户隐私文本或任何认证信息。
十六、可执行测试矩阵
真实设备测试至少覆盖:
| 场景 | 操作 | 预期 |
|---|---|---|
| 默认题库 | 打开普通加减乘除题 | 不出现旧 audio 提示入口 |
| 兼容题 | 注入audio + audioHint | 显示提示卡 |
| 离线引擎可用 | 点击播放 | 朗读并显示播放状态 |
| 离线不可用、在线可用 | 点击播放 | 尝试在线模式 |
| 两种引擎都不可用 | 点击播放 | 文字弹窗仍可阅读 |
| 连续点击 | 重复播放 | 旧任务停止,不重叠 |
| 快速关闭 | 播放后关闭弹窗 | 声音立即停止 |
| 快速退出 | 初始化期间返回 | 不残留引擎 |
| 旧回调晚到 | 新请求已开始 | 不清空新请求状态 |
| 重播 | 点击“再听一次” | 使用同一文本重新朗读 |
| 无语音 | 阅读提示并选择答案 | 答题流程完整可用 |
还要检查手机、平板和 2in1:
- 弹窗底部避开系统导航区;
- 长提示文本不会挤出按钮;
- 窄屏内部可滚动;
- 横屏或小窗口中关闭按钮始终可见;
- 外接键盘焦点能到达关闭与重播操作;
- 深浅色下文字、图标和遮罩对比度可读。
十七、发布前复核清单
面向 HarmonyOS 5.0 及以上版本发布前,应确认:
- 当前能力准确命名为文字转语音提示,而非语音识别;
- 默认题库没有语音题时,不在介绍中声称全题库可朗读;
- 离线引擎优先策略经过目标设备验证;
- 在线回退的权限、网络和隐私边界已核对;
- TTS 失败不阻断文字阅读和选项作答;
- 每次新朗读前停止旧任务;
- 回调用
requestId隔离旧请求; - 页面退出执行
stop()与shutdown(); - 异步创建结果不会在页面退出后重新挂载;
- 关闭、切题、答题卡跳题和自动提交都能停止朗读;
audio题型恢复时同步补齐标签、分类和测试数据;- 固定柱状动效没有被描述成实时声波;
- 无麦克风权限、无录音和无语音上传的事实与隐私材料一致;
- 弹窗在手机、平板和 2in1 的安全区内可完整操作。
总结
语音报题不是一次speak()调用,而是一条由题目条件、文本准备、引擎创建、请求标识、回调状态、文字降级和生命周期释放共同组成的链路。口算王当前源码已经提供离线优先、在线尝试、文字弹窗、停止旧播放和页面退出销毁等基础能力,但它只服务于旧audio题型,默认题库并未触发。
继续完善时,应优先增加请求级状态隔离、页面活跃检查、统一停止入口和重试路径,再决定是否把朗读扩展到普通题型。只要声音失败时仍能理解题目、旧请求不能污染新题、页面退出后不残留资源,这项增强能力才真正服从答题主流程,而不是反过来干扰它。
十八、建议实现:用请求令牌隔离迟到回调
仅在播放前调用stop()还不能完全消除竞态。旧引擎请求可能已经进入系统服务,用户切题后,迟到的完成或失败事件仍有机会改写新题目的状态。更稳的做法是为每次朗读分配递增令牌,监听器只处理当前令牌对应的事件;题目变化、弹窗关闭和页面退出都会让旧令牌失效。
private speechToken: number = 0 private beginSpeech(question: Question): number { this.speechToken += 1 this.activeAudioQuestionId = question.id this.audioStatusText = '正在准备朗读' return this.speechToken } private acceptSpeechEvent(token: number): boolean { return token === this.speechToken } private cancelSpeech(): void { this.speechToken += 1 this.ttsEngine?.stop() this.activeAudioQuestionId = '' this.audioStatusText = '' }令牌的职责只是判断事件是否仍属于当前操作,不替代引擎自己的requestId。页面可以继续把题目 ID 用于按钮选中态,把令牌用于异步事件归属。这样即使连续点击两道题,第一道题的迟到回调也不会把第二道题的“正在朗读”清空。上面是建议实现,当前源码尚未建立这一层令牌门禁。
十九、可访问性与失败降级应和朗读同等重要
语音题不能把“听见声音”作为唯一成功路径。当前实现先显示包含题干与朗读文本的弹窗,再尝试创建引擎,这是合理的降级顺序:设备缺少语音服务、离线音库未安装、在线能力不可用或播放失败时,用户仍能读到完整内容并继续作答。后续优化应继续保证弹窗可滚动、关闭按钮可聚焦、错误提示不会覆盖题干,且朗读按钮具有明确的可访问性描述。
真机验收至少覆盖离线引擎可用、只能在线回退、两种引擎都不可用、快速重复点击、切换题目、提交答案、关闭弹窗、按返回键和应用进入后台。每个场景都要核对声音是否停止、状态文案是否复位、旧题是否还保持高亮、返回页面后引擎是否被重复创建。若设备没有录音权限弹窗是正常的,因为这里是文字转语音而不是语音识别;如果新增语音答题,权限、隐私和交互流程必须另行设计。
CSDN-SERIES:ALL-163188689