上个月接了一个订单提醒类的 App 需求,客户就一句话:来新单子必须响铃,还要把订单号和金额念出来。项目本身是 UniApp 做的,我第一反应是去 DCloud 插件市场找现成的推送和语音播报方案,逛了一圈发现事情没那么简单——语音播报插件本来就少,有的只支持固定音频文件,有的基于在线 TTS 服务需要注册 key,还有的更新停留在两三年前,最要命的是消息通道跟客户自己的服务端对不上。后来我干脆把方案拆开自己搞:消息通道用 WebSocket 直连,语音用系统自带 TTS 引擎,保活用前台服务加厂商白名单引导,一个第三方插件都没装,顺利上线。这篇文章就把这套思路和关键代码整理出来,适合用 UniApp 做订单提醒、物流驿站、取餐叫号这类场景的开发者参考,也适合想搞明白“App 退到后台为什么还能播报”的读者。
先说结论:不用插件不等于不用原生能力。UniApp 本身提供 plus API 和 Native.js,可以直接调用系统级别的能力,只是需要你自己封装一层。这个“自己封装”的过程看起来多写点代码,但换来的是完全可控的消息通道、没有插件更新维护的隐患、以及更灵活的播报逻辑。
1. 需求边界:先想清楚“推送”“播报”“保活”分别要解决什么问题
很多人拿到这种需求会直接搜“UniApp 语音播报插件”,装上之后才发现根本没法用。因为大部分插件解决的是“播一句固定的话”或“播放一段音频”,而你要解决的是“内容随时变化的动态播报”。这两者的技术路线完全不同。
1.1 动态语音播报和固定提示音是两条完全不同的路
如果只是“来订单响一声”,那用 plus.audio 放一个 MP3 就够了,十行代码搞定。但实际需求往往是:来订单后要把“订单号、金额、备注”这些不确定的内容实时拼成一句话,然后像真人一样念出来。音频文件没法预先把所有组合录好,唯一的办法是走文字转语音(TTS),让系统根据文本动态合成语音。
举个例子,服务端推送过来一条消息:
{ "type": "new_order", "orderNo": "A20250115001", "amount": "36.5", "shopName": "老王家常菜", "note": "少辣多香菜" }你要播报的是“您有一条新订单,老王家常菜,订单号 A20250115001,金额 36.5 元,备注少辣多香菜”。这句话是服务端返回后动态拼出来的,没有任何一段预录音频能覆盖这种组合方式。这就是动态语音播报的核心场景。
1.2 “不用插件”的边界在哪里
我在项目里的处理原则是:不安装 DCloud 插件市场里的现成推送/播报插件,但 UniApp 自带的 plus API、Native.js、以及 Android/iOS 系统原生能力全都会用到。严格来说 Native.js 也是“原生能力桥接”,不是插件。这样区分的原因有三点:
- 第三方插件质量参差不齐,很多长期不更新,UniApp 升级后容易踩兼容性坑
- 推送播报这类功能跟业务强相关,现成插件往往需要按它的数据格式来,接入成本反而更高
- 自己封装可以精确控制播报时机、播报队列、后台行为,排查问题也容易定位
保活这一块更要提前讲清楚:如果完全不碰原生代码,只靠纯 UniApp 的 JS 逻辑,保活效果非常有限。真正稳定的方案要么走离线打包在原生工程里加 Service,要么用 UTS 插件自己写原生逻辑。这算不算“不用插件”?我的理解是:不用第三方插件,自己写的封装逻辑不算“用了插件”。这样既守住了标题的原则,又不误导读者。
1.3 完整链路预览
整个项目最终跑通的数据链路是这样的:
服务端订单消息 → WebSocket 长连接 → App 前端收到 JSON → 解析并分发 → 拼接待播报文案 → 调用原生 TTS 引擎 → 扬声器播放语音
保活链路则分为两部分:应用存活时靠前台服务和用户白名单设置维持长连接不断;应用被系统杀死后,靠系统级推送通知兜底,用户点击通知后拉起 App 再补播核心信息。
2. 消息通道:用 WebSocket 自建实时链路,绕开推送插件
消息通道是整个方案的地基。语音播报再流畅,消息到不了 App 也是白搭。这里我选择了 WebSocket 自建长连接,而不是接入个推、极光之类的推送 SDK,也不是用 UniPush。
2.1 为什么是 WebSocket 而不是轮询,也不是推送插件
订单提醒对实时性要求很高,最好秒级到达。如果走 HTTP 轮询,要保证 3 秒内感知新订单,就得每 2-3 秒请求一次服务端,费电费流量不说,频繁请求还可能被服务端限流。WebSocket 是长连接,服务端有消息随时可以推下来,延迟通常在一秒以内。
那为什么不用现成的推送 SDK?一方面是要在第三方平台注册应用、配厂商通道,流程繁琐;另一方面是推送 SDK 通常只负责“通知”,要把通知内容转成 App 内部消息再触发 TTS,中间绕了一层。如果你有自己的服务端,WebSocket 是最直接、最可控的方案。而且 WebSocket 是双向的,后续要做接单回执、已读上报都很方便,推送 SDK 在这块能力偏弱。
2.2 UniApp 中 WebSocket 的连接管理与消息分发
UniApp 已经封装好了 WebSocket API(uni.connectSocket),不需要额外装插件。我的做法是把连接管理封装成一个单例模块,统一处理连接、监听、断开和重连:
class WSClient { constructor() { this.socketTask = null; this.connected = false; this.reconnectCount = 0; this.heartbeatTimer = null; this.messageHandler = null; } connect(url) { if (this.socketTask) { this.close(); } this.socketTask = uni.connectSocket({ url: url, success: () => { console.log('WebSocket 连接发起成功'); } }); this.socketTask.onOpen(() => { console.log('WebSocket 已连接'); this.connected = true; this.reconnectCount = 0; this.startHeartbeat(); }); this.socketTask.onMessage((res) => { // 统一在这里解析消息 const msg = JSON.parse(res.data); this.dispatchMessage(msg); }); this.socketTask.onClose(() => { console.log('WebSocket 已断开'); this.connected = false; this.stopHeartbeat(); this.reconnect(url); }); this.socketTask.onError(() => { console.log('WebSocket 连接错误'); this.connected = false; }); } dispatchMessage(msg) { // 消息分发,由外部注册处理函数 if (this.messageHandler) { this.messageHandler(msg); } } }这个模块对外暴露 connect、sendMessage、close、setMessageHandler 这几个方法,页面或全局只跟这个模块打交道。收到消息后的分发逻辑放在 setMessageHandler 里统一处理,方便后面接语音播报。
有一个细节容易被忽略:uni.connectSocket 在 App 端如果连续调用,旧的连接可能不会主动释放,新连接建立前最好先把旧连接关闭。上面代码里 connect 开头调用了 this.close() 就是干这件事。
2.3 心跳和重连:长连接“静默死亡”的应对
WebSocket 最坑的地方是:网络切换、运营商 NAT 超时、路由器空闲断开,这些情况 TCP 连接已经断了,但客户端感知不到。你看着连接还在,实际上服务端已经收不到你的消息,服务端推给你的消息你也收不到。这种“静默死亡”比主动断开更难排查。
解决办法是心跳机制。客户端每 30 秒发一个 ping 包,服务端收到后回 pong。如果客户端连续两次没收到服务端的 pong,就主动断开并触发重连:
startHeartbeat() { this.stopHeartbeat(); this.heartbeatTimer = setInterval(() => { if (!this.connected || !this.socketTask) { return; } this.socketTask.send({ data: JSON.stringify({ type: 'ping' }), fail: (err) => { console.log('心跳发送失败', err); this.connected = false; this.socketTask.close(); } }); }, 30000); } stopHeartbeat() { if (this.heartbeatTimer) { clearInterval(this.heartbeatTimer); this.heartbeatTimer = null; } } reconnect(url) { // 指数退避重连,避免服务端被打爆 const delay = Math.min(30000, this.reconnectCount * 5000); this.reconnectCount++; setTimeout(() => { this.connect(url); }, delay); }指数退避的意思是:第一次重连等 5 秒,第二次等 10 秒,第三次 15 秒,最多等 30 秒。这种策略能避免弱网环境下大量客户端同时重连把服务端压垮。
2.4 消息格式设计:给语音播报留好字段
WebSocket 推送的消息格式建议服务端直接下发结构化数据,播报文案由客户端拼接,不要让服务端下发“已经组装好的播报文本”,更不要下发“语音文件的 URL”。原因很简单:
- 服务端下发播报文本的话,客户端想控制文案格式就做不到了,比如有的用户想听“订单号”,有的用户不想听
- 语音文件 URL 依赖网络下载,播报延迟大,断网场景直接失效
- 结构化数据以后可以扩展功能,比如点按消息跳转到订单详情、统计播报状态等
消息格式长这样比较合理:
{ "type": "new_order", "timestamp": 1736900000000, "data": { "orderNo": "A20250115001", "amount": "36.5", "shopName": "老王家常菜", "note": "" } }客户端收到后根据 type 判断业务类型,从 data 里取字段拼播报文案。这样消息通道本身就具备了多业务扩展能力,以后加“退款提醒”“配送超时提醒”都不用改通道代码。
3. 动态语音播报:Native.js 调原生 TTS,把文字变成声音
消息拿到了,接下来是核心:怎么把它变成声音。这里要解决两个问题:一是调用系统 TTS 引擎合成语音,二是把动态文案拼好再播报。
3.1 备选方案对比:为什么最终走原生 TTS
我在插件市场和开源社区里对比过几类方案,这里列个表方便你做选择:
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 预录音频播放 | 实现最简单 | 无法播报动态内容 | 只做提醒音效 |
| HTML5 Speech API | 纯前端实现 | App 端支持差,后台受限 | 仅 H5 页面 |
| 在线 TTS 服务 | 音色自然、支持多种人声 | 依赖网络、有延迟、可能要付费 | 对音色要求高的场景 |
| 系统原生 TTS | 免费、离线可用、延迟低 | 音色一般、需平台适配 | 订单提醒、工具类播报 |
订单提醒这类场景对实时性要求高,而且可能发生在网络不稳定的环境,系统原生 TTS 是综合最优选择。Android 自带 TextToSpeech,iOS 自带 AVSpeechSynthesizer,都支持中文发音,不需要额外下载语音包。UniApp 里可以通过 Native.js(Android)和 plus.ios(iOS)直接调用。
3.2 Android 端:Native.js 封装 TextToSpeech 的关键代码
Android 端用 Native.js 调用 TextToSpeech,核心逻辑如下:
let ttsEngine = null; let ttsReady = false; function initAndroidTTS() { const main = plus.android.runtimeMainActivity(); const TextToSpeech = plus.android.importClass('android.speech.tts.TextToSpeech'); const Locale = plus.android.importClass('java.util.Locale'); // TextToSpeech 初始化需要监听器,用 plus.android.implements 实现接口 const OnInitListener = plus.android.implements('android.speech.tts.TextToSpeech$OnInitListener', { onInit: function(status) { if (status === 0) { // TextToSpeech.SUCCESS ttsEngine.setLanguage(Locale.CHINA); ttsReady = true; console.log('TTS 初始化成功'); } } }); ttsEngine = new TextToSpeech(main, OnInitListener); } function speakAndroid(text) { if (!ttsReady || !ttsEngine) { console.log('TTS 未初始化,无法播报'); return; } // TextToSpeech.QUEUE_ADD = 1,追加到播报队列 // TextToSpeech.QUEUE_FLUSH = 0,立即打断当前播报 ttsEngine.speak(text, 1, null, 'tts_' + Date.now()); } function stopAndroidTTS() { if (ttsEngine) { ttsEngine.stop(); } }这里有三个容易踩的坑:
- TextToSpeech 的构造函数带一个监听器参数,Native.js 里必须用 plus.android.implements 实现,否则初始化回调拿不到,ttsReady 永远是 false
- setLanguage 需要传 Locale.CHINA,如果传入的是 Locale.getDefault(),在部分国产手机上可能因为语言设置问题导致英音播报中文
- speak 方法的最后一个参数是一个字符串类型的 utteranceId,建议每次播报传一个唯一 ID,方便后续监听播报完成事件
Native.js 的写法受包名、类名影响,不同 UniApp 版本可能略有差异。实际项目中如果有条件,把 TTS 封装成 UTS 插件会更稳定,因为 UTS 插件直接编译到原生层,不受 JS 桥接限制。这个后面会再提到。
3.3 iOS 端:plus.ios 调用 AVSpeechSynthesizer
iOS 端的 TTS 实现比 Android 简单,没有初始化监听器那套回调逻辑。通过 plus.ios 直接创建 AVSpeechSynthesizer 就能用:
let iosSynthesizer = null; function ensureIOSSynthesizer() { if (!iosSynthesizer) { const AVSpeechSynthesizer = plus.ios.importClass('AVSpeechSynthesizer'); iosSynthesizer = new AVSpeechSynthesizer(); } } function speakIOS(text) { ensureIOSSynthesizer(); const AVSpeechUtterance = plus.ios.importClass('AVSpeechUtterance'); const AVSpeechSynthesisVoice = plus.ios.importClass('AVSpeechSynthesisVoice'); // 创建朗读单元并设置中文语音 const utterance = AVSpeechUtterance.speechUtteranceWithString(text); const voice = AVSpeechSynthesisVoice.voiceWithLanguage('zh-CN'); utterance.setVoice(voice); utterance.setRate(0.5); // 语速,0~1,0.5 比较适合播报 iosSynthesizer.speakUtterance(utterance); } function stopIOSTTS() { if (iosSynthesizer) { iosSynthesizer.stopSpeakingAtBoundary(0); } }iOS 端的 AVSpeechSynthesizer 一次只能播一段,没有队列概念。我在项目里是通过 AVSpeechSynthesizerDelegate 的 didFinish 回调来推动队列播报下一条,这个 delegate 用 plus.ios.implements 实现,逻辑跟 Android 的 OnInitListener 类似。
3.4 播报队列与动态文案拼接的工程细节
生产环境一定要处理“消息扎堆”的情况。比如午餐高峰期,一分钟内进来 5 个订单,每个都立即播报的话,语音会叠在一起,用户什么都听不清。
Android 端有一个天然的解决办法:speak 时传 QUEUE_ADD 参数,系统会把新播报排到当前播报后面,自动排队。这也解释了上面代码里为什么用 1 而不是 0。iOS 端就需要自己在 didFinish 回调里播下一条。
如果不想等原生回调,也可以用定时器估算时长:按中文播报语速大约每秒 4-5 个字,根据文案长度 setTimeout 后播下一条。这种方式虽然不够精确,但实现简单,多数场景够用。
文案拼接我建议单独抽一个函数,方便统一维护:
function buildSpeechText(msg) { const data = msg.data || {}; let text = `您有一条新订单`; if (data.shopName) { text += `,${data.shopName}`; } if (data.orderNo) { text += `,订单号 ${data.orderNo}`; } if (data.amount) { text += `,金额 ${data.amount} 元`; } if (data.note) { text += `,备注${data.note}`; } return text; }这里有个产品经验:播报文案控制在 50 字以内,重点信息前置。因为 TTS 播报大长句时断句容易出问题,而且用户不会耐心听完一整段。如果订单信息多,就播报核心信息,完整信息通过通知栏查看。
还有一个容易忽略的点:Android 播报前最好申请音频焦点,否则正在播放音乐时,TTS 声音会被压低甚至直接不出声。音频焦点的申请需要用到 AudioManager,Native.js 里可以这样绕一下:
function requestAudioFocus() { const main = plus.android.runtimeMainActivity(); const Context = plus.android.importClass('android.content.Context'); const AudioManager = plus.android.importClass('android.media.AudioManager'); const audioManager = main.getSystemService(Context.AUDIO_SERVICE); const AudioAttributes = plus.android.importClass('android.media.AudioAttributes'); const AudioFocusRequest = plus.android.importClass('android.media.AudioFocusRequest'); // Android 8.0+ 用 AudioFocusRequest if (plus.os.version >= 26) { const focusRequest = new AudioFocusRequest.Builder(1) // AUDIOFOCUS_GAIN .setAudioAttributes(new AudioAttributes.Builder() .setUsage(2) // USAGE_MEDIA .build()) .build(); audioManager.requestAudioFocus(focusRequest); } }这段代码在不同手机上兼容性略有差异,如果只是做内部工具,可以先不处理音频焦点,但要意识到有这个坑。我是在做第二版优化时才补上音频焦点逻辑的,补完之后“播放音乐时订单播报没声音”的投诉基本没了。
4. 保活策略:前台服务、白名单引导、系统推送兜底
语音能播了,但一个残酷的事实是:App 退到后台五分钟后,整个链路可能就断了。Android 的 Doze 模式、App Standby、iOS 的后台挂起机制,都会把长连接干掉。这也是整个方案里最需要“妥协”和“分层”的部分。
4.1 退到后台后系统到底做了什么,先认清现实
开发者经常骂“国产手机杀后台厉害”,但其实 Android 原生和 iOS 都有类似的限制机制,只是国产 ROM 更激进。系统限制后台的目的很简单:省电、省内存、提升前台应用体验。你要保活,本质上是在跟系统要资源,这需要合理的手段,而不是暴力对抗。
先看现实的衰减过程:
| 场景 | WebSocket 状态 | TTS 播报能力 |
|---|---|---|
| 应用在前台 | 正常连接 | 正常播报 |
| 应用退到后台,约 5 分钟内 | Android 通常保持,部分 ROM 会冻结 | 可播报,但锁屏时可能走静音 |
| 应用退到后台超过 10 分钟 | 可能被系统暂停网络 | 大概率失效 |
| 用户手动清理后台 | 连接断开 | 完全失效 |
| App 进程被系统杀死 | 连接断开 | 完全失效 |
没有保活措施的话,你的“实时语音播报”只在 App 打开时有效,这显然不满足订单类业务的诉求。所以保活要做,但要分层做。
4.2 Android 前台服务与常驻通知:守住后台运行的根
Android 保活的核心手段是前台服务(Foreground Service)。前台服务有一个常驻通知,用户能在通知栏看到“XX应用正在运行”,同时它的进程优先级比普通后台进程高,系统回收时会优先保它。
在 UniApp 里创建前台服务,有两条路:
- 离线打包:在原生工程里写一个 Service,继承 Service 并调用 startForeground(),然后在 UniApp 的 Android 工程里注册
- UTS 插件:用 UniApp 官方推荐的 UTS 插件在原生层写 Service,打包成插件后通过 JS 调用
UTS 插件是目前 UniApp 做原生能力扩展的主流方式,本质上是自己写的原生逻辑,不是第三方现成插件。它的写法跟 Kotlin 很像,比如创建一个前台服务的核心代码如下(UTS 语法):
class PushService extends Service { override onCreate(): void { super.onCreate() const CHANNEL_ID = 'push_channel' const manager = this.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager const channel = new NotificationChannel(CHANNEL_ID, '推送服务', NotificationManager.IMPORTANCE_LOW) manager.createNotificationChannel(channel) const notification = new Notification.Builder(this, CHANNEL_ID) .setContentTitle('订单提醒服务运行中') .setContentText('保持连接以接收实时订单') .setSmallIcon(appContext.getApplicationInfo().icon) .build() this.startForeground(1, notification) } }启动前台服务后,用户可以在通知栏看到常驻通知。这里要注意一个体验问题:常驻通知不能被用户滑掉,否则服务会被系统回收。实际使用时要在首次启动时明确告知用户“这个通知是为了保证能收到订单提醒”,否则用户会当作垃圾通知直接关掉,服务就跟着没了。
如果项目预算有限、不想碰原生代码,纯 UniApp 能做到的保活上限是:WebSocket 心跳 + 重连 + 引导用户加白名单。这套组合在 App 存活期间有效,锁屏 10 分钟内基本能保持,超过 10 分钟就难说了。所以我的建议是:核心订单场景至少上 UTS 插件级别的前台服务。
4.3 引导用户关闭电池优化与自启动限制
前台服务并不是万能的。国产 ROM 对后台应用的管控很强,就算你的服务是前台服务,用户没在电池优化白名单里,系统照样会在特定时间杀掉它。所以保活的第二板斧是引导用户做设置。
我封装了一个跳转系统设置的方法,在用户首次开启提醒功能时调用:
function openSystemSettings() { const main = plus.android.runtimeMainActivity(); const Intent = plus.android.importClass('android.content.Intent'); const Settings = plus.android.importClass('android.provider.Settings'); const Uri = plus.android.importClass('android.net.Uri'); // 跳转到应用详情页,用户可在此设置自启动、电池管理 const intent = new Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS); intent.setData(Uri.parse('package:' + main.getPackageName())); main.startActivity(intent); }针对不同厂商,引导文案和设置路径略有差异:
| 厂商 | 关键设置项 | 路径 |
|---|---|---|
| 小米 MIUI | 自启动、省电策略 | 设置 → 应用设置 → 应用管理 → 你的应用 → 省电策略 → 无限制 |
| 华为 EMUI | 启动管理、电池 | 设置 → 应用 → 应用启动管理 → 你的应用 → 允许自启动/关联启动/后台活动 |
| OPPO ColorOS | 允许后台运行 | 设置 → 电池 → 应用耗电管理 → 你的应用 → 允许完全后台行为 |
| vivo OriginOS | 后台高耗电 | 设置 → 电池 → 后台耗电管理 → 你的应用 → 允许后台高耗电 |
| 原生 Android | 电池优化 | 设置 → 应用 → 特殊应用权限 → 电池优化 → 你的应用 → 不优化 |
要注意的是,直接申请 ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS 权限弹窗在很多 ROM 上被限制,反而跳转应用详情页让用户手动操作最稳妥。引导界面要有耐心,建议做成三步拼接图,告诉用户“跟着点三下就能保证不错过订单”。
4.4 兜底方案:系统级推送只负责唤醒,不硬扛播报
不管保活做得多好,用户主动上滑清理后台或者手机重启后,你的 App 都不会自动运行。这个场景下,WebSocket 完全指望不上,只能依赖系统级推送通道。
我在项目里的做法是:接入 UniPush(DCloud 官方提供的推送服务),但把它定位为“兜底唤醒”,不是主要的消息通道。App 存活时走 WebSocket 实时播报;App 被杀后,UniPush 推送一条通知,用户点击通知后拉起 App,再通过一条查询接口把错过的订单补下来播报。
这里要管理好预期:App 被杀死后,系统推送只能弹通知,不能直接触发语音播报。原因很简单,App 进程不存在,TTS 引擎没有运行环境。就算能拉起,冷启动加 TTS 初始化也需要好几秒,体验反而更差。所以产品设计上,被杀死后的交互预期是“通知提醒 + 点击查看”,而不是“直接念出来”。
5. 实测踩坑记录:从“能播”到“稳定播”之间隔着这些细节
方案跑通之后,我花了两周时间在真机上反复折腾,这里挑几个最典型的坑分享,帮你少走弯路。
5.1 收到消息没声音:先查 TTS 初始化,再看音频焦点
上线第一天就遇到“来订单了但不播报”的反馈。排查路径是这样的:先看日志里 WebSocket 有没有收到消息,确认收到后再看 TTS 初始化状态。
最后定位到两个原因。第一个是 TTS 初始化时机问题:App 刚启动时 TTS 引擎还在初始化,WebSocket 消息已经到了,代码里 ttsReady 还是 false,播报被静默丢弃。解决办法是启动 App 时就初始化 TTS,并且把 ttsReady 为 false 期间的播报请求缓存到一个待播队列,初始化完成后自动补播。第二个原因是音乐播放中导致 TTS 没声音,这个在前面提过,用音频焦点解决。
5.2 Android 版本差异:权限和 targetSdk 的坑
Android 13(API 33)开始强制要求通知权限。如果你的 App targetSdk 是 33+,没有动态申请 POST_NOTIFICATIONS 权限,前台服务的通知会被系统隐藏,进而导致前台服务本身被系统限制。这个逻辑很隐蔽:你以为通知只是“看不到”,实际上服务也被连坐了。
解决方法是进入 App 时动态申请通知权限:
// 通过 Native.js 动态申请通知权限 function requestNotificationPermission() { const main = plus.android.runtimeMainActivity(); if (plus.os.version >= 33) { const PermissionManager = plus.android.importClass('android.permission.PermissionManager'); // 检查并申请 POST_NOTIFICATIONS } }由于 Android 权限申请逻辑在不同系统版本差异很大,这个功能在纯 JS 层写很容易踩版本兼容的坑,更建议在 UTS 插件层里封装,把权限申请做成一个统一方法。
5.3 国产 ROM 后台限制:小米/华为/OPPO/vivo 实测差异
同一套代码,在原生 Android 模拟器上一切正常,到了小米手机上锁屏半小时就断线。原因就是前文说的后台限制策略。我的处理方式是:App 首次引导时弹窗,不是偷偷申请,而是明确告知“为了保证订单播报不中断,需要关闭系统省电限制”,用户点击后跳转到对应的设置页面。
实测下来,小米和华为的引导转化率最高,只要用户点了一次“确定”,后面基本不会再被杀。OPPO 和 vivo 的设置路径藏得比较深,很多用户找不到,所以我把引导文案改成了类似“点击跳转后,在电池设置里选择不限制”这种带操作指引的文字,而不是只给一个按钮。
这套引导逻辑说难不难,但很琐碎,每个厂商的 Action 跳转 URL 可能不一样。我建议先支持主流四家(小米、华为、OPPO、vivo)跳转,其他品牌统一跳转应用详情页,让用户自己找设置项。
5.4 密集消息:同时来 10 条订单怎么播
订单类应用最容易触发的一个场景就是午餐高峰,一分钟内来 5-10 个订单。第一版上线时用的是 QUEUE_ADD 排队,结果用户反馈“手机一直叽里呱啦说不完”,体验很差。
后来我做了两个优化。第一个是聚合播报:如果两秒内收到多条同类型消息,不再逐条播报,而是拼成一句“您有 5 条新订单,总金额 168 元,请打开 App 查看”。第二个是播报节流:同一类型的播报至少间隔 3 秒,如果连续来,合并到同一条里播。这两个优化上线后,用户投诉直接归零。
聚合播报的实现不复杂,核心是加一个定时器:
let aggregationTimer = null; let aggregationList = []; function pushMessage(msg) { aggregationList.push(msg); if (!aggregationTimer) { aggregationTimer = setTimeout(() => { const list = aggregationList; aggregationList = []; aggregationTimer = null; // 根据列表生成聚合播报文案 const speechText = buildAggregatedSpeechText(list); speakText(speechText); }, 2000); } }buildAggregatedSpeechText 里判断消息数量,如果只有一条就照常播报详情,如果多条就播报聚合信息。这个逻辑小但实用,建议大家直接抄。
说回整体方案,我在这套架构里最大的感受是:不要试图用一套机制对抗系统的所有限制,而是接受限制、分层处理。App 活着的时候尽量把体验做到极致,App 被杀之后用系统推送兜底,让用户明白“点了通知就能看到详情”。这种预期管理做好的项目,用户反而觉得你很专业,不会天天骂你 App 有 Bug。