Flutter Android 收款语音播报

📅 2026/7/13 19:45:34 👁️ 阅读次数 📝 编程学习
Flutter Android 收款语音播报

Flutter Android 收款语音播报:从 TTS 首播失败到本地 WAV 兜底

在收银、到账提醒等场景中,语音播报看起来只是调用一次 speak(),真正落到 Android 设备上却并不简单。

这篇文章记录一个 Flutter 收款播报需求的完整排查过程:为什么 iOS 正常、部分 Android 手机却完全没有声音,为什么第一次进入页面不播、第二次才恢复,以及如何实现“系统 TTS 优先、本地 WAV 兜底”的可靠方案。

一、问题现象

最开始的调用非常直接:

SpeechService.speak('收款15.60元');

iOS 设备能够正常播报,但 Android 模拟器和部分国产 Android 手机出现以下日志:

W/TextToSpeech: isLanguageAvailable failed: not bound to TTS engine
W/TextToSpeech: speak failed: not bound to TTS engine

实际测试还发现两个更棘手的问题:

  • 手机没有安装或启用任何 TTS 引擎时,调用必然失败。
  • 手机存在可用引擎时,第一次进入收款成功页可能不播,第二次进入才正常。

在测试机上安装 Google 的 Speech Recognition & Synthesis 后,系统 TTS 立即恢复。这证明 Flutter 代码本身并不是唯一变量,Android 设备是否提供可用 TTS 引擎同样关键。

二、为什么 iOS 不需要额外安装,Android 却需要

iOS 的语音合成由系统框架提供,常规情况下可以直接通过 AVSpeechSynthesizer 使用。Flutter 插件在 iOS 上调用的是系统内置能力,因此用户通常感知不到“安装语音引擎”这一步。

Android 的 TextToSpeech 更像一套统一调用接口,真正负责合成声音的是系统或第三方 TTS Engine。不同品牌和系统版本可能出现以下情况:

  • 系统预装了可用 TTS 引擎。
  • 预装引擎不包含中文语音数据。
  • 系统设置里没有默认引擎。
  • 用户卸载、禁用或从未安装过语音引擎。
  • 第三方引擎存在,但受 Android 包可见性影响,应用查询不到。

因此,这不是申请一个麦克风权限或在厂商平台开通 API 就能解决的问题。TTS 是音频输出能力,通常不需要录音权限;问题的核心是设备上有没有可绑定、支持目标语言的合成引擎。

三、为什么别的 App 能播,自己的 App 不能

看到其他 App 可以播报,并不能证明它们使用了系统 TTS。常见实现至少有四种:

  • 调用设备系统 TTS。
  • 集成云端 TTS,服务端返回音频。
  • App 内置离线语音 SDK 或离线模型。
  • 内置预先录制的数字、单位和提示音,再按金额拼接播放。

后面三种都可以绕过“手机没有系统 TTS 引擎”的限制,因此仅凭使用体验无法判断别的 App 使用了哪种方案。

四、最终架构:系统 TTS 优先,本地 WAV 兜底

本次采用的是混合方案:

flowchart TDA[进入收款码页面] --> B[Android 提前初始化系统 TTS]B --> C[等待支付结果]C --> D[进入收款成功页面]D --> E{系统 TTS 是否准备成功}E -->|是| F[播报完整中文金额]E -->|否| G[解析金额]G --> H[拼接本地 WAV 片段]H --> I[顺序播放收款提示、数字、单位]

这个方案同时满足三点:

  • 有系统 TTS 时,优先获得更自然的语速、语调和连贯性。
  • 没有系统 TTS 时,不要求用户安装 Google 服务,仍然能够播报。
  • 不改动已经稳定运行的 iOS 播报路径。

五、不要等到成功页才初始化 TTS

第一次不播、第二次才播,通常是原生 TTS 绑定尚未完成导致的。

如果成功页一创建就立刻调用 speak(),Flutter 页面生命周期和 Android 原生 TextToSpeech.OnInitListener 可能发生竞态:第一次调用发生时还没绑定,第二次进入时上一次绑定已经完成,所以表现为“第二次才正常”。

更可靠的做法是在前一个业务页面预热:

class PaymentCodeController extends GetxController {@overridevoid onInit() {super.onInit();unawaited(PaymentSpeechService.prepare());startPaymentStatusListener();}
}

