「小鲸写字」是我们团队做的练字应用,主要面向低龄用户,主场景是孩子拿着平板或手机跟着字卡认识生字、练笔画。迭代到第8个版本,产品提了一个需求:希望整个练字流程中加入语音播报,点击汉字能听到读音,笔画演示时能同步听到“先横后竖、先左后右”的口令,完成练习后再来一句鼓励语音。乍一看这是一个很小的功能,但真正落地之后你会发现,“语音播报”四个字背后藏着从文本处理、合成引擎、音频管理到状态联动的一长串问题。
这篇文章我先拆需求,再讲技术选型,然后按“从点击到出声”的链路逐段展开实现细节,最后是测试与上线的经验。核心目标只有一个:让你看完之后,能在自己的项目里复现一个不卡、不串、出声快、还能扛住离线场景的语音播报模块。
1. 需求拆解:语音播报不是“读出来”三个字那么简单
1.1 播报内容的三种角色
先别急着接SDK,先把要播的内容盘一遍。我在「小鲸写字」里把播报内容分成了三类,三类内容的实时性和离线要求完全不同。
第一类是认字读音,也就是字卡页上的单个汉字、词组和拼音。这类内容由用户主动点击触发,体验要求最高,点击后最好500毫秒内就出声,而且必须在无网环境下也能播。第二类是笔画口令,比如“横、竖、撇、捺”,以及整字书写顺序的口令。这类内容要嵌到动画演示流里,每一笔开始前几百毫秒就要播出来,否则孩子会感觉声音和笔势对不上。第三类是激励反馈,比如练完字之后的“完成得很棒”,它出现在评分结果页,延迟可以放宽,但音色必须亲切,机械感太强的声音很违和。
我把需求整理成了下面这张表,后续所有技术决策都是围绕这三行的差异来定的。
| 场景 | 内容 | 触发方式 | 出声要求 | 离线要求 |
|---|---|---|---|---|
| 认字读音 | 单字/词组/拼音 | 点击字卡 | 500ms内 | 必须 |
| 笔画口令 | 笔顺名称及顺序 | 自动连播 | 每笔前300ms | 必须 |
| 激励反馈 | 鼓励语 | 评分后播放 | 可稍慢 | 尽量 |
1.2 触发时机与交互约束
拆完内容,第二步是跟产品确认触发时机。这里有一个常见误区:为了省事,把语音播报做成“点击任意文字就自动读出来”,看着功能是有了,但用户很快就会觉得吵。我们最终和产品对齐了三条交互约束。
第一条,用户主动点击字卡时,允许打断当前播报,立即播最新的读音。第二条,描红过程中只允许播笔画口令,用户落笔的瞬间所有语音必须停止,避免边写边播造成干扰。第三条,练习完成进入评分页时,先停掉字卡页可能残留的句子,再播激励反馈。这三条约束直接决定了后面状态机的设计。如果一开始不把边界定清楚,写代码时就会一直在“要不要打断”“残播怎么办”这些判断里打补丁。
1.3 先定边界:离线、延迟、音色不能含糊
还有一件很容易漏掉的事:需求边界。在正式开工前,我和产品、后端同学一起对齐了三项硬性边界,技术方向基本就被框死了。
第一是离线。练字场景大量发生在家里、学校、地铁上,网络非常不稳定。认字读音和笔画口令必须支持离线播报,不能依赖每次请求云端。第二是延迟。点击汉字后500毫秒内出声,是我们在多台真机上测过后定下的体验基线。低于这个值,用户会觉得“点下去没反应”,儿童应用对反馈时效尤其敏感。第三是音色。面向低龄用户的语速和音色,跟导航、阅读类应用完全不同,语速要偏慢,音调要柔和,家长对机械音色的容忍度非常低。
2. 技术选型:系统TTS、离线SDK还是自建服务
2.1 三套方案的账面对比
当时团队内部讨论了三套方案:一是直接用移动端系统内置的语音合成能力,二是接入商用离线语音合成SDK,三是自建云端语音合成服务。我把三套方案拉了一张表,账面上差异很明显。
| 方案 | 接入成本 | 音色 | 离线能力 | 包体积/费用 | 主要风险 |
|---|---|---|---|---|---|
| 系统内置 | 最低,几行代码 | 一般,机械感偏强 | 看机型,很多设备支持 | 无额外成本 | 低端机型效果差、行为不统一 |
| 离线SDK | 中等,需要封装 | 好,有适合儿童的音色 | 强 | 增加数MB到十几MB,有授权费 | 初始化耗时、个别机型兼容问题 |
| 云端服务 | 中等 | 最好 | 无 | 按调用量计费 | 断网不可用、延迟不稳定 |
只看表格,系统内置是最省事的,但它有两个让我不放心的点:音色一致性不可控,低端机上的效果也参差不齐。云端服务反而最先被排除,因为离线是硬需求。真正纠结的,就是系统内置和离线SDK怎么选、要不要共存。
2.2 为什么没考虑自建服务
先说为什么不建议自建服务。坦白讲,很多团队看到“语音播报”四个字,立刻就开始谈算法模型、训练语料,但「小鲸写字」的核心业务是写字教学,不是语音技术。自建服务至少要解决三件事:合成模型本身的效果、服务端并发与运维、以及持续不断的音色优化。对一个小规模团队来说,这个投入产出比低到不值得讨论。
唯一值得采用“自建”思路的变体场景,是产品希望某一段固定话术,比如品牌口号、吉祥物台词,由指定音色录制且内容基本不变。这种情况直接用提前录好的音频文件最稳定,比任何TTS都靠谱。但动态内容,比如任意汉字、笔画名、评分结果,还是要走合成引擎。
2.3 最终方案:离线优先,系统兜底的双通道
我们最终的决定是:底层封装成一个对外稳定的语音播报服务,内部做成“离线SDK优先、系统内置兜底”的双通道。
具体来说,正常情况下,离线SDK初始化完成后,所有播报都交给它,音色好、不需要网络、延迟也可控;如果它在某些老旧机型上初始化失败、授权异常,或者设备内存太低无法常驻,就自动降级到系统内置合成。业务层完全不关心现在走的是哪条通道,只需要调用同一个speak方法。
这个设计帮我们省掉了后面大量的排查时间。只要测试反馈“某台手机不响”,先查是不是双通道降级条件触发了,再看具体通道的日志,路径非常清晰。早期如果直接让页面代码可以自由选择引擎,那问题就会散布在几十个页面里,根本没法收场。
3. 核心链路:从点击汉字到扬声器出声
3.1 播报文本的统一收口
链路的第一步,是生成要播的文本。我强烈建议不要把播音文案散落在页面代码里,而是统一收口到一个文本处理模块里。
原因有两个。第一,产品同学经常会在配置后台修改引导文案,统一模块能保证页面显示和语音播报用的是同一份内容,不会出现页面写“重”,语音读“chóng”这种低级错位。第二,很多文本不能直接丢给TTS,比如“3×3=9”这种算式、写成年份的“2024”、练字口诀里常见的顿号和破折号,都会影响断句和语气。
我们做了一个很薄的预处理层,规则也不复杂:把阿拉伯数字转成中文读法,把特殊符号翻译成“等于”“乘”等可读词,再对多音字做注音。每个播报请求封装成一个SpeakRequest对象,包含原始文本、拼音注音、播报类型和优先级。这个对象是整个语音模块的输入标准,后续所有逻辑都在它之上展开。
enum SpeakType { readAloud, strokeGuide, praise } class SpeakRequest { final String rawText; final SpeakType type; final String? pinyinText; final int priority; String build() { if (pinyinText != null) return '<phoneme>$pinyinText</phoneme>'; return normalize(rawText); } }别小看这一步,少了它,后面一大半“读错字”问题都会在这里产生。
3.2 TTS引擎的初始化与音色参数
真正的引擎封装,我们收敛在一个叫TTSService的类里。这个类对外只暴露init和speak两个核心方法,内部维护引擎实例、播放队列和回调状态。
初始化有两点必须注意。第一,不要在用户第一次点击时才初始化,而是要提前到进入字卡页之前,最好App启动后就静默初始化。第二,初始化不是一次成功就永远成功,授权过期、系统资源被回收这类问题,可能在App运行中某次调用时才暴露,所以每次speak之前要做一次轻量检查。
音色参数方面,我们针对低龄用户调了三组值:语速放到正常语速的0.9倍左右,音调略调高,音量默认用0.9。这三个值会写进引擎参数,同时在设置页对家长开放微调入口。参数不能写死,因为不同引擎对同一个数值的解释很可能不同,必须留调节余地。
class TTSService { bool _available = false; Future<void> init() async { _available = await _initOffline(); if (!_available) { _available = await _initSystem(); } } Future<void> speak(SpeakRequest req) async { _stopAll(); await _engine.speak( req.build(), params: TtsParams(rate: 0.9, pitch: 1.1, volume: 0.9), ); } Future<void> _stopAll() async { await _engine.stop(); } }3.3 播放中的焦点、生命周期与打断策略
引擎能出声之后,紧接着要处理的是“什么时候能播、什么时候必须停”。这里最容易出问题的是三块。
第一是系统音频焦点。不要在每次播报时强行把系统音量推到最高,也不要为了声音大就一直抢占音频通道。正确做法是播报前申请音频焦点,播报结束后释放,让系统自己决定音量策略。如果App里有背景旋律,播报时把旋律音量降下来,而不是让两条声音叠在一起。
第二是打断策略。连续点击字卡时,我们采用的是“新播报直接覆盖旧播报”,不排队等待。听音是短交互,用户快速点下一个字,通常意味着上一个字不想听了,排队反而会让系统听起来像卡住。
第三是页面生命周期。离开字卡页、App退到后台、来电响铃,这些时刻都要执行stop而不是pause。尤其是来电场景,TTS长期占用音频焦点可能被系统直接回收,之后恢复时甚至会出现整个App没有声音的问题。我们后来把生命周期处理收敛成一条规则:离开播报相关页面就无条件停止,不搞任何自动恢复。
3.4 与练字流程的联动状态机
把语音播报嵌进练字流程,不是简单地在页面切换时调一下speak就行。我们用一个很简单的状态机管理播报,只维护三个状态:idle、speaking、paused。
页面侧的映射关系非常清晰,列成表就是下面这样。
| 当前流程节点 | 触发动作 | 语音行为 |
|---|---|---|
| 进入字卡页 | 自动播读音 | idle -> speaking |
| 点击字卡 | 播读音 | speaking下覆盖重播 |
| 进入笔画演示 | 每笔前播口令 | 按笔顺序依次播 |
| 用户落笔 | 立即停止 | speaking -> idle |
| 评分完成 | 播激励反馈 | idle -> speaking |
| 页面销毁/退后台 | 停止 | speaking -> idle |
这套映射写清楚之后,页面侧就不再需要到处补stop逻辑,只要在关键节点调用服务的对应方法即可。整个状态机大概花了一个工作日写完,后续上线几个月里,没有因为“该停没停”出过线上问题。
4. 体验优化:把“能播”打磨成“好播”
功能跑通只是起点。按我的标准,一个语音播报功能要真正能用,至少要过三道体验关:首播要快、读字要准、打断要干净。
4.1 预热:把首次延迟压到可接受范围
TTS引擎初始化普遍需要几百毫秒,如果不做处理,用户第一次点击字卡时会有明显卡顿。我们的解决办法就三个字:早预热。
App启动后立刻在后台初始化引擎,不等用户走到字卡页。更进一步,利用空闲时间预合成一份高频生字表里的前几十个常用字,当用户恰好点击到这些字时,引擎直接播放预合成结果,延迟几乎为零。
实测数据很直观。不预热时,点击到出声大概要800毫秒到1.2秒;预热后,绝大多数设备能压到200到300毫秒。但预热也要控制量,把整本教材三千字全部预合成,会显著增加引擎内存占用和启动耗时。我们只预合成前五十个最高频字,收益已经很可观。
4.2 多音字和轻声:拼音注音是绕不开的
语音播报最容易翻车的,就是多音字。比如“行”在“行走”和“银行”里读音完全不同;“重”在“重量”和“重复”里也不一样。单字朗读的问题更大,因为一个字单独拿出来没有上下文,引擎只能靠统计概率猜,猜错率不低。
我们的解法,是把多音字的正确答案直接通过注音文本传给引擎。具体流程是:建立生字词库,每个字卡不仅保存汉字,还保存拼音;发声前给文本加注音标记,实现指定读音。这需要多写一层映射关系,但效果非常直接。而且线上一旦收到“读错字”反馈,可以精确到是哪个字的注音配错了。
提示:不同合成引擎对注音的支持不一样,有的有专用拼音标签,有的需要自己把文本拼出来。封装层最好把这种差异屏蔽掉,上层不用关心底层是哪种引擎。
4.3 静音模式、后台与音频通道的细节
还有一个容易被忽略的坑:系统静音模式。教育类应用通常希望自己播报时不被系统静音键“静掉”,但这个思路有风险。强行绕过用户意志的App很容易被投诉,甚至可能被应用商店下架。
我们最终的做法是:默认尊重系统静音模式,但在App设置里增加一个“练习时持续播音”开关,默认关闭。家长确认需要强制出声,就自己打开开关。这个方案既照顾了产品诉求,也避开了审核风险。
关于后台,我们的原则是:退后台强制停止,切回前台不自动恢复。用户切到后台再回来,中间可能已经做了很多别的事,App自作主张接着播,反而会让用户觉得“这应用怎么自己响起来了”。
4.4 数据指标:延迟、成功率、播放时长
技术手段都做了,但没有数据,你永远不知道优化有没有效。我们给播报链路加了三个关键埋点:发起播报、开始播放、播放完成或失败。汇总之后主要看四个指标:
- 首次播报延迟:从发起speak到开始播放回调的时间。
- 播报成功率:开始播放数除以发起播报数,目标定在99%以上。
- 平均播放时长:太短说明频繁被打断,太长说明有残留播报没人停。
- 打断率:用户主动切换字卡的频率,可以辅助判断内容设计是否舒服。
有一次线上数据发现,某款低端设备的播报成功率只有92%。排查后确认是离线引擎在低内存环境下被系统回收,重试机制又不完善,用户点击后根本没声音。后来在封装层加了“失败自动重试系统引擎”的逻辑,成功率才回到99.5%。这个案例说明,数据埋点不是给报表看的,是给排查问题指路的。
5. 测试、验收与线上监控:语音播报的过关清单
功能写完之后,不能只在开发同学的手机上点两下就说“行了”。语音播报的测试矩阵,比普通UI功能麻烦得多。下面是我们实际用过的清单。
5.1 功能用例:覆盖正常与异常场景
正常用例不复杂,重点在异常场景。我列出的必测项目有这些:
- 首次点击发声:冷启动后第一次进入字卡页,点击汉字,500毫秒内出声。
- 连续快速点击:一秒内连点五个不同的字,只应播最后点击的那个。
- 播放中退出页面:退出后声音必须停止,不能残留。
- 来电中断:播放中模拟来电,结束后不自动恢复播报。
- 断网播放:飞行模式下,读音、口令播放正常。
- 长文本播报:一次播完整首“笔画口诀”,不能被引擎截断。
- 低内存设备:连续播报30分钟,不崩溃、不静音。
自动化跑这套用例成本偏高,我们是每个版本手动回归一遍,效果已经足够。语音功能这种强依赖真机环境的模块,自动化用例相对容易误报,手动回归反而更可控。
5.2 真机专项:音质、音量与耗电
除了功能,还要看音质和耗电。我们准备了高、中、低三档真机,系统版本尽量拉开。每台设备上把系统音量分别调到30%、60%、100%,各播报一段标准字卡音频,听三件事:有没有破音、音量是否忽大忽小、播完是否有杂音残留。
耗电方面,连续播放30分钟,观察设备温度和电量变化。如果音量不大但掉电异常,说明引擎在高负荷下有问题,需要考虑降低预合成数量或者缩短引擎常驻时间。这类问题只在真机长期跑才会暴露,模拟器基本发现不了。
5.3 线上日志与反馈闭环
上线后的监控比测试更重要。我们的做法是给语音播报单独开一条日志通道,不混在普通业务日志里。日志内容只包含:播报内容类型、引擎通道、是否命中预合成、延迟、成功或失败原因。文本原文不进日志,避免个人信息问题。
线上如果发现某个字读错了,用户可以从字卡页直接提交反馈,反馈自动带上拼音注音版本,我们就能很快定位是注音配置错了,还是引擎自身的问题。灰度发布时,我们先把新功能放到内测版本里,集中收集一周读错反馈,确认问题收敛后再全量放开。
做「小鲸写字」的语音播报时,我最深的感受是:这个功能看上去是技术问题,其实八成是产品边界和交互细节问题。很多坑都是从“先接个TTS再说”开始的,等真正联调才发现,文本没处理好、打断逻辑没定、离线场景完全没覆盖。我个人的习惯是,任何看起来简单到不值得设计的功能,都先写一页纸的需求说明和状态映射,再动手写代码。最后分享一个很实用的小技巧:封装TTS时,把播放状态放在服务内部管理,页面代码只关心“叫它播”和“叫它停”,千万不要在业务页面里自己维护播放状态,否则你会被各种残播问题追着改到怀疑人生。