在英语练习类应用里,语音朗读不是一个孤立按钮。用户点一下“听音示例”,页面至少要同时处理四件事:题目卡片要显示正在播放,系统语音引擎要被按需创建,弹窗要给出可读文本兜底,用户返回页面或关闭弹窗时还要停止播放。只把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 类型,并且存在audioHint:
if (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): 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(),而是前五行状态写入。即使语音引擎失败,弹窗仍然打开,用户仍然能看到听音文本和答题提示。对于学习应用,这个兜底比“播放失败后什么都没有”更重要。
requestId使用题目 ID 加时间戳,也有实际意义:一次点击对应一次播放请求。虽然当前源码没有按 requestId 区分多路播放,但这为后续排查日志、扩展播放队列留下了明确标识。
五、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 } } 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()。它使用audioDialogText:
Button('再听一次') .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 |
| 文本兜底 | 点击后先打开弹窗,展示audioDialogText | toggleAudioPreview()前置状态 |
| 引擎创建 | 已有引擎复用,失败时写入错误状态 | ensureTtsEngine() |
| 回调清理 | 完成、停止、失败都会处理页面状态 | setListener() |
| 关闭停止 | 关闭弹窗时调用stop() | closeAudioDialog() |
| 页面释放 | 退出时stop()+shutdown() | aboutToDisappear() |
| 切题清理 | 下一题不继承上一题播放态 | goNext()/applyAnalysisState() |
如果这张表里有任意一项缺失,真实用户练习时就可能遇到“按钮点了没反应”“上一题还在播放”“弹窗关闭后仍有声音”“设备不支持语音时无法继续”等问题。
常见问题与处理
| 现象 | 优先排查 | 建议处理 |
|---|---|---|
| 点击后没有声音 | ensureTtsEngine()是否返回 false | 展示“当前设备语音引擎不可用”,保留文字提示 |
| 播放结束后图标仍高亮 | onComplete是否清空activeAudioQuestionId | 完成、停止、失败都走清理逻辑 |
| 返回上一页仍在播放 | aboutToDisappear()是否调用stop()和shutdown() | 页面退出释放引擎,不依赖自动回收 |
| 切题后上一题状态残留 | goNext()是否清理播放态 | 切题时同步清空activeAudioQuestionId和audioStatusText |
| 复听内容和弹窗文本不一致 | 复听是否重新读取当前题 | 使用audioDialogText作为复听输入 |
| 底部按钮被遮挡 | 是否叠加安全区 padding | 使用导航指示器高度计算底部留白 |
小结
句匠这段语音朗读实现的核心,不是简单调用一次 TTS,而是把“题目入口、播放状态、弹窗兜底、回调清理、页面生命周期”放到同一条链路里。HarmonyOS 应用做类似听音、朗读、提示音场景时,也可以沿用这个边界:UI 先给用户确定反馈,系统能力再异步尝试;成功时同步高亮,失败时保留可操作路径;关闭、切题和返回都负责清理资源。
这样写出来的朗读功能不会夸大设备能力,也不会把状态散落在多个组件里。对于学习类应用,它带来的价值很直接:用户知道当前在播放什么,设备不支持时知道为什么失败,页面离开后不会留下不可见的播放任务。