预热只做引擎绑定和参数配置,不播放静音文本,也不产生用户可听见的声音。

成功页在 Android 上放到 onReady() 触发,确保页面首帧已经建立:

class PaymentSuccessController extends GetxController {@overridevoid onReady() {super.onReady();if (GetPlatform.isAndroid) {PaymentSpeechService.speakAmount(amount);}}
}

如果 iOS 原来的 onInit() 播报已经稳定,就没有必要为了代码形式统一而改变它。

六、用单例 Future 表示“正在初始化”

仅仅把 FlutterTts 声明成全局单例还不够。多个入口可能同时调用初始化,如果每次都重新绑定,会增加状态竞争。

可以缓存同一个初始化 Future:

class PaymentSpeechService {static const _prepareTimeout = Duration(seconds: 3);static final FlutterTts _tts = FlutterTts();static Future<bool>? _preparation;static bool _ready = false;static Future<bool> prepare() {if (!Platform.isAndroid) return Future.value(true);if (_ready) return Future.value(true);final task = _preparation ??= _configureAndroidTts();return task.timeout(_prepareTimeout,onTimeout: () => false,);}
}

这里有一个非常重要的细节:超时应该限制调用方等待时间,而不是把底层初始化永久判定为失败。

Future.timeout() 不会取消原始 Future。某台模拟器曾在 3 秒时触发超时,但 0.2 秒后原生引擎就完成了连接。如果把带超时的 Future 本身缓存为最终结果,后续成功页仍会拿到 false,即使引擎已经准备好了。

上面的写法缓存原始 _configureAndroidTts(),每个调用方只限制自己的等待时间。底层任务稍后成功时,仍然可以把 _ready 更新为 true

七、Android 引擎发现和语言配置

优先使用用户设置的默认引擎。如果默认引擎为空,再从应用可见的已安装引擎中选择一个:

static Future<bool> _configureAndroidTts() async {final defaultEngine = await _tts.getDefaultEngine;var engineName = defaultEngine?.toString().trim() ?? '';if (engineName.isEmpty) {final engines = await _tts.getEngines;if (engines is List) {for (final engine in engines) {final candidate = engine?.toString().trim() ?? '';if (candidate.isNotEmpty) {engineName = candidate;break;}}}}if (engineName.isEmpty) return false;await _tts.setEngine(engineName);final languageResult = await _tts.setLanguage('zh-CN');if (languageResult != 1) return false;await _tts.setVolume(1.0);await _tts.setSpeechRate(0.5);await _tts.setPitch(1.0);_ready = true;return true;
}

Android 11 及以上还需要关注包可见性。若应用需要查询 TTS Service,可以在 AndroidManifest.xml 中声明:

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

这段配置的作用是允许应用发现 TTS 服务,并不会替用户安装引擎,也不是所谓的 TTS 权限开关。

八、系统播报失败后切换到 WAV

收款播报入口先尝试系统 TTS,失败后再解析金额并播放本地音频:

static Future<void> speakAmount(String amount) async {if (!Platform.isAndroid) {await speakWithOriginalIosPath('收款$amount元');debugPrint('Payment voice mode: iOS system TTS');return;}final requestId = ++_playbackId;await _stopAndroidPlayback();if (requestId != _playbackId) return;final spoken = await _speakWithSystem('收款$amount元',requestId,);if (requestId != _playbackId) return;if (spoken) {debugPrint('Payment voice mode: Android system TTS');return;}debugPrint('Payment voice mode: Android local WAV fallback');await _playLocalAmount(amount, requestId);
}

线上日志只保留“最终使用了哪种播报方式”,初始化细节和异常不持续刷屏,调试人员仍然可以快速确认实际分支。

九、本地 WAV 如何表达任意金额

本地方案不需要为每个金额录制一条完整音频,只需要准备有限的中文片段:

  • “收款”或“收款到账”
  • 数字零到九
  • 十、百、千、万、亿

例如 15.60 可以转换为:

收款 -> 十 -> 五 -> 点 -> 六 -> 元

文件名可以使用 one.wavtwo.wav,但文件内部录音仍然是中文“一”“二”。英文只是资源标识,不代表最终播放英文。

