最近群里好几个做叫号系统、外卖接单通知、物流提醒的朋友,都在问同一个问题:UniApp收到推送之后,怎么让手机自动把内容念出来?而且要求别装一堆插件,最好项目本身就能跑。说实话,我第一次接到这个需求时也走了弯路,去插件市场搜“语音播报”,下载了三四个插件试了一圈,要么只兼容老的Android系统,要么和项目里已有的推送插件互相抢占资源,有一个还直接把页面栈顶的Webview弄崩了。后来我把整套方案推倒重做,改成不依赖任何第三方插件,只用UniApp自带的plus桥接能力直接调用Android系统文本转语音引擎,再配合厂商推送通道和一套保活策略,这套组合线上跑了半年多,稳定性比之前好了不止一个档次。这篇文章就把整套实现完整拆开,消息怎么进、语音怎么报、后台怎么保活、坑在哪里,一次讲清楚。
1. 方案选型:为什么放弃插件,改走“原生桥”
1.1 插件市场的坑,我踩了一遍
插件市场里能搜到的UniApp语音播报插件,大体分两类:一类是封装了在线语音合成SDK,比如讯飞、百度这种,功能很强,但引入之后包体积直接飙升,而且很多是闭源SDK,离线打包时经常要跟原生工程做额外适配;另一类号称轻量级,实际就是调用系统TTS接口包了一层,问题在于这类插件很多已经停止维护,Android 11、12上通知权限和后台启动限制收紧之后,经常出现初始化失败、播报没声音的情况。
更麻烦的是,这类插件通常带着自己的原生依赖,一旦跟主工程的AndroidX版本、targetSdkVersion不匹配,编译能给你报一堆莫名其妙的错。我之前试过一个插件,单独跑没问题,一合进项目就触发so库冲突,排查了半天,最后只能放弃整个插件。所以后来我干脆想明白了一个道理:语音播报的核心能力是Android系统自带的,UniApp的运行环境本身又允许通过plus.android直接调用原生类,那为什么不自己写一个轻量封装,把主动权握在自己手里?
1.2 这套方案到底适合什么场景
先说清楚适用范围,免得有人拿着这套方案去硬套不合适的场景。如果你的需求是“收到订单/消息后,把文本内容用系统语音读出来”,而且播报文本是动态变化的、可能来自服务端推送,那这套方案非常合适。它不依赖网络,离线也能播,用的是系统内置TTS引擎,亚秒级就能开始说话,稳定性比走在线合成还要强一些。
但如果你的需求是让语音播报带真人音色、多情感、多音色定制,那系统TTS确实做不到,这类需求还是得用专业语音合成SDK。另外,如果产品需要iOS端也实现同样强度的后台自动播报,纯JS这套方案在iOS上是受限的,后面我会专门说iOS的问题。总的来说,这套方案最适合中轻量级的业务场景,比如门店叫号、快递到件提醒、外卖订单提示、设备告警通知。这类场景对音色要求不高,但对“来了消息能马上念出来”这件事要求极高,系统TTS正好满足。
2. 消息推送接入:先把“到手消息”变成可播报文本
2.1 推送通道选型:厂商通道和普通通道不是一回事
要实现语音播报,第一步是先让App收到消息。消息怎么才能可靠地到达App,这里面门道不少。UniApp最常见的推送方案是基于DCloud的UniPush,它内部把各家厂商的推送通道都封装了一遍,你在前端只需要面对一个统一的API。
厂商通道和普通在线通道的核心区别,我打个比方:普通通道相当于App自己维护了一条网络连接,服务端通过这条连接把数据推过来,App在后台且进程还活着的时候能收到;厂商通道则不同,它是绕开App进程,由手机系统级的推送服务直接把消息送到通知栏。后者的好处是即使App进程被系统回收了,只要手机厂商推送服务还在运行,消息照样能到通知栏。
对语音播报这个需求来说,这里要特别关注推送消息的类型。UniPush里有通知消息和透传消息两种:通知消息会直接展示在系统通知栏,如果App进程被杀,用户点击通知后才会启动App;透传消息则直接发给App,由App里的JS代码去处理。我们要做语音播报,就必须要靠透传消息,因为只有透传消息才能让App在后台收到内容后,主动调用TTS去念,而不是被通知栏拦截。
有的同学可能会问:如果App进程都被系统杀掉了,透传消息还能触发语音播报吗?这里要坦白说,进程被杀透传就触达不到,只有厂商通道的厂商服务才能启动有限能力。所以光靠推送还不够,保活策略必须跟上,第4节我会重点讲。
2.2 前端监听透传消息,进入播报准备
UniPush前端事件监听很简单,在App.vue的onLaunch里挂一个uni.onPushMessage,收到透传消息后解析出文本内容,再交给后续的TTS播报函数处理。这里我来写一个比较典型的监听逻辑:
// App.vue 中 onLaunch 里注册 uni.onPushMessage((res) => { console.log('收到推送消息:', JSON.stringify(res)) if (res.type === 'transmit') { // 透传消息:App在后台或前台都能收到原始数据 handleTransmitMessage(res.payload) } else if (res.type === 'click') { // 用户点击了通知栏消息,此时App已经拉起 handleClickMessage(res.payload) } }) function handleTransmitMessage(payload) { // payload 可能是JSON字符串,也可能是对象 let parsed = payload if (typeof payload === 'string') { try { parsed = JSON.parse(payload) } catch (e) { parsed = { text: payload } } } // 兼容服务端不同字段名,取到一个text字段作为播报内容 const text = parsed.text || parsed.content || parsed.msg if (text) { speakText(text) } }这里有几个细节值得注意。第一,服务端发透传消息时,payload字段类型尽量统一,建议服务端约定好固定格式,比如统一用JSON字符串,客户端解析时才不用写一堆兼容逻辑。第二,在Android 13及以上的系统,通知权限是运行时权限,需要在App启动时主动申请,否则系统可能直接把通知通道静默掉,后续推送和播报都会受影响。权限申请代码可以这样写:
const Permission = plus.android.importClass('android.Manifest$permission') const main = plus.android.runtimeMainActivity() plus.android.requestPermissions( [Permission.POST_NOTIFICATIONS], function (result) { console.log('通知权限申请结果:', JSON.stringify(result)) }, function (error) { console.error('通知权限申请失败:', JSON.stringify(error)) } )申请权限的位置放在App启动后第一屏最合适,不要一上来就弹,否则用户很容易拒绝。等用户理解这个App是用来接单/收提醒的,再弹权限成功率会高不少。
3. 动态语音播报核心实现:无插件TTS
3.1 先用plus.android把系统TTS引擎拉起来
UniApp提供了plus.android这个原生桥接对象,它可以通过importClass把Android原生类引到JS里直接使用。Android系统自带了一个文本转语音引擎,就是我们常说的TTS(TextToSpeech),通过这个引擎,我们可以让App在收到消息后,把文本自动念出来。
完整初始化代码我放在这里,这个封装我在项目里跑了好几个月,可以直接参考:
let ttsInstance = null let ttsReady = false const textQueue = [] function initTTS() { const TextToSpeech = plus.android.importClass('android.speech.tts.TextToSpeech') const Locale = plus.android.importClass('java.util.Locale') const main = plus.android.runtimeMainActivity() ttsInstance = new TextToSpeech(main, new TextToSpeech.OnInitListener({ onInit: function (status) { if (status === 0) { // 0 表示 TextToSpeech.SUCCESS const langResult = ttsInstance.setLanguage(Locale.CHINESE) // 0=LANG_AVAILABLE, 1=LANG_COUNTRY_AVAILABLE, 2=LANG_COUNTRY_VAR_AVAILABLE if (langResult === 0 || langResult === 1 || langResult === 2) { ttsReady = true console.log('TTS引擎初始化成功,可以开始播报') flushQueue() } else { console.error('TTS语言设置失败,langResult=' + langResult) } } else { console.error('TTS引擎初始化失败,status=' + status) } } })) } function speakText(text) { if (!ttsReady) { // 初始化还没完成,先把文本放到队列里 textQueue.push(text) return } // 第二个参数:0=QUEUE_FLUSH,打断当前播报立即念新内容 // 第四个参数:utteranceId,用于识别每次播报,不能重复 ttsInstance.speak(text, 0, null, 'uni_tts_' + Date.now()) } function flushQueue() { while (textQueue.length) { speakText(textQueue.shift()) } }初始化TTS的时机很关键。我建议把initTTS放在App.vue的onLaunch里,App一启动就初始化,不要等到收到第一条消息才初始化。因为TTS引擎首次初始化可能要几百毫秒到一两秒,如果消息到了再去初始化,用户就会听到播报延迟甚至丢失。
提示:textQueue这个队列很重要。TTS初始化是异步的,onInit回调触发前,消息可能已经来了好几条。如果不做排队,这些消息就会在播报函数里被吃掉,什么都没念出来。这段代码里的flushQueue就是干这个用的。
3.2 动态文本拼接、播报去重与打断策略
接单类、叫号类场景有一个共同特点:消息来得又快又密。比如外卖平台高峰期,一分钟能进来好几单,如果每条都念一遍,声音会糊成一团,用户根本听不清。这里我推荐两个策略,根据自己的业务需求选。
第一个策略是“去重”。同一个订单号,或者内容完全相同的消息,在很短时间内到达多条,只播报第一条。实现起来很简单,记录上一次播报的文本和时间,在3到5秒内重复内容直接丢弃:
let lastText = '' let lastSpeakTime = 0 function speakText(text) { const now = Date.now() if (text === lastText && now - lastSpeakTime < 3000) { console.log('检测到相同消息,3秒内跳过') return } lastText = text lastSpeakTime = now // ... 原来的播报逻辑 }第二个策略是“抢占式打断”。新消息比当前正在播报的消息更重要时,直接用QUEUE_FLUSH(代码里的0)打断当前播报,立刻念新消息。如果所有消息优先级一样,或者希望按照到达顺序依次念完,那就要用QUEUE_ADD模式,这是1:
// 按顺序排队,不打断当前播报 ttsInstance.speak(text, 1, null, 'uni_tts_' + Date.now())我实际用下来,大多数业务场景适合用打断式。因为用户听到的往往是“新来的一单”,而不是把之前积压的几十单全部重新念一遍。打断式还有一个好处:如果文本很长,被打断的地方不会让它继续浪费时间。动态拼接方面,建议在播报前把消息格式化成自然语言,比如“您有一个新订单,订单号9527,请在3号窗口取餐”,这种句式比直接念JSON字段要友好得多。
function formatMessage(data) { // data可能是服务端推送的对象 return `您有一个新${data.type || '订单'},单号${data.orderId || ''},请到${data.station || '取餐台'}领取` }3.3 生命周期处理:别让播报在页面销毁后失控
这是我从一次线上事故中学到的教训。最早我把TTS初始化放在某个业务页面里,结果用户退出那个页面后,TTS实例直接变成野指针,要么界面上不播报,要么页面销毁了还在后台一直念,越念越多。后来把所有TTS相关的状态都提升到了全局。
建议把TTS实例挂在App.vue的data或全局变量上,页面销毁跟播报生命周期彻底分离。收到推送消息时,语音播报负责把消息念完;用户停留在哪个页面,不影响播报逻辑。除非App真的要退出,不建议在页面级主动调用shutdown。
如果是App退出时需要释放资源,可以这样:
function destroyTTS() { if (ttsInstance) { ttsInstance.stop() ttsInstance.shutdown() ttsInstance = null ttsReady = false textQueue.length = 0 } }但正常情况下不要频繁destroy和重新create。Android的TTS引擎每次create都会建立一次音频会话,高频创建销毁会导致声音卡顿、掉字,甚至在一些低端机上报“TTS init failed”。一次初始化、全程复用,这是最稳的。
3.4 iOS端该怎么办,得把预期管理好
这套纯JS无插件方案,在iOS上有一些现实限制。iOS系统本身没有开放类似Android的TTS系统级接口给普通WebView直接调用。UniApp在iOS端能通过原生插件或离线打包集成本地TTS,纯JS方案在iOS上做不到稳定可靠的后台自动播报,尤其是App在后台、屏幕锁定时,WebView的JS执行会被系统挂起,就算能调起TTS,也扛不过几秒钟。
如果产品必须支持iOS自动播报,我的建议是:iOS端走离线打包,在原生层集成AVSpeechSynthesizer,然后把播报能力封装成原生插件给JS调用。这样iOS上也能做到收到透传后本地合成语音。如果不想投入原生开发,那就做成降级方案:iOS端收到推送只展示通知栏,由用户点击后进入App再播报,或者直接播放一段预置提示音。
注意:如果你的项目目标用户里iPhone占比很低,可以先只做Android端的自动语音播报,iOS端用降级方案顶上。别为一个低占比平台硬磕原生,投入产出不一定划算。
4. 保活策略:让App在后台活得久一点
4.1 保活的逻辑起点:系统允许的范围比想象中大
一谈到保活,很多人的第一反应是“隐藏图标”“双进程守护”“互相拉起”这些野路子。这里先说结论:在Android 8以后,后台服务限制已经大幅收紧,双进程守护在Android 10以上的系统上基本失效,而且应用市场审核时这种行为很可能被判定为恶意。所以我推荐的保活策略是:做系统法律允许的事,同时把用户引导做到位。
保活的核心逻辑其实很简单:如果App进程还活着,透传消息就能触发TTS播报;如果进程被系统杀了,那就要靠厂商通道把通知送到通知栏,用户点击后App会被拉起,再播报。所以保活的目标就是让App进程在合理情况下活的更久,减少被杀的概率。
系统允许的范围内,我能稳定起作用的保活手段有三个:关闭电池优化、允许自启动、允许后台运行。这三个设置虽然都需要用户主动配合,但是可以通过代码把用户直接引导到对应设置页,把操作成本降到最低。
4.2 引导用户把App放进“电池白名单”
Android系统从6.0开始引入了电池优化机制,App如果被系统判定为“高耗电应用”,在后台会被频繁终止。把App加入“忽略电池优化”白名单,是提升后台存活率最有效的一步。
在UniApp里,我们可以用plus.android打开系统的电池优化设置页,引导用户手动开启:
function openBatteryOptimizationSettings() { const Intent = plus.android.importClass('android.content.Intent') const main = plus.android.runtimeMainActivity() const intent = new Intent('android.settings.IGNORE_BATTERY_OPTIMIZATION_SETTINGS') main.startActivity(intent) }打开设置页还不够,用户需要手动把App从“不允许”列表里切到“允许”。这一步光靠代码做不到,所以需要在App内部做一个引导页面,最好在用户第一次收到播报消息之前就提示用户开启。我项目里的引导方式,是在设置页面放一个“开启后台保活”的按钮,点击后打开上面的系统设置页,同时用文字说明“请将本应用设置为允许后台运行”,这样用户操作路径最短。
需要补一个权限声明:如果希望直接通过代码请求加入白名单,而不仅仅是打开设置页,需要在应用Manifest里声明REQUEST_IGNORE_BATTERY_OPTIMIZATIONS权限。在HBuilderX云打包时,可以在manifest的Android权限配置里找这个权限,或者通过自定义Android权限入口添加;如果用的是离线打包,直接在AndroidManifest.xml里加一行:
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS"/>4.3 厂商后台设置速查表
国内头部手机厂商都有各自的后台管理策略,这些策略叠加在系统Android的规则之上,等于给App额外加了一层“管家”。不做厂商适配,App在后台被清理几乎是必然的。下面这张表是我整理出来的主要厂商设置入口,不同系统版本菜单名字可能有细微出入,但大方向一致。
| 厂商 | 主要设置入口 | 需要打开的开关 |
|---|---|---|
| 小米 | 设置-应用设置-授权管理-自启动 | 允许自启动、允许后台弹出界面 |
| 华为 | 设置-应用-应用启动管理 | 手动管理,打开允许自启动、允许关联启动、允许后台活动 |
| OPPO | 设置-电池-应用耗电管理 | 允许后台运行 |
| vivo | 设置-电池-后台高耗电 | 允许后台运行 |
| 荣耀 | 设置-应用-应用启动管理 | 手动管理,打开自启动、后台活动 |
| 三星 | 设置-电池-后台使用限制 | 选择“无限制” |
这块如果靠用户自己去翻设置,体验会很差,大部分人根本找不到入口。所以厂商适配必须做在引导页面里:先检测用户手机品牌,再根据品牌显示对应的操作提示,最好配上截图或简要说明。我在项目里直接用uni.getSystemInfoSync拿到platform和brand字段,然后按品牌展示不同的引导文案,实测用户打开率和设置成功率提升明显。
4.4 别再做双进程守护了
很多以前做保活的同学,最先想到的可能是双进程守护,这个方案在Android低版本时代确实有效,但在现在的系统环境下不仅无效,还容易惹麻烦。双进程守护的原理是两个进程互相监听,一个进程被杀,另一个立刻把它拉起来。但Android 8之后,后台启动服务受到严格限制,应用在后台根本无法随意拉起另一个进程;Android 10之后,应用的进程外通信也被大幅收紧。结果就是,双进程守护既拉不起来进程,还会被系统标记为恶意行为,轻则推送被限流,重则应用市场审核直接不通过。
另外一个常见误区是“前台服务保活”。前台服务确实能显著提升App在后台的存活优先级,这也是很多保活方案的核心,但使用前台服务往往需要原生代码配合,还必须在通知栏常驻一条通知。如果纯用UniApp JS层,靠的是系统允许的能力在有限范围内延长生命周期,而不是像某些插件那样去注册一个原生前台服务。
我的经验是:把保活的预期管理好。客户端能做的就是尽量延长App进程存活概率,配合厂商通道让消息在极端情况下也能触达用户。如果客户硬性要求“App被用户从最近任务划掉后,还能在后台自动播报”,那预算和方案都要重新谈。这不是技术做不到,而是在普通应用权限模型下,厂商系统不允许App在用户主动划掉后继续安静运行。想真正绕过这个限制,只有两种路:申请系统级别的特殊权限,或者做系统应用级别的定制,这对绝大多数项目来说都不现实。
5. 常见问题与排查实录
5.1 高频问题速查表
这套方案在落地过程中,我反复遇到几个问题,整理成一张表,很多情况直接对着表排查就能解决。
| 问题 | 常见原因 | 解决建议 |
|---|---|---|
| TTS初始化成功但没声音 | 手机处于静音/勿扰模式,或TTS音量被系统调到0 | 播报前检查媒体音量;建议用音频焦点配合提示音 |
| TTS初始化失败,status非0 | 部分定制ROM阉割了Google TTS引擎,或用户卸载了系统语音包 | 引导用户下载安装系统语音包;国产机常用的小米、华为语音引擎要先设置 |
| 收到的消息没有触发onPushMessage | 透传消息和通知消息没区分开,服务端发的是通知消息 | 去后台推送平台确认推送类型,透传消息才会走JS回调 |
| App在后台时语音播报延迟明显 | 系统为省电挂起了JS执行或限制了网络连接 | 配合厂商电池白名单;推送改为厂商通道;尽量让App保持前台服务 |
| 同一个消息被重复播报多次 | 服务端重试推送、客户端重复注册监听 | 用前文提到的去重逻辑,按文本加时间窗口过滤 |
| 页面跳转后TTS实例报错 | TTS在页面级初始化,销毁页面时实例失效 | 把TTS初始化和生命周期提升到App全局 |
5.2 两个被问烂但真的管用的排查步骤
第一个是“看日志”。很多同学说播报不响,第一反应是怀疑代码有问题,但一查推送平台后台,发现消息压根没发出去。遇到问题先从三个地方确认:推送平台后台的消息记录、UniApp控制台打印的uni.onPushMessage日志、TTS的onInit回调日志。日志能告诉你是消息没到、还是到了没播。
第二个是“测试时要先排除厂商干扰”。用Android Studio或adb直接往开发机上发一条透传消息,如果这条能播出来,但用厂商推送平台发就播不出来,那问题大概率在推送配置,而不是TTS代码。这个排查路径非常高效,能帮你把问题快速定位到推送链路还是播报链路,避免在一个环节里反复打转。
最后再分享一个细节,是我实际用下来觉得最提效的:调试TTS时,把它封装成一个全局调试入口,在App设置页面放一个输入框,输入任意文本点“试听”,不依赖推送系统就能快速验证TTS是否正常。这个功能在真机调试时帮了我大忙,很多推送联调问题一眼就能排除掉。如果是在小米、华为这类国产机上测试,建议从一开始就把厂商的电池管理设置好,否则你调一整天都可能以为代码有问题,结果只是系统把App进程杀了。