ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

UniApp订单提醒语音播报:不用插件,自建WebSocket+TTS实现

UniApp订单提醒语音播报:不用插件,自建WebSocket+TTS实现 上个月接了一个订单提醒类的 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 APIuni.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 自带 TextToSpeechiOS 自带 AVSpeechSynthesizer都支持中文发音不需要额外下载语音包。UniApp 里可以通过 Native.jsAndroid和 plus.iosiOS直接调用。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 永远是 falsesetLanguage 需要传 Locale.CHINA如果传入的是 Locale.getDefault()在部分国产手机上可能因为语言设置问题导致英音播报中文speak 方法的最后一个参数是一个字符串类型的 utteranceId建议每次播报传一个唯一 ID方便后续监听播报完成事件Native.js 的写法受包名、类名影响不同 UniApp 版本可能略有差异。实际项目中如果有条件把 TTS 封装成 UTS 插件会更稳定因为 UTS 插件直接编译到原生层不受 JS 桥接限制。这个后面会再提到。3.3 iOS 端plus.ios 调用 AVSpeechSynthesizeriOS 端的 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~10.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 声音会被压低甚至直接不出声。音频焦点的申请需要用到 AudioManagerNative.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 完全指望不上只能依赖系统级推送通道。我在项目里的做法是接入 UniPushDCloud 官方提供的推送服务但把它定位为“兜底唤醒”不是主要的消息通道。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 13API 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。
返回列表