十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Android文字转语音(TTS)开发全记录:从系统API到云端合成

Android文字转语音(TTS)开发全记录:从系统API到云端合成 产品经理过来跟我说文章详情页加个“朗读”按钮我当时没多想就答应下来了。文字转语音TextToSpeech简称TTS不是成熟得不能再成熟的技术吗系统自带API都有百度一下一堆教程。真开始动手之后才发现从“能出声”到“好用”中间横着大量文档里不会写的细节——模拟器上好好的真机上没声音一个页面上两个播放按钮旧的还没停新的就说话了甚至同一段代码在小米和华为上表现完全不同。这篇文章就完整记录我从零做出一个“文字转语音”功能的全过程把技术选型、第一版demo、装机适配踩坑、体验调优、上架前注意事项都摊开讲清楚。标题叫“尝试做一个”确实就是边做边踩坑的过程希望能帮正在做类似功能、或者正在做app课程设计、期末大作业、个人工具类应用的朋友少走点弯路。你不需要一开始就追求多高级的人工智能音色先把整个链路跑通后面的一切都好办。1. 先把“朗读”需求拆清楚这一步决定后面所有技术选型接到需求后别急着写代码先花半小时把需求翻译成技术语言。很多开发者在TTS功能上翻车恰恰是因为没想清楚“用户到底要听什么”“在什么场景下听”。1.1 一句话需求背后的隐藏信息“帮我在详情页加个朗读按钮”这句话至少包含下面这些待确认的问题要读的内容长度是读标题、读摘要还是整篇文章全文几百字和几千字的处理逻辑完全不一样。在线还是离线用户可能在电梯、地铁里没有稳定网络是否允许合成失败音色要求是“能听清就行”的机械音还是要接近真人的自然声音是否需要边读边高亮很多阅读类app要求读到哪、字高亮到哪这需要朗读进度回调。后台播放用户切到别的页面朗读是继续还是停止是否有收费预算第三方语音服务一般是按调用次数或时长计费。这些问题不确认清楚后面几乎每一步都可能返工。以我这次的“朗读按钮”为例产品希望先支持整篇详情页的全文朗读同时需要按段落停下来音色方面暂时不要求太自然“能听就行”。这个定位直接决定了第一版可以先用最省事的系统方案。1.2 三种主流TTS方案横向对比在Android生态里做TTS基本逃不开下面三条路线方案优点缺点适合场景系统内置TextToSpeech免费、无额外SDK、集成最快音色机械、不同手机发声差异大、部分机型中文支持差第一版demo、工具类app、内部测试云端语音合成API音色自然、可定制发音人、支持多语言依赖网络、有费用、需要AppID/APIKey鉴权正式上线的阅读类、资讯类app离线语音合成SDK速度快、无网络依赖、隐私性好包体大离线资源包几十到几百MB、通常有授权费用有车机、离线场景、特定行业终端系统内置TTS本质上是调用手机上已有的“语音合成引擎”不同手机的引擎不一样有的来自谷歌有的是厂商定制有的来自第三方讯飞、度秘等引擎所以同一套代码在不同机器上表现天差地别。云端API则是把文本发到服务器合成完返回音频文件摆脱了对手机本地引擎的依赖体验稳定得多。离线SDK是把推理模型和资源包都塞进你的app里每次本地合成代价是安装包体积剧增。1.3 我的选型结论先本地验证再平滑替换第一版我选的系统内置TextToSpeech原因很现实先把产品流程跑通让产品经理和测试同学能上手体验同时验证“详情页朗读”这个功能形态是否成立。如果一开始就上云端API又要申请账号、又要写鉴权逻辑反而干扰了对核心交互的判断。但我从一开始就在代码结构上做了隔离——把语音合成封装在一个TextToSpeechHelper类里上层UI完全不关心底层用的是系统引擎还是云API。后期接云端或离线SDK只需要替换Helper内部的实现UI层零改动。这就是面向接口编程的价值别等功能做完了再重构。2. 系统自带TextToSpeech第一版demo的实现过程与关键代码选定了“先系统自带”的方案之后正式进入写代码阶段。我会把完整的关键步骤和代码骨架放出来方便直接抄作业。2.1 初始化onInit回调是第一个容易踩坑的点创建一个简单的Kotlin工具类初始化系统的TextToSpeech核心代码如下class TextToSpeechHelper(private val context: Context) { private var tts: TextToSpeech? null private var ready false fun init(listener: (Boolean) - Unit) { tts TextToSpeech(context) { status - ready when (status) { TextToSpeech.SUCCESS - { val result tts?.setLanguage(Locale.CHINESE) result ! TextToSpeech.LANG_MISSING_DATA result ! TextToSpeech.LANG_NOT_SUPPORTED } else - false } listener(ready) } } }这里有两个非常容易踩的坑。第一TextToSpeech的构造函数必须传一个OnInitListener语音引擎初始化是异步的不能在构造函数之后立刻调用speak否则大概率没声音。第二setLanguage(Locale.CHINESE)的返回值必须检查如果返回LANG_MISSING_DATA或LANG_NOT_SUPPORTED说明当前手机上压根没有可用的中文语音数据需要引导用户去系统设置里下载语音包。我做第一版时就是没检查返回值在模拟器上初始化成功换成一台旧款手机后直接没声音查了很久才发现是那部手机的TTS引擎中文数据是坏的。2.2 语速、音调和队列模式教科书里不会明说的参数细节初始化成功之后就可以设置语速和音调了fun setRate(rate: Float) { tts?.setSpeechRate(rate) // 默认1.00.5为半速2.0为两倍速 } fun setPitch(pitch: Float) { tts?.setPitch(pitch) // 默认1.0数值越大声音越尖 }这里我强烈建议把语速和音调做成可配置项不要写死。一个是实际体验中很多用户会觉得默认语速偏快或偏慢另一个是产品经理随时可能让你加一个“语速调节”的滑块提前预留好总比临时改要舒服。接下来是播放的核心方法speakfun speak(text: String) { if (!ready) return tts?.speak(text, TextToSpeech.QUEUE_FLUSH, null, tts_utterance) }第二个参数queueMode有两个选项QUEUE_FLUSH和QUEUE_ADD。QUEUE_FLUSH会清空当前播放队列立刻播最新的文本QUEUE_ADD则会把文本追加到队列尾部播完前面再播后面的。这个参数特别重要比如列表页有多个条目用户快速点击几个条目的朗读按钮如果都用QUEUE_ADD声音就会一个接一个排队体验很奇怪。我的做法是点击新的朗读时先stop()再以QUEUE_FLUSH重新开口。2.3 一个完整的发声与销毁生命周期还有个非常容易被遗忘的点Activity或Fragment销毁时必须释放TTS资源否则会出现“页面关掉了声音还在响”的诡异bug。fun release() { tts?.stop() tts?.shutdown() tts null ready false }shutdown()会释放底层引擎资源之后同一个实例不能再使用需要重新初始化。所以如果你要在一个页面里进入、退出多次记得在onDestroy里调用release在onCreate或onResume里重新init。2.4 集成到界面输入框加按钮就能跑起来工具类封装好后UI层就非常简单了。一个EditText输入要朗读的文字一个“播放”按钮触发speak一个“停止”按钮触发stop大概就是这个思路btnPlay.setOnClickListener { val content editText.text.toString() if (content.isNotEmpty()) { helper.speak(content) } } btnStop.setOnClickListener { helper.stop() }当然实际app的入口不会这么简单一般是文章详情页的“朗读”按钮或者评论区的“语音播报”但核心逻辑完全一致。第一版demo跑通之后我下载到真机上试了试心想功能总算有了。但说实话那时候我就隐隐感觉到系统自带的合成音实在太机械了念长文章根本没法听。产品体验这关后面还有得折腾。3. 模拟器正常、真机翻车跨设备适配的排查链路demo能跑只是第一步真正的噩梦是从我把apk发给同事测试那天开始的。同一套代码我的测试机上正常同事的机器上要么没声音要么直接闪退。这节我把几个真实排查过程完整写出来遇到类似问题可以照着排查。3.1 最先遇到的坑手机上没有可用的中文语音数据某位同事的手机是国产某品牌的中低端机型安装后点“播放”没有任何反应logcat里也没有明显异常。我一开始怀疑是权限问题后来突然想到初始化时有一段setLanguage(Locale.CHINESE)的返回值检查如果返回LANG_MISSING_DATA我会弹一个提示去下载语音包。但同事反馈“什么提示都没弹”。仔细一看代码发现问题了我虽然检查了LANG_MISSING_DATA但UI层把初始化失败回调的提示框写得过于“温和”只在键盘弹起时一闪而过同事压根没注意到。后来我换了一种更明确的处理方式初始化失败时弹一个对话框“检测到系统缺少中文语音数据是否前往设置页下载”点击后通过以下代码跳转语音合成设置界面fun openTtsSettings(context: Context) { try { context.startActivity(Intent(TextToSpeech.Engine.ACTION_INSTALL_TTS_DATA)) } catch (e: Exception) { // 部分机型没有这个入口需要引导用户去 设置-系统-无障碍-文字转语音 里手动设置 } }注意Android不同版本的TTS设置入口差异很大厂商ROM也经常修改路径所以捕获异常是必要的。3.2 第二个坑默认TTS引擎不同导致的发音异常另一个同事反馈朗读时声音更类似英文中文读得怪腔怪调。这个问题的本质是手机当前默认的TTS引擎不支持中文或者默认设置成了某个英文引擎。解决办法有两个方向。第一个是主动检查引擎支持情况代码里可以遍历已安装的TTS引擎val engines tts?.engines如果发现当前引擎不支持中文可以用tts.setEngineByPackageName(packageName)切换引擎但前提是那个引擎确实安装了。第二个方向是引导用户去系统设置里手动切换引擎。说实话这两个方案在用户侧都不够顺滑这也暴露了系统自带TTS的一个天然短板我们无法控制用户手机上的引擎质量。3.3 第三个坑应用闪退罪魁祸首是so库不完整有一位同事用的是模拟器安装后app直接闪退logcat显示是加载TTS引擎的so库失败。这个情况和手机无关是模拟器架构和真机不同导致的。后来我看了下项目配置发现没有做ABI拆分所有架构的so库都会打包但部分模拟器特别是老旧镜像仍然可能缺失某个架构的支持。这个问题的排查思路是注意看logcat里的dlopen failed相关日志查询缺少的是哪个平台库arm64-v8a、armeabi-v7a、x86等。不过这只是模拟器的问题真机上倒是没遇到。所以做TTS、语音识别这类依赖native库的功能真机调试比模拟器可靠得多。3.4 排查经验小结三步定位法踩了这么多次坑之后我总结出一套TTS问题定位的三步法分享出来先看初始化状态onInit回调的status是不是SUCCESSsetLanguage返回是否正常。再看引擎和语音数据通过tts.engines检查引擎用tts.isLanguageAvailable(Locale.CHINESE)确认中文语音数据是否存在。最后看播放时机speak是否在onInit成功之后调用queueMode是否符合预期。大多数“没声音”“闪退”“发音奇怪”的问题都能从这三步里找到答案。4. 体验升级长文本、进度回调、自定义音色都怎么实现系统自带TTS跑通之后距离一个能上线的功能还差不少。接下来这一步我把从“能发声”到“好用”的几项体验优化逐个讲清楚。4.1 长文本播报为什么整篇发送却没反应最初我以为speak方法传多长的字符串都行实测发现某些引擎对单次合成的文本长度有限制比如超过一定字数后合成直接失败或静默无反应。为了应对这个限制我写了文本切分的逻辑按标点符号句号、感叹号、问号、分号把长文本切成多个句子然后用QUEUE_ADD逐句加入播放队列。fun speakLongText(text: String) { if (!ready) return val sentences text.split(Regex([。])) tts?.stop() for (sentence in sentences) { if (sentence.isNotBlank()) { tts?.speak(sentence, TextToSpeech.QUEUE_ADD, null, UUID.randomUUID().toString()) } } }这样既能规避单次合成的长度限制又为后面的“按句暂停”打好了基础。这里有个细节切分后记得给每个句子分配一个唯一的utteranceId后面要用它来识别当前朗读到哪个句子。4.2 朗读进度与高亮UtteranceProgressListener的正确用法如果要做到“读到哪、高亮到哪”必须监听朗读进度。方法是通过setOnUtteranceProgressListener设置回调注意这个API在Android 5.0之后推荐使用UtteranceProgressListener老接口OnUtteranceProgressListener已经废弃。tts?.setOnUtteranceProgressListener(object : UtteranceProgressListener() { override fun onStart(utteranceId: String?) { // 某句话开始播放 runOnUiThread { highlightSentence(utteranceId) } } override fun onDone(utteranceId: String?) { // 某句话播放完成 } Deprecated(Deprecated in Java) override fun onError(utteranceId: String?) { // 播放出错 } override fun onRangeStart(utteranceId: String?, start: Int, end: Int, frame: Int) { // API 26以上可以拿到具体音素范围一般用不到 } })这个回调的触发器就是speak时传入的utteranceId所以前面切分长文本时给每个句子生成唯一ID正是为了在回调里定位“是哪一句”。实际做全文高亮时我会维护一个句子数据类包含文本内容和在文章中的起始位置用utteranceId查表得到当前句子并更新高亮。注意这个回调不一定在主线程更新UI一定要切回主线程。4.3 暂停、继续与停止不打断的朗读体验系统TTS本身没有现成的“暂停/继续”API不像播放器那么方便。安卓的TextToSpeech只在API 23以上有一个pause方法但兼容性不是很好。我实测下来在不同厂商ROM上行为不一致索性自己做一个简单的状态管理。我的做法是记录当前朗读的句子列表和索引点击“播放/继续”时从当前索引继续往队列里加句子点击“暂停”时立刻stop()点击“停止”时stop()并把索引清零。这样在系统引擎的限制下也能获得接近“暂停/继续”的体验。关于stop()后重新开始有一个需要特别注意的点stop()之后再次调用speak监听器可能不会正常触发onDone这是某些引擎的已知问题。解决方法是每次都重新设置一次OnUtteranceProgressListener或者在状态管理里做好兜底判断不要过度依赖onDone作为唯一状态源。4.4 音色与自然度从系统引擎换到云端API系统自带的音色确实有限当产品从“能听”变成“好听”的阶段就轮到云端API登场了。这也是我最早在封装层面做了隔离的收益体现。接云端API时流程一般是在云厂商控制台创建语音合成应用拿到AppID、APIKey、SecretKey。客户端把待合成文本和参数发音人、语速、音量、采样率等发送到服务端。服务端返回一个音频文件一般是MP3或PCM格式。客户端用MediaPlayer或AudioTrack播放这段音频。这里要注意的是鉴权方式每家都不一样。有的需要把密钥拼在URL参数里有的需要生成带时间戳的签名有的为了保证安全建议先经过自己的后端再转发给云厂商。如果你做的app不涉及敏感业务密钥直接放客户端也能跑但我不建议这么做因为反编译后很容易泄露。接云端API后之前的“按句切分”逻辑依然有效只不过不再是逐句调系统speak而是把整篇文章发给服务端返回整段音频再播放。如果想做逐句高亮就得按句请求并记录每句话对应的音频时长然后配合MediaPlayer的进度回调来联动高亮工作量会更大。4.5 给用户一个“语速调节”滑块很多阅读类app都有语速调节做起来其实不复杂。系统自带TTS直接用setSpeechRate取值范围建议0.5到2.0云端API也有对应的语速参数通常是-500到500之间的数值不同的厂商定义不同。我从产品体验角度补充一个建议语速调节滑块最好用百分比或“慢、正常、快”之类用户能理解的语言不要让用户面对0.5、1.0这类数字。另外语速变更后最好即时生效也就是用户拖动滑块时已经开播的内容能立即按新语速继续。系统TTS的setSpeechRate在播放中调用可能不会立即生效需要先stop再重新朗读当前句子这一点要提前跟产品对齐预期。5. 多端扩展鸿蒙和uni-app里面怎么做文字转语音如果你不只是做安卓端或者你的app本身就是用跨平台框架开发的这部分的思路可以继续往下看。很多热搜词里也提到“鸿蒙app开发小项目”“uniapp”的内容这里我把多端TTS的实现路径简单说一下。5.1 鸿蒙应用系统API的基本套路鸿蒙开发目前以ArkTS为主系统也提供了TTS能力。整体逻辑和Android很像都是“初始化引擎 → 设置参数 → 播放 → 回调监听”的套路只是API名称和回调方式不同。鸿蒙的TTS实现大概是import textToSpeech from ohos.textToSpeech; let ttsEngine textToSpeech.createEngine(); ttsEngine.speak({ text: 这是一段测试文本, language: zh-CN });注意鸿蒙的语音引擎能力在不同设备上支持程度不同尤其是早期鸿蒙版本和后续新版本差异很大。我在适配时遇到过“系统界面搜不到TTS设置入口”的情况后来发现需要检查系统是否包含语音数据包。稳妥的做法是先通过系统提供的API查询语言可用性不可用就直接走云端API兜底。5.2 uni-app用云API加音频播放兜底uni-app本身没有内置TTS组件第三方插件市场有一些原生插件但质量参差不齐更新不及时是常态。我的建议是uni-app项目里做文字转语音优先走“云API 本地播放”的组合。流程是通过uni.request调用云厂商TTS接口拿到合成的音频文件地址。用uni.createInnerAudioContext()或plus.audio.createPlayer()播放。通过播放进度回调实现实时跟读高亮。这种方案的好处是跨端一致iOS、Android、小程序都能跑。缺点是必须有网络。如果要做离线语音那就需要找一个同时支持多端的原生TTS插件或者自己写原生桥接成本和复杂度都会明显上升。5.3 什么时候应该让后端转发很多初学者会把AppID、APIKey直接写死在客户端这样做在简单工具里能跑但上线后极容易出问题。一旦key泄露盗刷费用是小被恶意调用导致封号才是大麻烦。我建议具备以下任一情况时必须走自己的后端转发TTS调用量大需要做缓存和限流有用户体系需要区分免费额度和付费额度内容涉及版权或需要内容审核不希望在客户端裸奔多端统一管理密钥不想在每端重复配置。后端转发并不复杂就是用自己的服务器封装一个接口前端传文本后端拿密钥去云厂商合成并返回音频。多一层转发多一次网络开销但对安全和可维护性的提升是巨大的。6. 打包发布前要盯住的几个细节权限、隐私、so库和体积功能开发完最终要走到打包上架。这一节的内容很多是从“app发布”热搜词背后那些真实翻车案例总结出来的建议在上架前逐条检查。6.1 权限能不加就不加尤其别乱申请麦克风权限做TTS播放不需要任何特殊权限不申请RECORD_AUDIO麦克风权限也不需要存储权限除非你要把合成的音频保存到本地。引起审核被拒的往往是开发者顺手申请了一堆“以后可能用到”的权限尤其是麦克风、定位、通讯录这种敏感权限。很多TTS功能本身和麦克风无关但团队里如果同时做了语音识别或语音输入很容易把RECORD_AUDIO顺手加进Manifest。这会在应用权限列表里显示“录制音频”哪怕实际没用到也会极大增加隐私审核的沟通成本。建议发布前检查一遍AndroidManifest.xml把不用的权限全部清掉。6.2 隐私政策用第三方语音SDK必须先声明如果用了讯飞、百度、腾讯等第三方语音合成SDK或者通过云服务商的API合成根据国内应用商店的审核要求必须在隐私政策中明确写明使用了哪些SDK、收集了哪些信息文本内容通常会被传送到服务器、用途是什么、第三方服务商的隐私政策链接是什么。这里容易被忽视的一点是传给云API的文本内容也属于用户数据处理。如果用户的文章、评论等内容是私有的上云端合成会涉及数据隐私问题。能合规的做法是告知用户“朗读功能会将文本发送到语音服务商进行合成”并在用户首次使用该功能时通过弹窗取得授权。很多人只做了App整体的隐私弹窗忽略了功能级别的单独告知被应用商店打回并不冤枉。6.3 so库与ABI精简安装包的第一步如果使用了原生TTS SDK那么apk里通常会包含针对不同CPU架构的so库。Android常见的AIB是arm64-v8a、armeabi-v7a、x86和x86_64。如果不加限制Gradle默认会把所有架构的so库都打进去导致包体变大。实际发布时建议在build.gradle的abiFilters里指定需要的架构ndk { abiFilters arm64-v8a, armeabi-v7a }armeabi-v7a兼容几乎所有老款arm手机arm64-v8a覆盖现在的主流机型x86和x86_64只在模拟器上用完全可以不打包。这里有个权衡如果完全去掉x86支持模拟器上调试就会跑不起来建议只在“release包”做ABI裁剪debug包保留全部架构方便调试。6.4 包体积与语音资源分离云API方案在包体积上非常有优势app只负责网络请求和播放安装包也就多几百KB。离线方案则往往要打包几十到几百MB的语音资源很容易被用户吐槽“安装包太大了”。如果必须用离线方案建议把离线资源包做成“首次使用时下载”的形态而不是直接打进apk。启动时通过接口下发资源包下载地址用户需要离线语音时再下载同时在下载页明确提示资源包大小。这样可以守住安装包第一印象又不牺牲离线功能。实测下来用户对“首次安装后额外下载100MB”的容忍度远高于“安装包本身100MB”。6.5 从apk到aab上架应用商店的注意事项现在Google Play会要求使用Android App BundleAAB格式而国内多数应用商店仍然接受apk。无论哪种格式只要集成了TTS SDK都要关注SDK的版本和targetSdkVersion是否符合当前商店要求。很多老项目的targetSdkVersion长期停在旧版本上架新版本时会被商店直接拒绝这不是TTS功能的问题但却是打包发布时最容易卡住的地方。建议发布前跑一遍lint检查确认没有高风险的漏洞再看一眼minSdkVersion和targetSdkVersion是否满足目标应用商店的规定。做完这些检查之后再提交审核基本不会在“技术层面”被打回了。最后再分享几条做TTS功能的经验这个功能从接到需求到真正能稳定上线前后折腾了不少时间。回看整个过程我个人觉得最有价值的几个判断是第一版用系统TTS快速跑通流程是对的它让我很快弄清了产品层面的所有关键问题但系统TTS的音质和跨机型一致性确实不太行正式上线还是得靠云端方案。另外真机调试永远是第一位的模拟器上出现的“正常”有太多假象比如语音数据、引擎差异、so库兼容这些问题在模拟器上往往不会暴露一上真机就原形毕露。如果你也正在做一个带朗读功能的小工具app或者正在做相关期末大作业、课程设计不用太担心。先用系统TTS把一个简洁的工具类跑通再加上几个关键回调就能应付80%的场景。等到产品真的要做大规模推广了再平滑替换成云端或离线方案是完全来得及的。动手试一下前先把自己机器的TTS设置页面打开把中文语音包装好能少踩很多坑。
返回列表