Android 的 TTS 引擎选择,系统 vs 第三方

Android 的 TTS 引擎选择,系统 vs 第三方

Android 的 TTS 引擎选择,系统 vs 第三方


「Android 的 TTS 引擎选择,系统 vs 第三方」


去年把 App 的 targetSdkVersion 升到 31(Android 12)之后,后台收到一条很诡异的崩溃链:用户在 Pixel 5 上点击播放按钮,应用直接闪退,堆栈里既没有 NullPointerException,也没有常见的 SecurityException,而是一个源自 TextToSpeech 的 IllegalStateException,提示 "Service connection disposed"。复盘之后发现,问题根源是我们在 Application 里提前 new 了一个 TextToSpeech 实例,而用户在系统设置里把默认 TTS 引擎切换成了刚安装的第三方引擎,绑定过程还没完成,Activity 就已经触发了 speak()。这件事让我意识到,Android 系统 TTS 的"开箱即用"其实是个幻觉。如果你真要在生产环境做语音合成,系统引擎和第三方 SDK 之间的边界远比文档上写的要脏。


系统 TTS 的异步初始化:一个被低估的并发陷阱


很多开发者第一次接触 TTS 时,会写出类似这样的代码:


TextToSpeech tts = new TextToSpeech(context, status -> {
    if (status == TextToSpeech.SUCCESS) {
        tts.setLanguage(Locale.CHINA);
        tts.speak("你好", TextToSpeech.QUEUE_FLUSH, null, "utt1");
    }
});

这段代码在 Demo 里大概率能跑通,但一旦放到线上,面对低端机和被厂商深度定制的 ROM,问题就会暴露。TextToSpeech 的构造函数只是向系统发起了绑定请求,真正的 ServiceConnection 建立发生在主线程 Looper 的下一次调度中。如果你在 onInit 回调之前,或者在另一个线程里并发调用 speak,行为是未定义的。更麻烦的是,Android 11(API 30)引入了 package visibility 限制,如果你的 AndroidManifest.xml 里没有显式声明查询 TTS 服务,getEngines() 可能返回空列表,甚至默认引擎名称是 null。


<queries>
    <intent>
        <action android:name="android.intent.action.TTS_SERVICE" />
    </intent>
</queries>

这个 queries 标签在 targetSdkVersion 30 以下是可选的,但从 30 开始变成强制。我踩过的坑是,某次灰度发布后,Android 11 用户反馈"语音按钮没反应",而 Android 10 完全正常。排查日志发现 TextToSpeech 初始化返回了 SUCCESS,但 setLanguage(Locale.US) 抛出了 -2(LANG_MISSING_DATA)。根因不是语言包缺失,而是由于 visibility 限制,系统根本没找到 Google TTS,回退到了某个不支持英文的厂商内置引擎。这种静默回退比直接崩溃更难查。


被劫持的默认引擎:国产 ROM 的隐形边界


在 AOSP 里,TTS 引擎是通过 com.android.settings/.tts.TextToSpeechSettings 让用户选择的,但国内各大厂商的定制系统往往预装了自己的语音引擎。华为有 com.huawei.hiai,小米早期用 com.svox.pico 或自研引擎,OPPO、vivo 也有各自的实现。它们都会注册 android.intent.action.TTS_SERVICE,所以系统 Settings 里看起来只是多了一两个选项,但对开发者来说,API 的语义被偷偷改了。


比如,Google TTS 支持通过 tts.setVoice(Voice) 在 API 21 以上切换男声、女声甚至不同区域口音,且 getVoices() 返回的列表通常包含离线高音质语音。但某国产 ROM 的引擎虽然实现了 TextToSpeechService,其 onGetVoices() 返回的列表永远只有一个默认条目,调用 setVoice() 实际上是个空操作。更隐蔽的是 UtteranceProgressListener 的生命周期。Google TTS 在 utterance 播放结束后会稳定回调 onDone,但我测试过两台三星 Galaxy 设备(One UI 4.1,Android 12),当语速被设置到 2.0f 以上时,onDone 有概率不被触发,导致我们的播放队列出现死锁。这个 Bug 在三星的开发者论坛和 Google Issue Tracker 上都能找到影子,但三星官方从未修复。


如果你做的是阅读类 App,需要精确知道每个句子什么时候读完,以便自动翻页或高亮,这种不确定性是致命的。系统 TTS 的另一个软肋是多音字和数字读法。中文里"一行代码"和"银行"的"行"读音不同,"123"在电话号码里应该读成"幺二三"还是"一百二十三",取决于上下文。系统引擎通常只做简单的词典匹配,遇到混合文本时经常读出令人哭笑不得的结果。


接入讯飞 MSC 的 API 地狱:当 TTS 不再继承 TextToSpeech


为了摆脱系统引擎的不确定性,我们评估了第三方方案,最终先把讯飞 MSC(Speech SDK)接进了测试分支。讯飞的合成不继承 android.speech.tts.TextToSpeech,而是自成一体:


SpeechUtility.createUtility(context, SpeechConstant.APPID + "=你的AppId");
SpeechSynthesizer mTts = SpeechSynthesizer.createSynthesizer(context, null);
mTts.setParameter(SpeechConstant.VOICE_NAME, "xiaoyan");
mTts.setParameter(SpeechConstant.SPEED, "50");
mTts.startSpeaking("你好", new SynthesizerListener() { ... });

这个设计意味着你之前围绕 TextToSpeech 封装的所有工具类、生命周期管理和单元测试全部作废。讯飞的初始化必须在主线程完成,如果在子线程调用 createUtility,会直接抛出 RuntimeException。它的回调默认也在主线程,如果播放长文本时你在 onSpeakProgress 里做 UI 更新,低端机上很容易掉帧。


更实际的问题是离线语音包的体积。讯飞的离线 TTS 需要把资源文件(common.jet、tts.jet 等)打包进 assets 或下发到内置存储,一个高质量的离线女声大约在 60MB 到 100MB 之间。对于已经为了 ABI 兼容而包含多个 so 库的 App 来说,这 40MB+ 的增量不是小数目。而且讯飞的 SDK 对 Android 12 的适配曾经滞后过,我们在 2022 年测试时发现其内部使用的 PendingIntent 没有指定 FLAG_IMMUTABLE,在 targetSdkVersion 31 的设备上直接崩溃,直到更新了 2022Q4 的版本才解决。


不过第三方 SDK 也确实解决了系统 TTS 的几个

Android 的 Storage Access Framework 性能,SAF 文档选择器的卡顿问题 2026-07-31

评论区