【中国方言题库|10】HarmonyOS ArkTS 语音播放实战:管理读音播放与页面生命周期
证据边界:本轮只复核 PracticePage、题目模型、题库数据与模块配置的静态源码,没有执行构建、设备播放、断网回退或引擎故障测试。当前实现把 audioHint 文本交给 zh-CN TTS 合成,不是预置音频文件或真人方言录音;requestId 虽已生成,但回调状态没有按请求标识过滤,快速重复点击仍需运行验证。
摘要:中国方言题库的听音能力并不是播放预置方言音频文件,而是在
PracticePage中调用@hms.ai.textToSpeech,把题目的audioHint文本交给系统语音引擎合成。实现采用本地引擎优先、在线引擎回退的策略,用监听器同步开始、完成、停止和错误状态;点击播放时先打开文字兜底弹窗,再尽力启动 TTS;页面离开时停止并关闭引擎。本文面向 HarmonyOS 5.0 及以上版本,基于真实 ArkTS 源码复核引擎创建、重复点击、请求标识、状态恢复、底部弹窗和生命周期释放,并明确zh-CN合成不能等同于真实方言录音。
一、先说清楚:这是 TTS 合成,不是真实方言音频
项目的Question模型定义:
export interface Question { id: string bankId: string chapterId: string type: string stem: string audioHint?: string options: Option[] answer: string analysis: string example?: string }audioHint的注释是“听音题占位描述”。题目数据中存放的是:
{ type: 'audio', stem: '听音选择:这段四川话表达的意思是?', audioHint: '莫挨老子', options: [ '请离我远点', '请帮我搬东西', '我饿了', '走慢点' ], answer: 0, analysis: '“莫挨老子”表示“别靠近我”,语气较强。' }源码没有.mp3、.wav资源地址,也没有媒体播放器。它使用language: 'zh-CN'的文本转语音引擎朗读短语。因此可以描述为“系统语音合成示例”,不能宣传成“真人方言发音”“标准母语者录音”或“原生方言音色”。
唯一校验标记:CSDN-SERIES:ALL-163195219。
二、真实调用链位于 PracticePage
语音能力的真实核心文件是:
entry/src/main/ets/pages/PracticePage.ets entry/src/main/ets/mock/MockBanks.ets librarya/src/main/ets/models/Dialect.ets entry/src/main/module.json5调用链为:
音频题卡片点击 -> toggleAudioPreview(question) -> 立即打开文字弹窗 -> ensureTtsEngine() -> 本地引擎或在线引擎 -> speak(audioText) -> listener 更新状态 -> close / 页面离开时 stop -> 页面离开时 shutdown页面详情模块本身不负责播放,播放能力跟随答题会话存在。这样语音状态、当前题目和答题弹窗属于同一个组件生命周期。
三、为什么引擎不是 @State
页面声明:
private ttsEngine?: textToSpeech.TextToSpeechEngine = undefined引擎对象是平台资源句柄,不是直接渲染到 UI 的业务数据,因此不需要@State。真正驱动界面的状态是:
@State activeAudioQuestionId: string = '' @State audioStatusText: string = '' @State showAudioDialog: boolean = false @State audioDialogText: string = '' @State audioDialogStem: string = ''这形成清晰分工:
| 类型 | 字段 | 作用 |
|---|---|---|
| 平台资源 | ttsEngine | 创建、朗读、停止、关闭 |
| 活动目标 | activeAudioQuestionId | 标记当前播放题目 |
| 用户反馈 | audioStatusText | 显示播放或错误状态 |
| 弹窗状态 | showAudioDialog | 决定兜底弹窗是否可见 |
| 弹窗内容 | audioDialogText | 显示可参考的发音文本 |
平台对象和声明式状态分开,页面重建时不会把引擎当作普通可观察数据反复替换。
四、文本来源优先 audioHint
页面准备朗读文本:
private audioText(question: Question): string { return ( question.audioHint || question.stem ).replace(/["“”]/g, '').trim() }优先使用audioHint,缺失时回退到题干。随后去除直引号和中文引号,并清理首尾空格。
例如:
audioHint = "“莫挨老子”" output = "莫挨老子"这样可以避免 TTS 把引号或题目提示一并朗读。由于 UI 只有在type === 'audio' && audioHint时显示播放区,正常路径几乎总会使用audioHint;题干回退主要是方法级防御。
五、本地引擎优先,在线引擎回退
ensureTtsEngine()先检查是否已有引擎:
if (this.ttsEngine) return true随后尝试本地模式:
this.ttsEngine = await textToSpeech.createEngine({ language: 'zh-CN', person: 0, online: 0 })失败后再尝试在线模式:
this.ttsEngine = await textToSpeech.createEngine({ language: 'zh-CN', person: 0, online: 1 })两次都失败时:
this.audioStatusText = '当前设备语音引擎不可用' return false这个顺序能优先利用设备本地语音服务;在线模式是降级补充。项目的module.json5真实声明了:
"requestPermissions": [ { "name": "ohos.permission.INTERNET" } ]因此不能把整个语音链路描述成“纯离线”。即使本地引擎通常可用,代码仍明确包含在线回退。
六、引擎复用避免每次点击重复创建
这句短路:
if (this.ttsEngine) return true意味着首次点击完成创建后,后续题目复用同一个引擎。创建平台能力通常比调用speak()更重,复用能降低延迟和资源波动。
但复用有一个前提:引擎一旦进入不可恢复错误,当前代码仍保留对象,下一次ensureTtsEngine()会直接返回true。如果平台错误需要重建,onError中应考虑关闭并置空:
try { this.ttsEngine?.shutdown() } catch (_) { } this.ttsEngine = undefined当前源码的错误监听只更新 UI,没有自动重建。这是可改进项,不是现成功能。
七、监听器把平台回调映射为页面状态
引擎创建成功后设置监听器:
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 = '语音播放失败,请检查系统语音服务' } })四个回调覆盖了开始、自然完成、主动停止和失败。UI 不需要主动轮询引擎,而是由回调收敛状态。
八、requestId 已生成,但回调没有校验
播放请求使用:
requestId: `${question.id}_${Date.now()}`这个 ID 同时包含题目和时间,能区分连续请求。但监听器接收requestId后没有判断它是否仍是最新请求。
可能的竞态是:
- 用户播放 A;
- 很快切换并播放 B;
- A 的停止回调晚到;
- 回调清空 B 的活动状态。
当前页面在新播放前调用stop(),大多数情况下足够,但严格状态机可以保存:
private activeAudioRequestId: string = ''回调只处理匹配的请求:
if (requestId !== this.activeAudioRequestId) return这能防止旧请求改变新请求的 UI。现有源码尚未做该校验。
九、点击后先打开弹窗,再等待引擎
toggleAudioPreview()的顺序是:
this.activeAudioQuestionId = question.id this.audioStatusText = '正在播放示例' this.audioDialogText = this.audioText(question) this.audioDialogStem = question.stem this.showAudioDialog = true完成 UI 状态后,才异步执行:
const ready = await this.ensureTtsEngine()这个顺序非常关键。用户点击后立刻得到可见反馈,不会因为引擎创建耗时而误以为按钮失效。即使引擎不可用,弹窗仍显示发音文字,用户仍可继续完成题目。
十、播放前先停止旧内容
引擎准备完成后:
if (this.ttsEngine.isBusy()) { this.ttsEngine.stop() }再发起新请求:
this.ttsEngine.speak( this.audioText(question), { requestId: `${question.id}_${Date.now()}` } )这避免两段语音同时播放或排队叠加。对于用户连续点不同听音题,最新意图应优先。
需要注意,方法名叫toggleAudioPreview,但行为不是“点击同一项切换播放/暂停”:无论当前是否播放,都会打开弹窗并重新请求。更准确的命名是openAudioPreview()或playAudioPreview()。
十一、外层 try/catch 为什么保证弹窗可用
语音尝试被包在:
try { const ready = await this.ensureTtsEngine() if (ready && this.ttsEngine) { // stop + speak } } catch (_) { }即使isBusy()、stop()或speak()抛出异常,前面设置的showAudioDialog=true仍然有效。注释也明确写着:
后台尽力尝试 TTS,失败也不影响弹窗使用这体现了能力降级原则:平台语音是增强能力,文字弹窗是任务可完成的基础路径。
不过空catch会让audioStatusText可能长期停留在“正在播放示例”。更好的做法是在异常分支显式写入失败状态。
十二、为什么文字兜底对听音题很重要
弹窗展示:
Text(this.audioDialogText)并提示:
部分设备未开启语音服务时, 可参考上方文字辨识发音并选出正确答案。这确保设备没有语音服务时,用户不会卡死在无法作答的题目。对于教育题库,功能完整性比强制依赖某个引擎更重要。
但文字提示会降低纯听力测验的难度。产品应明确它是“学习辅助模式”还是“严格听力考试”。当前实现选择了可完成性优先,不能宣传为封闭式听力测评。
十三、zh-CN 合成不能承诺方言音准
创建参数固定为:
language: 'zh-CN' person: 0系统会按可用的中文普通话语音能力合成。“唔该晒”“阿拉上海宁”“涯爱转屋下”等文本可能被按普通话字音朗读,不能保证声调、变调、声母韵母和地域口音符合对应方言。
若产品需要可评测的方言发音,应使用:
- 获得授权的真人录音;
- 明确支持目标方言的语音模型;
- 音频资源版本与题目版本绑定;
- 播放失败时保留文本兜底;
- 在隐私和版权材料中说明音频来源。
当前源码没有这些录音资源,本文不能伪造。
十四、活动题目标记驱动卡片视觉
播放卡片颜色根据:
this.activeAudioQuestionId === this.currentQ()!.id活动时,图标与波形使用主色;非活动时使用提示色。文案也会切换:
active ? `${audioHint} · ${audioStatusText || '正在播放示例'}` : `${audioHint} · 点击播放`activeAudioQuestionId不保存布尔值,而是保存题目 ID,这比isPlaying更能说明“哪一道题正在播放”。当页面允许多个可播放项时,这种状态更可靠。
十五、波形只是静态视觉,不是音频分析
播放卡片和弹窗中的波形来自固定高度数组:
ForEach( [3, 6, 10, 8, 5, 12, 7, 4, 9, 6, 3, 8, 11, 5, 7], (h: number) => { Column() .width(3) .height(h) } )它没有读取音频振幅、播放进度或实时频谱。因此应描述为“波形样式装饰”或“播放状态图形”,不能叫实时声波可视化。
十六、关闭弹窗时停止当前语音
关闭逻辑:
private closeAudioDialog(): void { if (this.ttsEngine) { try { this.ttsEngine.stop() } catch (_) { } } this.showAudioDialog = false this.activeAudioQuestionId = '' this.audioStatusText = '' }用户点击关闭按钮或遮罩,都会调用该方法。先尝试停止语音,再收起弹窗并清状态,避免弹窗消失后声音继续播放。
如果停止失败,UI 仍会关闭。此时平台音频是否继续取决于引擎状态,所以页面离开时还有第二层shutdown()保障。
十七、“再听一次”复用现有引擎
弹窗按钮逻辑:
if (this.ttsEngine) { try { if (this.ttsEngine.isBusy()) { this.ttsEngine.stop() } this.ttsEngine.speak( this.audioDialogText, { requestId: `replay_${Date.now()}` } ) } catch (_) { } }只有引擎已经创建成功时才执行。若首次创建失败,按钮不会再次调用ensureTtsEngine(),因此无法自动重试恢复。
更稳妥的实现是让“再听一次”调用统一异步方法:
private async replayAudio(): Promise<void> { const ready = await this.ensureTtsEngine() if (!ready || !this.ttsEngine) return if (this.ttsEngine.isBusy()) { this.ttsEngine.stop() } this.ttsEngine.speak( this.audioDialogText, { requestId: `replay_${Date.now()}` } ) }这是建议方案,当前源码尚未采用。
十八、切换下一题时重置播放标记
goNext()在普通模式中执行:
this.selectedKey = '' this.showAnalysis = false this.activeAudioQuestionId = '' this.audioStatusText = ''在错题解析模式中,applyAnalysisState()也会:
this.activeAudioQuestionId = ''这能避免下一题错误继承上一题的活动颜色。但这里没有主动stop();如果用户在语音仍播放时通过其他交互切题,声音可能继续到自然完成。
严格处理可以在题目切换前统一调用stopAudio()。当前主要关闭路径依赖弹窗关闭,实际交互中用户通常先关闭弹窗。
十九、页面离开时必须 stop 和 shutdown
生命周期释放是:
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()释放引擎资源,最后置空引用。这样返回上一页、跳到结果页或离开答题页后,不会继续占用语音引擎。
唯一需要注意的是stop()和shutdown()放在同一个try中:如果stop()抛出异常,shutdown()可能不会执行。更稳妥的是分两个保护块,或者使用finally确保关闭。
二十、生命周期资源和 UI 状态应统一收口
当前释放引擎后,没有显式重置:
showAudioDialog activeAudioQuestionId audioStatusText页面已经离开时通常不会继续渲染,因此问题不大。但如果组件实例因导航缓存后再次出现,旧弹窗状态可能需要重新确认。
可以提供统一方法:
private releaseAudio(): void { try { this.ttsEngine?.stop() } catch (_) { } try { this.ttsEngine?.shutdown() } catch (_) { } this.ttsEngine = undefined this.showAudioDialog = false this.activeAudioQuestionId = '' this.audioStatusText = '' }关闭弹窗只停止,页面离开则完整释放,职责会更清晰。
二十一、在线回退带来的权限与隐私边界
项目真实声明ohos.permission.INTERNET,语音创建也设置过online: 1。因此发布材料需要确认:
- 在线语音服务是否实际发送待合成文本;
- 服务由系统还是应用后端提供;
- 隐私政策是否覆盖联网语音处理;
- 无网络时是否仍可完成核心流程;
- 应用是否仍适合描述为“主要本地运行”;
- 语音文本是否含个人信息。
本项目的题目短语来自内置题库,不包含用户输入,风险相对可控,但不能因为文本固定就忽略网络行为说明。
二十二、错误反馈目前有两套语义
引擎创建失败:
当前设备语音引擎不可用播放回调失败:
语音播放失败,请检查系统语音服务这两条能区分“没有可用引擎”和“引擎存在但播放失败”。但toggleAudioPreview()外层异常被吞掉,可能显示错误的“正在播放示例”。
建议把语音状态收窄为联合类型:
type AudioState = | 'idle' | 'preparing' | 'playing' | 'unavailable' | 'failed'显示文案由纯函数映射,避免在多个回调中直接拼接字符串。
二十三、重复点击与并发创建风险
首次点击时ensureTtsEngine()是异步的。若用户在引擎创建完成前快速点击两次,两次调用都可能看到:
this.ttsEngine === undefined然后并发创建两个引擎。后完成的一个覆盖字段,另一个对象可能失去释放路径。
可以缓存创建中的 Promise:
private ttsCreating?: Promise<textToSpeech.TextToSpeechEngine> = undefined所有调用等待同一个 Promise。或者在preparing状态下禁用重复点击。当前源码没有创建锁,这应纳入高频点击测试。
二十四、弹窗布局如何保障小屏可达
听音弹窗使用整页Column:
上部半透明遮罩 -> layoutWeight(1) 底部内容面板 -> 自适应内容高度底部面板 padding 包含:
bottom: Sizes.PADDING_LARGE + this.bottomSafePadding()因此“再听一次”按钮不会压入系统手势区。背景遮罩与“关闭”文字都可退出,用户不会被不可用的 TTS 困在弹窗中。
如果未来增加更长说明、语速或音色控制,底部内容应增加最大高度和内部Scroll,防止横屏或大字体下溢出。
二十五、按钮行为与可访问性
播放入口当前是一个Row,整行绑定onClick;图标本身 40x40,外层还有 12vp padding,触控范围较好。活动状态不仅通过颜色表达,还带有“正在播放示例”文字。
仍可补充:
- 播放入口的可访问性文本;
- “再听一次”禁用态;
- 引擎准备时的进度反馈;
- 错误状态下“重试”动作;
- 2in1 键盘焦点顺序;
- 屏幕阅读器对波形装饰的忽略。
装饰性波形不应被逐条朗读。
二十六、测试矩阵要覆盖资源生命周期
| 场景 | 期望 |
|---|---|
| 首次播放且本地引擎可用 | 创建一次并开始朗读 |
| 本地失败、在线可用 | 在线回退成功 |
| 两种引擎都失败 | 弹窗仍可见,显示不可用 |
| 连续点击两道题 | 旧播放停止,新内容开始 |
| 点击关闭 | 停止播放并清空活动状态 |
| 点击遮罩 | 与关闭按钮行为一致 |
| 点击再听一次 | 停止当前后重新朗读 |
| 播放过程中切题 | 不保留上一题活动样式 |
| 播放过程中离页 | stop、shutdown、引用置空 |
| 回调发生错误 | 显示失败文案,不崩溃 |
| 无网络 | 本地可用则继续,不可用则文字兜底 |
| 横屏、小窗、大字体 | 弹窗按钮可达且不遮挡 |
还应观察日志确认没有重复创建和离页后回调写入失效页面。
二十七、不要为系统能力异常伪造成功状态
语音引擎不可用时,页面没有伪造“播放完成”,而是保留文字兜底。技术文章和测试记录同样应区分:
TTS 本地引擎成功 TTS 在线回退成功 TTS 不可用但文字兜底可用 TTS 播放失败不能把“弹窗打开成功”写成“语音播放成功”,也不能根据波形变色推断系统真的发声。唯一可靠证据是引擎回调与真实设备听测。
二十八、如果要升级为真实方言音频
更高质量方案可以保留现有状态机,但替换音源层:
interface PronunciationSource { kind: 'asset' | 'tts' uri?: string text?: string dialectCode: string speaker?: string license?: string }优先播放授权音频资源,缺失时再使用 TTS,并在 UI 标注来源。资源层还要处理:
- 下载或包内资源;
- 文件校验;
- 播放器状态;
- 耳机和音频焦点;
- 暂停与继续;
- 版权和音色授权;
- 题库版本迁移。
这是一条产品升级路线,不是当前项目已经实现的功能。
二十九、发布前合规检查
语音功能上架前,应验证:
INTERNET权限与在线 TTS 回退一致;- 隐私政策准确描述可能的在线语音处理;
- 不宣称真人录音或标准方言音色;
- 无语音服务时核心答题仍可完成;
- 页面离开后不继续播放;
- 系统深浅色下播放卡片和弹窗可读;
- 手机、平板、2in1 和小窗下按钮不被遮挡;
- 状态栏与底部导航区安全;
- 安装、启动、播放、答题、切题、退出和卸载无异常;
- 不记录或上传用户私密语音;
- 错误文案不暴露内部错误细节;
- 网络断开和系统语音服务关闭均有真实测试结果。
三十、把“能播放”拆成内容、状态与资源三层
语音能力不能只验证扬声器是否发声。内容层要确认传入引擎的是当前题目的 audioHint,缺失时使用明确文字兜底,不能朗读上一题;状态层要确认开始、完成、停止和错误回调只影响对应请求;资源层则要保证页面离开后停止播放并关闭引擎。三层任一失配,都可能出现“有声音但内容错了”或“页面已经离开仍继续播”的问题。
当前 requestId 已用于发起合成,却没有在回调处筛选请求归属。当用户快速切换题目,旧请求的完成回调可能晚于新请求到达,从而错误清除新题目的播放状态。后续应维护活动请求标识,回调先比较再更新 UI;停止旧内容后也要等待或确认状态转换,避免两个请求在引擎内部交叠。
三十一、文字兜底是可用性边界,不是播放成功证明
点击后先打开文字弹窗,使本地引擎不存在、在线引擎不可用或 speak 报错时,用户仍能读到题目提示并继续答题。这种设计保障的是任务可完成,并不代表语音播放成功。界面应区分“正在准备”“正在播放”“播放失败但可阅读”和“已停止”,错误提示不能与成功状态共用同一文案。
在线引擎回退还意味着可能存在网络依赖。是否需要网络权限、数据是否离开设备、服务不可用时如何降级,都应与真实 SDK 行为、隐私说明和发布材料一致。本文只确认代码存在本地优先与在线回退创建策略,不推断实际设备一定安装本地引擎,也不承诺离线必然可播。
三十二、生命周期验收要覆盖快速离开与重建
常规路径是首次播放、完成、关闭弹窗、离开页面。边界路径还包括播放中按返回键、快速切到下一题、连续点击播放、应用切后台、页面重建、引擎创建失败和 shutdown 抛错。每条路径都应检查当前播放标记、弹窗可操作性、扬声器是否停止以及再次进入页面后能否重新创建引擎。
如果 shutdown 只在正常离开调用,而异常路径遗漏,平台资源可能跨页面存活;反过来,如果多次关闭同一引擎没有幂等保护,也可能触发新的错误。测试日志应记录创建次数、活动请求、回调顺序、停止与关闭结果,确认一次页面生命周期只拥有一套有效资源。
三十三、方言产品还需要单独的音质与版权方案
zh-CN 系统合成只能提供普通话或系统音色能力,不能作为四川话、粤语等方言音准证据。若产品目标升级为真实方言听辨,应准备来源清晰、授权明确、发音经过审校的录音,并设计本地资源或受控下载、缓存、校验、播放进度和失败回退。合成与录音是两条不同技术链路,不能只替换文案就宣称完成升级。
三十四、建议用显式状态机约束并发回调
播放状态不宜只靠一个布尔值表达。至少可以区分空闲、准备、播放、停止和失败,并保存活动题目与活动请求标识。新点击到来时先使旧请求失效,再停止旧内容、更新活动标识并启动新合成;回调到达后先比对 requestId,只有当前请求才能改变页面状态。这样即使平台回调乱序,旧完成事件也不会覆盖新播放状态。
typeAudioState='idle'|'preparing'|'playing'|'stopped'|'error'if(callbackRequestId!==this.activeRequestId) {return}this.audioState='playing'页面离开时先使活动标识失效,再调用停止与关闭。即便后续仍收到延迟回调,也会被请求校验丢弃。错误信息应映射成用户可理解的提示,平台错误码则留在诊断日志中,两者职责分开。
this.activeRequestId=''this.ttsEngine?.stop()this.ttsEngine?.shutdown()this.audioState='idle'三十五、结语
中国方言题库的语音实现最有价值的不是“调用一次speak()”,而是把平台能力放在可降级的答题流程中:先显示文字弹窗保证任务可完成,再本地优先创建引擎,必要时尝试在线回退;播放前停止旧请求;通过监听器同步状态;关闭弹窗停止声音;页面离开时关闭引擎。
它的边界也必须讲清楚:当前是zh-CN系统 TTS,不是真实方言录音;波形是静态装饰;请求 ID 尚未用于过滤旧回调;首次快速重复点击可能并发创建;“再听一次”在首次引擎失败后不会主动重建。只有把这些事实写清楚,后续性能优化、生命周期修复和真实音频升级才有可靠起点。