【句匠|02】HarmonyOS ArkTS 语音朗读实战:同步播放状态、页面返回与错误提示
在英语练习类应用里语音朗读不是一个孤立按钮。用户点一下“听音示例”页面至少要同时处理四件事题目卡片要显示正在播放系统语音引擎要被按需创建弹窗要给出可读文本兜底用户返回页面或关闭弹窗时还要停止播放。只把speak()写进点击事件短期看能出声真正进入练习流程后就容易出现状态残留、返回后仍播放、引擎不可用却没有反馈等问题。本文基于句匠应用源码D:\huawei\one18-11\entry\src\main\ets\pages\PracticePage.ets复盘一条更稳的实现链路。源码面向 HarmonyOS 5.0 以上的 ArkTS/ArkUI 页面使用hms.ai.textToSpeech做题目朗读围绕activeAudioQuestionId、audioStatusText、showAudioDialog和页面生命周期完成播放状态同步。正文唯一复核标记com.jiaweikang.one18。这篇文章会解决几个具体问题audio 题型如何在题目卡片里出现独立朗读入口。点击播放时如何先更新 UI再异步准备 TTS 引擎。onStart、onComplete、onStop、onError回调如何反向清理页面状态。TTS 不可用时如何保证答题流程仍然可用。页面返回、切题、关闭弹窗时如何避免播放状态残留。一、把朗读能力放在答题页而不是散落到题目组件里句匠的朗读逻辑没有单独做成一个全局播放器而是收束在PracticePage。这符合当前源码的业务边界朗读只服务于答题页的 audio 题题目切换、答案记录、错题解析和页面返回也都由PracticePage持有。如果把 TTS 引擎藏进某个题目 Row 组件切题时就很难统一清理状态。页面顶部的导入已经说明了能力来源import { router, promptAction } from kit.ArkUI import textToSpeech from hms.ai.textToSpeech这里没有使用媒体播放器文件也没有伪造音频资源。源码走的是系统文本转语音能力题目给出audioHint或题干文本页面按需调用TextToSpeechEngine.speak()。页面内与朗读直接相关的状态集中在一起State activeAudioQuestionId: string State audioStatusText: string State showAudioDialog: boolean false State audioDialogText: string State audioDialogStem: string private ttsEngine?: textToSpeech.TextToSpeechEngine undefined这组状态的职责很清楚状态负责内容如果缺失会怎样activeAudioQuestionId当前正在朗读的题目 ID切题后无法判断哪个音频入口高亮audioStatusText正在播放、失败等短反馈TTS 不可用时用户不知道发生了什么showAudioDialog是否展示听音弹窗没有文字兜底设备不支持语音时体验断掉audioDialogText弹窗展示和复听的文本复听按钮没有稳定输入ttsEngine系统语音引擎实例每次点击都重复创建且生命周期难释放工程上最重要的不是变量数量而是它们都属于同一个页面状态模型。播放开始、回调结束、关闭弹窗、切换题目、页面退出都能在同一处清理。二、题目模型先给朗读入口一个明确条件页面不是对所有题目都展示朗读按钮。源码里题目卡片先判断当前题目是否为 audio 类型并且存在audioHintif (this.currentQ()!.type audio this.currentQ()!.audioHint) { Row({ space: 12 }) { Image($r(app.media.ic_audio_play)) .width(40) .height(40) .objectFit(ImageFit.Contain) .colorBlend(this.activeAudioQuestionId this.currentQ()!.id ? Colors.PRIMARY : Colors.TEXT_HINT) Column({ space: 4 }) { Text(this.activeAudioQuestionId this.currentQ()!.id ? ${this.currentQ()!.audioHint!} · ${this.audioStatusText || 正在播放示例} : ${this.currentQ()!.audioHint!} · 点击播放) .fontSize(Sizes.BODY_FONT) .fontColor(Colors.TEXT_SECONDARY) } .layoutWeight(1) } .width(100%) .padding(12) .backgroundColor(Colors.BACKGROUND_ALT) .borderRadius(Sizes.CARD_RADIUS_SM) .onClick(() { this.toggleAudioPreview(this.currentQ()!) }) }这段 UI 的判断条件很实用type audio决定题型audioHint决定是否有可朗读提示。这样做可以避免普通选择题误出现播放入口也能防止没有朗读文本时触发空播放。入口里的视觉状态只看一个事实activeAudioQuestionId currentQ().id。同一时间只让一个题目显示为播放中切题或播放完成后把这个 ID 清空卡片自然回到“点击播放”。这个模型比维护多个布尔值更稳定。三、朗读文本要先归一化再交给引擎朗读文本不直接拼在点击事件里。源码使用audioText()统一处理private audioText(question: Question): string { return (question.audioHint || question.stem).replace(/[“”]/g, ).trim() }这段逻辑很短但解决了两个边界优先使用audioHint因为听音题通常会把要朗读的短语单独放在提示字段里。没有audioHint时退回stem避免空文本导致交互无反馈。去掉中英文引号并trim()让朗读内容更干净。这类归一化方法适合放在页面私有方法中而不是写在多个 UI 分支里。后续如果要过滤括号、题号、干扰符号只改一个地方即可。四、先打开弹窗和播放态再异步准备 TTS很多播放问题来自顺序错误先等待引擎创建成功后才更新页面。这样一旦设备 TTS 服务不可用用户会感觉按钮没有反应。句匠源码的顺序相反先让 UI 进入可解释状态再尝试系统语音能力。private async toggleAudioPreview(question: Question): Promisevoid { 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()而是前五行状态写入。即使语音引擎失败弹窗仍然打开用户仍然能看到听音文本和答题提示。对于学习应用这个兜底比“播放失败后什么都没有”更重要。requestId使用题目 ID 加时间戳也有实际意义一次点击对应一次播放请求。虽然当前源码没有按 requestId 区分多路播放但这为后续排查日志、扩展播放队列留下了明确标识。五、TTS 引擎创建要有离线优先和在线兜底ensureTtsEngine()是整个朗读能力的边界方法。页面先复用已有引擎没有则创建创建时先尝试离线模式失败后再尝试在线模式。private async ensureTtsEngine(): Promiseboolean { 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 } } if (this.ttsEngine) { this.bindTtsListener() return true } this.audioStatusText 当前设备语音引擎不可用 return false }上面把源码里的监听绑定单独抽成bindTtsListener()只是为了讲解更清楚实际源码是在ensureTtsEngine()中直接调用setListener()。这里的工程取舍可以总结成三点决策好处注意点if (this.ttsEngine) return true避免重复创建引擎页面退出时必须释放离线优先网络不稳定时也能尽量播放设备未安装语音服务时会失败在线兜底提高可用概率AGC 隐私与网络声明要和真实行为一致源码的module.json5声明了ohos.permission.INTERNET如果在线 TTS 兜底进入真实发布版本隐私政策、应用描述、权限说明必须与实际能力保持一致。文章只按源码说明不额外承诺所有设备都一定可播放。六、回调监听负责把播放状态带回页面系统语音播放是异步过程。页面不能只在点击时设置状态还要在引擎回调里清理状态。源码绑定了四个回调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 语音播放失败请检查系统语音服务 } })这四个回调的职责边界很明确onStart只确认正在播放不创建新的 UI 状态。onComplete和onStop都清空题目高亮避免音频结束后卡片仍显示播放中。onError清空高亮但保留错误文本让用户知道失败原因。这里有一个值得保留的原则完成和停止都按“播放已经结束”处理。用户关闭弹窗、切题、页面退出都有可能触发 stop如果不在onStop清状态最容易出现返回列表后再进入页面仍残留播放态的问题。七、弹窗不是装饰是语音不可用时的兜底路径源码在页面底部做了showAudioDialog弹窗。弹窗里有三类内容朗读文本、波形视觉、复听按钮。它不是营销式提示而是听音题的可用性兜底。if (this.showAudioDialog) { Column() { Row() { Image($r(app.media.ic_audio_play)) .width(28) .height(28) .objectFit(ImageFit.Contain) .colorBlend(Colors.PRIMARY) Text(听音示例) .fontSize(Sizes.H2_FONT) .fontWeight(FontWeight.Bold) .fontColor(Colors.TEXT_PRIMARY) Blank() Text(关闭) .fontSize(Sizes.CAPTION_FONT) .fontColor(Colors.TEXT_HINT) .onClick(() { this.closeAudioDialog() }) } Text(this.audioDialogText) .fontSize(28) .fontWeight(FontWeight.Bold) .fontColor(Colors.PRIMARY) .textAlign(TextAlign.Center) .width(100%) Text(提示部分设备未开启语音服务时可参考上方文字辨识发音并选出正确答案。) .fontSize(Sizes.SMALL_FONT) .fontColor(Colors.TEXT_HINT) .width(100%) } }对学习应用来说系统能力不可用不应直接中断答题。弹窗里的文字提示说明了当前能力边界设备未开启语音服务时用户可以参考文字完成题目。这种提示比隐藏失败更适合上架审核因为它没有夸大能力也没有让用户陷入不可操作状态。八、复听按钮要复用弹窗文本不重新读取题目弹窗里的“再听一次”没有重新找当前题也没有依赖currentQ()。它使用audioDialogTextButton(再听一次) .width(100%) .height(44) .fontSize(Sizes.BODY_FONT) .fontColor(Color.White) .backgroundColor(Colors.PRIMARY) .borderRadius(22) .onClick(() { if (this.ttsEngine) { try { if (this.ttsEngine.isBusy()) this.ttsEngine.stop() this.ttsEngine.speak(this.audioDialogText, { requestId: replay_${Date.now()} }) } catch (_) { } } })这个细节很重要。弹窗打开后用户可能误触其他区域或者后续代码扩展出自动切题。如果复听按钮重新读取当前题就可能朗读与弹窗展示不一致。把弹窗文本固定到audioDialogText可以保证“看到什么、复听什么”。更严格的实现还可以在 catch 分支里补充audioStatusText 语音播放失败请检查系统语音服务这样复听失败也有一致反馈。当前源码已经在引擎监听的onError中处理主要失败场景。九、关闭弹窗和页面退出必须停止引擎语音播放与页面生命周期强相关。源码里关闭弹窗时主动停止private closeAudioDialog(): void { if (this.ttsEngine) { try { this.ttsEngine.stop() } catch (_) { } } this.showAudioDialog false this.activeAudioQuestionId this.audioStatusText }页面退出时做更彻底的释放aboutToDisappear(): void { if (this.timerId ! -1) clearInterval(this.timerId) if (this.ttsEngine) { try { this.ttsEngine.stop() this.ttsEngine.shutdown() } catch (_) { } this.ttsEngine undefined } }这里同时处理了计时器和 TTS 引擎。对答题页来说返回上一页后继续播放示例音频会让用户误以为应用仍在后台执行任务也可能影响下一次进入页面的状态判断。stop()解决当前播放shutdown()释放引擎资源ttsEngine undefined则确保下次进入页面重新走创建链路。十、切题时同步清理播放态避免上一题影响下一题除了关闭和返回切题也是高频路径。源码在goNext()里切到下一题时清空选项、解析状态也同步清理朗读状态private goNext(): void { if (this.currentIdx this.questions.length - 1) { this.currentIdx if (this.mode wrongAnalysis) { this.applyAnalysisState() } else { this.selectedKey this.showAnalysis false this.activeAudioQuestionId this.audioStatusText } } }错题解析模式也有专门的状态恢复private applyAnalysisState(): void { const record this.records[this.currentIdx] this.selectedKey record ? record.selected : this.showAnalysis this.questions.length 0 this.activeAudioQuestionId }这说明朗读状态没有被当成独立功能孤岛而是纳入答题状态流。普通练习切题、错题解析切题都必须让上一题的播放高亮失效。十一、适配多设备时弹窗底部要避开系统手势区句匠源码面向 phone、tablet、2in1 设备module.json5中有对应声明。听音弹窗位于底部如果不考虑导航手势区在小屏手机或 2in1 小窗口里很容易让按钮贴到底部。源码使用navigationIndicatorHeightPx和bottomSafePadding()处理底部空间StorageLink(navigationIndicatorHeightPx) navigationIndicatorHeightPx: number 0 private bottomSafePadding(): number { return Math.max( Sizes.BOTTOM_NAV_MIN_PADDING, this.getUIContext().px2vp(this.navigationIndicatorHeightPx) ) }弹窗内容底部 padding 叠加了这段安全距离.padding({ left: Sizes.PADDING_LARGE, right: Sizes.PADDING_LARGE, top: Sizes.PADDING_LARGE, bottom: Sizes.PADDING_LARGE this.bottomSafePadding() })对上架审核来说这类细节不只是视觉问题。按钮被系统手势区遮挡会被归类为布局适配风险。尤其是语音弹窗这种底部操作区必须保证“关闭”和“再听一次”在小窗口、横竖屏切换后仍然可触达。十二、可以按这张清单复核自己的朗读页把源码里的实现拆成检查项可以得到一张比较实用的复核清单检查项通过标准对应源码点入口显示只有 audio 题并且存在audioHint时展示type audio audioHint播放高亮当前题 ID 与activeAudioQuestionId一致音频 Row 的colorBlend文本兜底点击后先打开弹窗展示audioDialogTexttoggleAudioPreview()前置状态引擎创建已有引擎复用失败时写入错误状态ensureTtsEngine()回调清理完成、停止、失败都会处理页面状态setListener()关闭停止关闭弹窗时调用stop()closeAudioDialog()页面释放退出时stop()shutdown()aboutToDisappear()切题清理下一题不继承上一题播放态goNext()/applyAnalysisState()如果这张表里有任意一项缺失真实用户练习时就可能遇到“按钮点了没反应”“上一题还在播放”“弹窗关闭后仍有声音”“设备不支持语音时无法继续”等问题。常见问题与处理现象优先排查建议处理点击后没有声音ensureTtsEngine()是否返回 false展示“当前设备语音引擎不可用”保留文字提示播放结束后图标仍高亮onComplete是否清空activeAudioQuestionId完成、停止、失败都走清理逻辑返回上一页仍在播放aboutToDisappear()是否调用stop()和shutdown()页面退出释放引擎不依赖自动回收切题后上一题状态残留goNext()是否清理播放态切题时同步清空activeAudioQuestionId和audioStatusText复听内容和弹窗文本不一致复听是否重新读取当前题使用audioDialogText作为复听输入底部按钮被遮挡是否叠加安全区 padding使用导航指示器高度计算底部留白小结句匠这段语音朗读实现的核心不是简单调用一次 TTS而是把“题目入口、播放状态、弹窗兜底、回调清理、页面生命周期”放到同一条链路里。HarmonyOS 应用做类似听音、朗读、提示音场景时也可以沿用这个边界UI 先给用户确定反馈系统能力再异步尝试成功时同步高亮失败时保留可操作路径关闭、切题和返回都负责清理资源。这样写出来的朗读功能不会夸大设备能力也不会把状态散落在多个组件里。对于学习类应用它带来的价值很直接用户知道当前在播放什么设备不支持时知道为什么失败页面离开后不会留下不可见的播放任务。