
【中国方言题库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很快切换并播放 BA 的停止回调晚到回调清空 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()抛出异常前面设置的showAudioDialogtrue仍然有效。注释也明确写着后台尽力尝试 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(): Promisevoid { 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然后并发创建两个引擎。后完成的一个覆盖字段另一个对象可能失去释放路径。可以缓存创建中的 Promiseprivate ttsCreating?: PromisetextToSpeech.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只有当前请求才能改变页面状态。这样即使平台回调乱序旧完成事件也不会覆盖新播放状态。typeAudioStateidle|preparing|playing|stopped|errorif(callbackRequestId!this.activeRequestId) {return}this.audioStateplaying页面离开时先使活动标识失效再调用停止与关闭。即便后续仍收到延迟回调也会被请求校验丢弃。错误信息应映射成用户可理解的提示平台错误码则留在诊断日志中两者职责分开。this.activeRequestIdthis.ttsEngine?.stop()this.ttsEngine?.shutdown()this.audioStateidle三十五、结语中国方言题库的语音实现最有价值的不是“调用一次speak()”而是把平台能力放在可降级的答题流程中先显示文字弹窗保证任务可完成再本地优先创建引擎必要时尝试在线回退播放前停止旧请求通过监听器同步状态关闭弹窗停止声音页面离开时关闭引擎。它的边界也必须讲清楚当前是zh-CN系统 TTS不是真实方言录音波形是静态装饰请求 ID 尚未用于过滤旧回调首次快速重复点击可能并发创建“再听一次”在首次引擎失败后不会主动重建。只有把这些事实写清楚后续性能优化、生命周期修复和真实音频升级才有可靠起点。