金额转换需要处理:

  • 整数位的十、百、千、万、亿。
  • 中间连续零的合并。
  • 10 到 19 开头时省略“一十”中的“一”。
  • 小数末尾无意义的零。
  • 超大金额无法按单位解析时的逐位降级。

十、为什么使用 video_player 播放 WAV

flutter_tts 负责文字合成,不负责播放项目中的 WAV 文件,所以本地兜底必须使用音频播放能力。

在已有项目中,如果已经依赖 video_player,可以复用 VideoPlayerController.asset() 播放 WAV,避免仅为兜底新增依赖。Android 端底层会使用 ExoPlayer。

如果是全新项目或音频功能较多,更推荐评估 just_audioaudioplayers 等专门的音频库,它们在音频队列、预加载和播放控制方面通常更合适。

无论选择哪种播放器,都要保证每个片段结束后释放资源,并为异常完成设置超时,避免播放队列永久卡住。

十一、全局单例不等于并发安全

收款、签到等事件可能连续触发。只有一个全局 FlutterTts 实例,可以避免重复绑定,但旧的异步任务仍可能在等待初始化后追上来播放。

解决办法是维护递增请求编号:

static int _playbackId = 0;static Future<void> speak(String text) async {final requestId = ++_playbackId;await stopCurrentPlayback();if (requestId != _playbackId) return;await startPlayback(text, requestId);
}

每个异步阶段开始前都检查编号。新请求到来后,旧请求自动失效,不会在几秒后突然补播。

具体业务还需要明确队列策略:到账提醒可能要求逐条播报,普通提示可能只保留最新一条。单例解决资源问题,队列或“最新请求优先”解决业务并发问题。

十二、验证方法

至少覆盖两类设备状态。

有系统 TTS 引擎

  • 冷启动后第一次进入收款码页。
  • 日志出现系统 TTS 已实际开始和结束的原生事件。
  • 成功页第一次进入即可播报。
  • 不应启动本地 WAV 播放器。

没有系统 TTS 引擎

  • 禁用测试设备上的 TTS 引擎并重启应用进程。
  • 收款码页初始化返回不可用。
  • 第一次进入成功页即启动 WAV 播放。
  • 每个音频片段初始化和释放数量一致。
  • 全流程无崩溃、无资源泄漏、无永久等待。

测试金额 13.55 时,本地序列应为:

收款 -> 十 -> 三 -> 点 -> 五 -> 五 -> 元

共 7 个片段,播放器初始化和释放次数都应为 7。

十三、几个容易踩的坑

  • 不要把 getEngines 的空结果直接等同于手机绝对没有引擎,优先检查默认引擎和 Android 包可见性。
  • 不要在成功页临时 Future.delayed() 后盲目播报,固定延时无法适配不同设备速度。
  • 不要为了预热而播放静音文本,初始化和参数配置本身就可以作为等待点。
  • 不要把调用方超时误认为底层 Future 已被取消。
  • 不要只做全局单例而忽略快速连续触发的异步竞态。
  • 不要把测试阶段生成的语音资源直接用于商业发布,必须确认录音和语音合成结果的商业分发授权。
  • 不要假设轮询一定比 Socket 更省服务器资源,支付状态查询的压力取决于轮询间隔、超时、并发和退避策略。

十四、方案取舍

系统 TTS 的优势是免费、自然、包体小;缺点是 Android 设备环境不可控。

本地 WAV 的优势是离线、确定、无需第三方账号;缺点是片段衔接感和音质不如完整句子合成。

云端 TTS 的优势是音色和一致性最好;缺点是需要网络、后端服务、鉴权和持续费用。

对于预算敏感且必须保证“至少能播”的业务,系统 TTS 加本地 WAV 是一个现实的折中。系统引擎负责体验,本地资源负责确定性。

结语

可靠的语音播报不是调用一次 speak(),而是要把设备能力、异步初始化、页面生命周期、并发控制和降级路径一起设计。

最终可以浓缩成四条原则:

  • 前置页面预热,不在结果页临时绑定。
  • Android 系统 TTS 优先,但不假设设备一定有引擎。
  • 系统能力失败时必须有本地可控兜底。
  • 超时、资源释放和并发失效检查缺一不可。

参考资料

  • flutter_tts
  • Android TextToSpeech
  • Flutter TTS does not find 3rd-party TTS engines