Android离线语音合成实践:基于系统TTS的纯本地文字转语音方案 简介一款面向 Android 开发者的离线文字转语音演示工程核心价值在于不依赖手机自带语音合成服务即使设备未安装任何文字转语音组件也能独立完成离线朗读并可自由切换发音人、调节语速适合需要在无网络或受限环境下提供语音能力的应用作为参考。压缩包共包含 25 个文件以 Java 源码、XML 布局与配置、prefs 工程配置以及 so 动态库为主整体体积仅 511KB轻量易部署其中 Java 代码负责核心调用逻辑XML 负责界面布局与资源定义so 库则封装底层离线合成能力。项目工程目录结构清晰源码、资源、库文件分区明确可直接导入集成开发环境运行便于快速验证离线语音合成效果并理解语音引擎的调用与集成方式。目前已有 3090 人学习下载适合需要接入离线语音能力或对现有方案进行二次开发的 Android 工程师。值得留意的是作者注明英文单词目前会逐字母朗读这也为后续优化留下了明确的改进方向。 做离线语音合成这个需求最初是因为一个巡检项目——现场在厂区深处网络信号约等于没有但设备必须把告警信息读出来。当时第一反应是找第三方SDK结果一调研要么要联网要么SDK体积大得离谱要么免费额度根本不够用。后来才把目光放回系统自带的android.speech.tts.TextToSpeech也就是常说的 Android 原生 TTS搭配离线语音包把文字转语音这件事彻底做成了纯本地能力。这篇文章就基于我整理的一个 android_tts_离线语音demo包完整拆解从选型、工程搭建、核心代码到实机踩坑的全过程给那些同样想把离线 TTS 做进 App、但又不想被第三方 SDK 绑死的同学一条可以照着走的路。1. 离线TTS选型复盘为什么这个Demo我只用系统引擎先说结论如果你的 App 需要的是中文播报、无网可用、包体可控、维护成本低这四个点Android 系统自带的 TextToSpeech API 就是性价比最高的方案。这个结论不是我拍脑袋得出的而是对比了一圈之后的结果。1.1 在线语音合成与离线合成的本质差异在线 TTS比如各类云厂商的语音合成接口有一个绕不开的问题所有文本都要先上传到服务器合成完再下载音频。好处是音色多、自然度高坏处也很明显——依赖网络、有延迟、有并发限制、有费用。在弱网或断网环境下业务直接瘫掉。对工业巡检、无障碍阅读、导航播报这类场景来说这种依赖是致命的。离线 TTS 则完全不同。语音合成在设备本地完成文本不出设备响应是毫秒级的。Android 系统从 5.0API 21开始自带的 TextToSpeech 引擎就已经具备比较完整的离线合成能力只要你把对应语言的语音数据下载到本地。它最大的限制是音色相对单一、自然度不如云端大模型但这个短板在告警提示、状态播报、短句朗读这类场景里完全够用。1.2 系统引擎与第三方SDK的取舍我做了个简单的对比表当时选型时基本就是看这几项对比维度系统 TextToSpeech第三方离线 SDK如讯飞离线、百度离线打包体积引擎和语音数据由系统/引擎提供APK 增量很小SDK 本体加语音资源通常几十 MB 起步集成成本纯系统 API无需额外权限和 key需要注册开发者账号、申请 AppID、配置密钥离线能力依赖系统引擎的离线语音包下载一次全局可用语音包内置于 App 或需单独下载授权费用免费一般按授权或设备数收费灵活性只能调用引擎内置音色可调语速/音调可能有更多发音人和情感参数这里有个容易忽略的细节系统引擎离线包装好之后是全局共享的。也就是说用户在系统设置里下载了中文中国的离线语音数据你的 App 直接就能用不需要再往自己包里塞任何语音资源。这也是我最终选择系统引擎的核心原因——省包体、省流量、省授权费。1.3 Demo 的核心目标边界我做的这个 demo 包里没有花哨的功能只围绕一件事把文字转语音做成一个稳定、可复用的离线播报模块。具体来说包含四块逻辑初始化 TextToSpeech 引擎并处理异步回调检查中文语音数据是否可用缺失时引导用户安装离线语音包调用 speak 方法完成文字转语音并监听播报进度处理 Activity 销毁、引擎启动失败等生命周期和异常问题。这套东西从Demo变成能上线的模块其实就差几个坑没踩平。下面把每一步都摊开讲。2. 工程搭建与离线语音包准备动手前先弄清这几件事2.1 Android Studio 工程的基本配置我用的是常规的 Android Studio 项目模块结构没做任何特殊处理。关键的配置集中在build.gradle里android { compileSdk 34 defaultConfig { applicationId com.example.ttsdemo minSdk 21 targetSdk 34 versionCode 1 versionName 1.0 } }minSdk 21对应 Android 5.0这是系统 TTS 离线能力比较完整的起点。如果你的业务设备系统版本更低也不是不能用但离线语音包的管理逻辑会弱一些我不建议在太老的系统上折腾离线 TTS。权限方面有个反直觉的点离线 TTS 完全不需要网络权限。我在 demo 的AndroidManifest.xml里特意不声明INTERNET权限这既是为了确认离线是真的离线也能防止审核时被质疑多余权限。如果你只是做纯本地播报千万别手滑加网络权限。manifest xmlns:androidhttp://schemas.android.com/apk/res/android application android:labelstring/app_name android:themestyle/Theme.AppCompat activity android:name.MainActivity android:exportedtrue intent-filter action android:nameandroid.intent.action.MAIN / category android:nameandroid.intent.category.LAUNCHER / /intent-filter /activity /application /manifest2.2 离线语音包从哪来这是最容易被误解的地方。很多人以为离线语音包是 SDK 的一部分要自己下载然后塞进 assets。其实在系统 TTS 的体系里语音数据由 TTS 引擎自己管理。常见的引擎包括 Google 的 Speech Services、部分国产 ROM 自带的语音引擎比如小爱语音引擎、讯飞语音引擎它们都遵循 Android 的 TTS 框架接口。所以流程是这样的App 通过接口询问引擎中文普通话能不能合成引擎返回LANG_MISSING_DATA缺数据或LANG_AVAILABLE可用如果缺数据App 发送一个系统 Action跳转到语音数据安装页面用户在页面里点下载等下载完成语音数据就躺在系统里了之后任何 App 再调用同一个引擎做中文合成都是离线完成。话虽简单这里藏着一个巨大的坑不同厂商 ROM 自带的引擎不一样跳转安装页的代码在部分机型上会直接找不到页面。这个问题我在后面踩坑记录里详细说先记住结论——不能假设 ACTION_INSTALL_TTS_DATA 在所有设备上都能跳转成功。2.3 检查语言可用性的标准写法初始化 TTS 时最核心的一件事是检查语言状态。标准的onInit回调里我们拿到TextToSpeech实例后立刻设置语言并用返回值判断TextToSpeech tts new TextToSpeech(context, status - { if (status TextToSpeech.SUCCESS) { int result tts.setLanguage(Locale.CHINESE); if (result TextToSpeech.LANG_MISSING_DATA || result TextToSpeech.LANG_NOT_SUPPORTED) { // 语言数据缺失或引擎不支持 Intent installIntent new Intent(TextToSpeech.Engine.ACTION_INSTALL_TTS_DATA); installIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); startActivity(installIntent); } else { // 中文离线语音可用可以正常 speak } } });setLanguage的返回值非常关键很多人栽在这。返回LANG_MISSING_DATA不等于引擎不行只是语音包没装返回LANG_NOT_SUPPORTED才是引擎压根不支持中文。这两者的处理策略完全不同LANG_MISSING_DATA引导用户去下载语音包这是可修复的状态LANG_NOT_SUPPORTED建议用户更换 TTS 引擎比如装一个支持中文的引擎并在系统设置里切换。3. 核心代码链路初始化、语言检查、语音合成与进度回调这一节直接把我 demo 里最核心的封装代码摊开讲。我不会贴完整的一百行类而是把每个职责拆分出来你照着拼就能用。3.1 初始化引擎启动是异步的状态机必须设计好TextToSpeech的构造函数是异步初始化onInit回调触发时引擎才真正可用。这个异步坑了好多人——很多人直接在new TextToSpeech()之后立刻调用speak结果压根没声音就是因为引擎还没就绪。我的处理方式是给 TTS 模块维护一个简单的状态public class TtsManager { private TextToSpeech tts; private boolean initialized false; private final QueueString pendingQueue new ArrayDeque(); public void init(Context context) { if (tts null) { tts new TextToSpeech(context, status - { if (status TextToSpeech.SUCCESS) { int langResult tts.setLanguage(Locale.CHINESE); if (langResult TextToSpeech.LANG_AVAILABLE) { initialized true; // 把初始化期间积压的文本一次性播报 while (!pendingQueue.isEmpty()) { speakInternal(pendingQueue.poll()); } } } }); } } public void speak(String text) { if (!initialized) { pendingQueue.offer(text); return; } speakInternal(text); } private void speakInternal(String text) { tts.speak(text, TextToSpeech.QUEUE_ADD, null, utterance_ System.currentTimeMillis()); } }看起来简单但这里面有两个细节值得说积压队列初始化完成前调用方可能已经发来了好几条播报请求比如 Activity onCreate 里连续调用。如果不缓冲这些播报会全部丢弃。这个队列是在真机上被吞播报之后才加上的。utteranceId 唯一speak 的第四个参数是 utteranceId它用来关联进度回调我故意用时间戳保证唯一否则回调里可能出现混乱。3.2 speak 队列模式QUEUE_FLUSH 和 QUEUE_ADD 到底怎么选这是 TTS 使用中最让人纠结的一个点。简单说QUEUE_FLUSH清空当前正在播和排队的语音立刻播这一条QUEUE_ADD把这条语音追加到当前队列尾部播完前面的才轮到它。业务里通常的规则是紧急打断型播报用 FLUSH普通通知型播报用 ADD。比如巡检设备报温度过高这种必须立刻打断当前播报用 FLUSH如果是北京时间十二点整排队讲就行用 ADD。我把这个选择直接暴露成接口参数public void speak(String text, boolean interrupt) { int queueMode interrupt ? TextToSpeech.QUEUE_FLUSH : TextToSpeech.QUEUE_ADD; tts.speak(text, queueMode, null, utterance_preview); }这里有个性能隐患如果连续多条 ADD 且每段文本很长TTS 引擎会逐句合成并排队内存和 CPU 都会有压力。所以我在 demo 里对长文本做了简单分段限制超过某个长度就拆成多条按 ADD 送入避免一条超长文本让引擎卡顿。3.3 进度回调播报完成后的钩子光有 speak 还不够很多时候业务需要知道这段话什么时候读完。比如播报完一条告警后要紧接着执行下一步操作。这个钩子由UtteranceProgressListener提供tts.setOnUtteranceProgressListener(new UtteranceProgressListener() { Override public void onStart(String utteranceId) { // 开始播报 } Override public void onDone(String utteranceId) { // 播报完成 } Override public void onError(String utteranceId) { // 播报失败 } });注意onDone是在引擎的 Binder 线程回调的不能直接更新 UI需要 post 到主线程。还有个小坑旧版本的OnUtteranceCompletedListener已经废弃但很多老教程还在用新项目请直接走UtteranceProgressListener。3.4 语音参数调节语速、音调与音量离线 TTS 的可调参数不算多但够用// 语速1.0 为正常速度0.5 慢一倍2.0 快一倍 tts.setSpeechRate(1.0f); // 音调同样以 1.0 为基准 tts.setPitch(1.0f);这两个参数直接影响听感。我的实测经验是告警类文本建议语速 1.2 左右音调 1.0太快容易听不清关键信息太慢又拖沓朗读类内容可以语速 0.9音调 1.0听起来更从容。音量则直接用系统的媒体音量控制也可以在合成前用AudioManager临时调整但注意别忘了恢复。3.5 生命周期管理Activity 销毁时及时释放如果不释放 TTS 实例会一直占用引擎资源严重时导致后续初始化变慢甚至失败。标准做法是在Activity.onDestroy()里 shutdownOverride protected void onDestroy() { if (ttsManager ! null) { ttsManager.shutdown(); } super.onDestroy(); }这里有个真实场景如果 App 里多个页面都会播报建议把 TTS 模块做成单例由 Application 统一持有而不是每个 Activity 都创建新实例。否则每次进页面都要等待异步初始化体验很差而且频繁创建/释放容易触发引擎的 Binder 连接问题。4. 真机运行中的实测踩坑记录Demo 跑起来容易跑稳很难。这一节是我在实机上踩过的坑每一个都花过不少时间排查。4.1 坑一部分机型跳转语音包安装页直接报 ActivityNotFoundException这是个经典问题。ACTION_INSTALL_TTS_DATA并不是所有 ROM 都实现了。某些国产 ROM 的引擎包名不同对应的安装页面 Activity 也不在默认路径下直接 startActivity 就会抛异常。排查链路是这样的先用adb shell dumpsys package查看当前默认 TTS 引擎是什么再用PackageManager.queryIntentActivities检查 ACTION_INSTALL_TTS_DATA 是否有可响应的 Activity如果确实没有就得退而求其次跳转到系统文字转语音设置页Settings.ACTION_TEXT_TO_SPEECH_SETTINGS让用户手动去设置里安装语言数据。示例代码private void openTtsSettings(Context context) { try { Intent intent new Intent(TextToSpeech.Engine.ACTION_INSTALL_TTS_DATA); intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); context.startActivity(intent); } catch (ActivityNotFoundException e) { // 降级打开系统 TTS 设置页 Intent settings new Intent(Settings.ACTION_TEXT_TO_SPEECH_SETTINGS); settings.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); context.startActivity(settings); } }降级方案虽然多了用户手动操作但至少不会崩溃。在特殊硬件设备比如工业平板上这个降级路径几乎是必走的。4.2 坑二明明设置了语言为什么还是 LANG_NOT_SUPPORTED这个问题绝大多数出现在模拟器优先开发的人身上。电脑模拟器里的 TTS 引擎经常没有中文语音数据甚至根本没有可用的离线引擎。你在模拟器上测试setLanguage返回 LANG_NOT_SUPPORTED 是正常的不代表真机上不行。解决办法只有一个尽早用真机测试。如果手边只有模拟器可以先在系统设置里看文字转语音输出是否有可用的引擎和语言数据。这个排查顺序很重要别一上来就怀疑代码写错了。4.3 坑三QUEUE_ADD 模式下播报文本堆积这个坑比较隐蔽。业务里如果有一条超长文本被切成几十段连续 ADD或者短时间内收到大量播报请求TTS 队列会越积越长用户听到的就是没完没了的朗读而且想打断都难。我的处理策略是给队列加一个水位线。当 pending 的播报超过 N 条时直接丢弃中间部分只保留最新一条。对于告警类场景丢旧保新是完全合理的——用户只需要知道当前最紧急的状态。public void speak(String text, boolean interrupt) { if (interrupt) { tts.speak(text, TextToSpeech.QUEUE_FLUSH, null, utterance_ System.currentTimeMillis()); } else { if (pendingCount MAX_QUEUE_SIZE) { tts.stop(); // 清空已有队列 } tts.speak(text, TextToSpeech.QUEUE_ADD, null, utterance_ System.currentTimeMillis()); } }当然stop()之后立即 ADD 偶尔会丢首句所以稳妥一点的做法是先短暂延迟再 ADD或者在 onDone 回调里再发下一条。这些细节需要在具体场景里调。4.4 坑四初始化完成后立刻 speak 偶发无声这也是异步初始化带来的连锁问题。onInit回调里setLanguage成功后立刻调用speak听感上有时会丢前半句。原因是引擎虽然报告初始化完成但语音数据可能还在加载尤其是刚下载完离线包引擎还没有重新扫描数据。我的解决方法是在 onInit 成功但不立刻 speak而是在极短延迟比如 100ms后再发或者等待第一次 onStart 回调后再发后续内容。这个极短延迟听起来不够优雅但在实际项目中确实能有效解决偶发丢字。4.5 坑五多个 TTS 实例互相干扰如果你在 App 里同时创建了两个 TextToSpeech 实例操作同一个引擎有时会出现其中一个实例的设置语速、音调影响另一个实例甚至出现一个正常播报另一个没声音的情况。排查这个问题的思路是先全局搜索new TextToSpeech确认只有一个实例。我的 demo 直接把 TTS 模块做成了 Application 级别的单例从根上避免这种问题。这也是为什么我建议不要在页面级频繁创建销毁 TTS 的原因之一。5. 从Demo到可落地的模块性能、体验与后续优化方向Demo 跑通只是一个起点。真正让它变成可用模块还需要做一些性能与体验层面的打磨。5.1 预初始化让第一句播报更快如果等到用户点击播报按钮时才去创建 TTS 实例第一次播报的延迟会非常明显引擎初始化加语音数据加载冷启动可能几百毫秒到一秒多。更好的做法是在 Application 启动时异步初始化 TTS甚至在 Activity 启动后空闲时预热。我的做法是Application.onCreate 里启动一个低优先级线程初始化 TTS初始化完成后仅设置好语言和参数不立即播报用户真正触发播报时实例已经 ready首句几乎零延迟。这样做的代价是进程启动时多占了几 MB 内存但换来的体验提升非常明显。5.2 音频焦点别让播报和音乐打架离线 TTS 默认走的是媒体的音频流STREAM_MUSIC)。如果用户正在听音乐播报会直接盖过去音乐停了之后还得手动恢复体验很差。建议在 speak 前请求音频焦点播报完再释放。Android 的AudioManager提供了requestAudioFocus和abandonAudioFocus配合setAudioAttributes使用更专业AudioAttributes audioAttributes new AudioAttributes.Builder() .setUsage(AudioAttributes.USAGE_ASSISTANCE_ACCESSIBILITY) .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH) .build(); tts.setAudioAttributes(audioAttributes);这个细节在文档里不难找但很多人根本不知道 TTS 还能设置 AudioAttributes。对于做无障碍阅读或者导航播报的场景这一项几乎是必做的。5.3 长文本分段策略别让引擎吃撑系统 TTS 对超长文本的处理并不理想。我实测过一篇几千字的文章一次性丢给 TTS 合成不仅合成的等待时间很长而且播报过程中如果用户打断了恢复的粒度和灵活性都很差。比较实用的做法是将长文本按中文标点句号、问号、感叹号、分号拆分成短句逐条调用 speak用QUEUE_ADD串起来。这样打断和恢复的单位都变小用户操作更跟手。public void speakLongText(String text, boolean interrupt) { String[] sentences text.split((?[。])); int mode interrupt ? TextToSpeech.QUEUE_FLUSH : TextToSpeech.QUEUE_ADD; for (String sentence : sentences) { String trimmed sentence.trim(); if (!trimmed.isEmpty()) { tts.speak(trimmed, mode, null, utterance_ System.currentTimeMillis()); mode TextToSpeech.QUEUE_ADD; // 后续全部排队 } } }这个策略是我在实际项目中踩过长文本播报吞字、卡顿之后总结出来的虽然是笨办法但稳定可靠。5.4 神经网络TTS与传统TTS的取舍现在的系统引擎如果比较新比如 Android 11 以上的 Google 引擎部分语言已经支持基于神经网络的高质量合成自然度比传统拼接合成好很多。但要注意两点神经网络语音包体积通常更大下载耗时更久部分低端设备上神经网络 TTS 的首字延迟可能反而更高因为推理需要更多 CPU 时间。所以在硬件配置不高的设备上我倾向于使用传统语音包或者给用户提供高质量/低延迟的选择开关。5.5 一个容易被忽略的收尾引擎监听与切换如果用户在系统设置里切换了 TTS 引擎比如从 A 引擎换成 B 引擎你的 App 里已经创建的 TextToSpeech 实例并不会自动跟随切换。需要在onResume里检测默认引擎是否变化如果有变化就重建实例。我是在实际设备上碰到过一次切换引擎后 App 播报没声的问题排查半天才知道是这个原因。检测方法很简单String currentEngine tts.getDefaultEngine(); // 在 onResume 里与上次记录的引擎比较不同则 recreate这个场景不算高频但做稳定的产品必须覆盖。最后分享一点个人体会系统 TTS 这套方案看着简单真正用起来还是有不少细节需要打磨。尤其是离线语音包的引导安装、不同厂商 ROM 的兼容性、初始化异步的时序问题这三件事几乎每个项目都会遇到。我见过不少人因为前两次碰到坑就放弃系统 TTS转头去接第三方离线 SDK结果包体涨了、授权费用来了、限制也更多了。其实只要把状态管理做扎实系统引擎的离线 TTS 完全能撑起大部分中文播报场景。后续如果你想在这套 demo 基础上继续扩展可以考虑的方向有两个一是把 TTS 模块封装成 AIDL 跨进程服务供多个应用共享二是把播报内容与业务事件解耦做成订阅式的播报中心。这两块的思路以后有机会再单独写一篇。本文还有配套的精品资源点击获取