
最近群里好几个做叫号系统、外卖接单通知、物流提醒的朋友都在问同一个问题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. 动态语音播报核心实现无插件TTS3.1 先用plus.android把系统TTS引擎拉起来UniApp提供了plus.android这个原生桥接对象它可以通过importClass把Android原生类引到JS里直接使用。Android系统自带了一个文本转语音引擎就是我们常说的TTSTextToSpeech通过这个引擎我们可以让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) // 0LANG_AVAILABLE, 1LANG_COUNTRY_AVAILABLE, 2LANG_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 } // 第二个参数0QUEUE_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:nameandroid.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进程杀了。