1、为什么做饭的人不看屏幕
菜谱应用有个天然矛盾:做法是文字,做饭时两只手是脏的、眼睛在锅上。1.0.4 版本我把"朗读文章"做成了首页一级入口,用 Core Speech Kit 的 textToSpeech 把健康饮食文章读出来——边做饭边听。本文记录这套朗读能力的接入过程和几个关键取舍。
2、引擎创建:一次创建,随页销毁
朗读的核心是 TextToSpeechEngine。工程里的创建代码:
import{textToSpeech}from'@kit.CoreSpeechKit'asynccreateSpeechEngine():Promise<textToSpeech.TextToSpeechEngine|undefined>{returnawaittextToSpeech.createEngine({/* 配置 */})}第一个取舍是引擎的生命周期:跟页面走。ReadingPage 的 aboutToAppear 里 createEngine,aboutToDisappear 里 shutdown——不在全局常驻。理由是朗读是低频场景(用户一天点开一两次),常驻引擎白白占资源;页面级创建的启动延迟在可接受范围。
3、状态机:三种文案背后的三件事
页面上有个 @State speechStatus,用户看到的文案是"点击朗读,收听当前文章 → 正在朗读 → 已停止朗读",出错时显示"朗读不可用:errorCode"。这三态背后是三件必须处理的事:
开始:speakCurrentSection(article) 把当前小节送进引擎。按小节切分而不是整篇送入——整篇长文一次性合成,中途停止和续播都难做;按小节粒度,"读下一段"就是换个入参再 speak。
完成回调:onComplete(response) 里把状态归位。回调驱动是 TTS 的关键——不能假设 speak 之后朗读"应该"结束了,只有回调说了算。
错误回调:错误码直接透给用户(朗读不可用:errorCode)。TTS 在部分设备/语言包缺失时会失败,与其静默失败让用户以为按钮坏了,不如把错误码亮出来——至少知道是能力不可用而不是应用 bug。
4、和菜谱场景的两个细节
朗读起点跟随阅读进度。用户滚到哪一段,点击朗读就从那一段开始——用当前可见小节作 speakCurrentSection 的入参,而不是死板的从头读。
朗读入口放在首页而不是文章页内。首页卡片显示第一篇文章标题,点击直达朗读页。这个入口设计来自场景推演:用户站在灶台前打开应用时,最短路径是"一个动作开始听",任何多一层的选择都会让做饭的人烦。
5、总结
Core Speech Kit 的接入本身不难(createEngine → speak → 回调三件套),难的是把能力嵌进真实场景:引擎生命周期跟页面、按小节切分合成、三态状态机、错误码亮给用户。当用户手上沾着面粉还能"读完"一篇健康文章时,这个功能才算真的可用。
6、参考资料
- Core Speech Kit 文档(textToSpeech)
- 工程源码:ReadingPage.ets
#鸿蒙 #HarmonyOS 7(API 26)
7、朗读状态机:比调用本身更重要
接完基础调用后真正花时间的是状态管理。朗读功能有五个状态:空闲、播放中、暂停、合成中(点了播放但声音还没出来)、错误。最容易踩的坑是"合成中":用户连点播放按钮,如果不在合成态拦截重复请求,会叠出多个播放任务,声音直接变混响。我用一个简单的状态门解决:合成中再点播放直接忽略,UI 上按钮显示加载态。
type SpeakState = 'idle' | 'synthesizing' | 'playing' | 'paused' | 'error' // 合成中重复点击直接吞掉,是整个功能最重要的三行代码 if (state === 'synthesizing') return8、错误处理的三个真实场景
真机上遇到过三类失败,每类的处理不一样,不能一个 catch 包完:
| 场景 | 现象 | 处理方式 |
|---|---|---|
| 引擎未就绪 | 首启立即播放返回错误 | 延迟到 onPrepared 再播,UI 提示"正在准备" |
| 文本含表情符号 | 静默失败或跳读 | 合成前正则清洗非文本字符 |
| 系统朗读服务被占用 | 错误码回调 | 自动降级为静默模式并提示 |
第三种最隐蔽:测试时好好的,用户后台挂着听书应用就复现了。最后加的降级提示反而是用户好评最多的一处——"至少告诉我为什么没声音"。
9、语速与音色的参数选择
朗读好不好听,参数比引擎重要。我把默认语速调到 0.9 倍(默认 1.0 偏快,菜谱步骤听不清操作量),停顿在步骤编号后自动插入 400 毫秒("第一步"后面停一下,给用户拿调料的时间)。音色选了系统自带的沉稳女声,试过三个声音,这个在厨房嘈杂环境下辨识度最高。
const options = { speed: 0.9, pitch: 1.0, pauseAfterStepMs: 400, voice: 'default-female-calm' }这些数值没有玄学,全是做饭时实测出来的:0.8 倍太慢让人等得慌,1.0 倍炒菜时跟不上,0.9 刚好;停顿 300 毫秒不够拿东西,500 毫秒节奏断了,400 合适。功能参数要放到真实使用场景里校准,这是这次最大的体会。
10、与界面的联动细节
朗读进行时,阅读页做了三处小联动:当前朗读的步骤高亮显示(听漏了能马上找到位置)、屏幕常亮(炒菜时不锁屏)、播放控制悬浮在底部(单手能暂停)。三处都依赖朗读进度的回调,回调频率控制在步骤粒度而不是字粒度——字粒度的回调每秒几十次,白白刷新界面。
11、性能与电量账本
最后记录一组实测数据(主流机型,30 分钟连续朗读):电量消耗 4%,内存稳定无增长,CPU 峰值出现在每次合成发起瞬间约 12%、持续不到一秒。这个开销对"边做饭边听"的场景完全可接受。合成任务用的是流式接口,第一句声音 800 毫秒内出来,用户不会感知到"加载"。
| 指标 | 实测值 | 判定 |
|---|---|---|
| 首句延迟 | 约 800ms | 无感 |
| 30 分钟耗电 | 4% | 可接受 |
| 内存趋势 | 平稳 | 无泄漏 |
| CPU 峰值 | 12% 瞬时 | 无感 |
回头看,接一个 Kit 的 API 只花了一小时,把它调到"做饭时真的好用"花了一个周末——接口文档负责能用,真实场景负责好用,中间的每一格参数和每一处降级都得自己踩。
12、从一句话到一整篇:合成的分句策略
引擎对长文本的处理并不总是可靠,超过一定长度后偶现截断。稳妥做法是应用侧自己分句:按步骤切分为最小朗读单元,每步再按句号二次切分,逐句合成、逐句入队,播放器按队列顺序播。好处有三:单句失败只需重试单句;进度天然落在步骤粒度;暂停恢复的精度也在句子级。分句的实现不到二十行,但把合成可靠性问题从"祈祷引擎"变成了"应用可控"。
function splitToUnits(steps: string[][]): string[] { return steps.flat().flatMap(s => s.split(/。|!|?/).filter(Boolean)) }分句后要注意一个体验细节:句间停顿要比自然朗读略长(约 150 毫秒),否则一句接一句显得急促。这个值同样是听出来的,不是算出来的。
13、并发合成的队列管理
逐句合成引出一个新问题:合成要不要提前做?全篇提前合成占内存、按需现合成有延迟。我的方案是滑动窗口预合成:当前句播放时,提前合成接下来的两句,窗口随播放推进。实现上是一个长度为三的环形队列,合成线程与播放线程通过队列解耦,任何一句合成失败只影响那一句的降级,不阻塞整篇。
| 策略 | 首句延迟 | 内存占用 | 适用场景 |
|---|---|---|---|
| 全篇预合成 | 高 | 高 | 短文本 |
| 按需现合成 | 低 | 最低 | 延迟敏感 |
| 滑动窗口(本文) | 低 | 可控 | 长菜谱 |
窗口大小为三同样是实测结果:窗口为二时切步骤瞬间偶尔卡一下,窗口为四内存多占无收益,三刚好覆盖一次步骤切换的合成时间。
14、听感测试的完整清单
功能发布前我做了一轮完整的听感测试,清单如下,供复用:十篇长菜谱全篇通读(验证分句与停顿)、中英混合菜名十例(验证英文单词读法)、数字与单位二十例(验证预处理规则)、连续播放三十分钟(验证内存与稳定性)、四种打断各三遍(验证状态机)、息屏播放十分钟(验证后台行为)、低电量模式播放(验证性能降档下的表现)。七项全过才发的版。
其中"低电量模式"一项是最后加的,也是最有价值的一格:低电量模式下系统会限制后台任务,朗读在第十分钟左右被暂停。最终的处理是注册长时任务并给出明确提示,让用户选择"继续播放会耗电"还是"暂停"。把限制讲清楚,比偷偷被系统掐断再让用户莫名其妙好得多。
15、上线后的真实反馈
这个功能上线两周,收到的反馈集中在意料之外的两处:一是长辈用户比年轻用户更依赖朗读(子女给父母装的菜谱应用,长辈看小字累、听步骤刚好),二是很多人把它当"厨房计时器"复合使用(听到"腌制二十分钟"就顺手定闹钟)。两类反馈都指向同一个结论:功能的真实使用场景往往比设计时的想象更宽。如果只按"做饭腾不出手"这一个场景设计,后半部分用户根本覆盖不到。
由此定下的迭代方向也很明确:步骤级的重听按钮("刚才那步再说一遍"比拖进度条好用)、音量随环境自适应(油烟机噪音下自动提高)、步骤完成手动打勾(听过的步骤显式标记,中断后不重复听)。三个改进都来自真实反馈,没有一个是我当初坐在电脑前想出来的——这也算个人项目最朴素的成长路径:做出来、用起来、听反馈、再改。
16、版本环境与实测数据
本文的验证环境:DevEco Studio 5.0.3.900,HarmonyOS NEXT 5.0.2.130(API 14)起开发,最终在 API 26 的 Mate 60 Pro(6.0.0.135)与 MatePad Pro 13.2(5.1.0.120)两台真机上完成验收。Core Speech Kit 引擎版本随系统更新有一次行为变化:API 14 时代 speak 回调偶发不触发 onEvent 完成事件,API 20 后已修复——如果你的工程锁在旧 API,升级系统前先跑一遍 speak 回调的冒烟用例。
| 实测项 | Mate 60 Pro | MatePad Pro 13.2 | 判定 |
|---|---|---|---|
| 首句合成延迟(300字) | 约800ms | 约650ms | 可接受 |
| 30分钟连续朗读耗电 | 4% | 3% | 可接受 |
| 内存峰值(单菜谱) | 18MB | 21MB | 平稳无泄漏 |
| 打断恢复正确率(30次) | 100% | 100% | 稳定 |
| 5万字菜谱全篇合成 | 无截断 | 无截断 | 可靠 |
17、实操验证:五条核心链路的真机用例
文档只给了接口,验证要自己设计。我最终固化了五条核心链路的真机用例,每条都写明前置条件、操作步骤、预期结果与实际结果。第一条:创建引擎后立即 speak,验证"合成中拦截"是否吞掉请求——结论:吞掉,需先等 onPrepared 等价状态。第二条:朗读中按 Home 键再返回——结论:API 26 上朗读继续,但回调丢失一次 onEvent,需主动查询当前进度补状态。第三条:朗读中拔出耳机——结论:系统音频焦点自动切换,声音转扬声器,无需干预,但音量记忆是分离的。第四条:同一菜谱反复朗读五次——结论:第五次合成耗时比首次低 60%,确认引擎有内部缓存。第五条:朗读中杀进程——结论:无系统级保存,重启后从步骤一重新开始,所以应用层必须自己做步骤落盘。
// 用例二的修复:返回后主动查询进度 onPageShow() { if (this.state === 'playing') { const progress = this.session.queryProgress() this.syncStepHighlight(progress.currentStep) } }五条用例跑完,Core Speech Kit 在我的清单里从"能用"升级到"可靠":边界行为全部摸清,降级路径全部有据可依。这套用例也复用到了后续接入的其他 Kit——每个 Kit 的接入验证成本大约半天,换来的是后续版本的升级不用重新踩坑。
18、错误码对照:文档没写全的四个码
文档列了错误码但没给处理建议,实测遇到四个高频码:401 权限未授予(引导用户到设置页,不要只 toast)、1001 引擎忙(排队重试,不要立即重发)、2001 合成内容超长(应用侧分句解决)、9999 内部错误(捕获后降级静默,不要反复重试)。四个码的处理策略写成一个错误映射表,项目里所有 Core Speech 的 catch 分支都指向这张表——错误处理的代码量比业务代码还多,但它决定了功能在真实环境的口碑。
这篇补充完毕后,Core Speech Kit 的接入-验证-错误处理三段就齐了。如果只看第一篇的接入流程,你会以为半天就能搞定;加上这篇的实测数据与错误码,你会知道真正要花功夫的是那五条真机用例——它们才是别人问起时你敢说"我验证过"的底气。