做微信小程序接入MQTT,最头疼的场景不是弱网,而是用户手机一锁屏,连接就断。群里经常看到有人问:小程序里用mqtt连了设备,亮屏时一切正常,一旦退到后台或者黑屏几分钟,再回来就收不到消息了,甚至设备上报的任务全部错过。这个问题我在一个设备监控小程序的开发过程中踩得很深,今天就把它拆开讲清楚:为什么锁屏会断、小程序端怎么做自动检测和重连、以及想把“熄屏后任务照常处理”这件事做扎实,光靠小程序端重连还不够,还需要什么样的兜底方案。
这个内容适合正在做物联网小程序、设备控制台、消息推送类小程序的开发者,以及被“后台保活”折磨过的前端同学。我会把代码、参数、踩坑记录都整理出来,照着抄基本能解决大部分场景。
1. 微信小程序MQTT为什么一熄屏就断线
1.1 小程序的后台生命周期:不是想保活就能保活
先理解一个基础事实:微信小程序不是原生App,没有自己的常驻进程,它运行在微信的宿主环境里。微信为了控制内存和功耗,对小程序后台运行有非常严格的策略。小程序退到后台后,经过一段时间,JS引擎会被冻结,包括setTimeout、setInterval、网络请求、WebSocket回调在内的一整套运行环境都会被挂起。
这里面最关键的一点是:WebSocket连接本身由微信网络模块维护,但你客户端的业务代码已经无法执行了。换句话说,你没办法继续发送MQTT心跳包,也没法响应服务端主动下发的消息。很多人以为是MQTT库断了,其实在小程序里,更准确的说法是“你的代码被暂停了,连接等于假活”。
MQTT连接再稳定,也需要客户端持续发送心跳来维持。小程序后台挂起后,心跳发不出去,服务端等不到PINGREQ,自然会在超时后把这条连接判定为死亡,主动断开。等你重新打开小程序,才会发现连接已经没了。
1.2 MQTT的心跳机制救不了黑屏场景
MQTT协议里有一个核心参数叫keepalive。客户端建立连接时,会在连接报文里带上这个时间间隔,单位是秒。比如设置keepalive: 30,意味着客户端必须在30秒内至少给服务端发一次报文,通常是PINGREQ心跳包。服务端在超过1.5 * keepalive时间没收到任何报文时,就认为网络连接已死,主动断开。
这个机制在正常App里没问题,原生App有自己的后台长期任务权限,能持续发心跳。但微信小程序没有这个权限,后台一挂,定时器就停了。你设30秒也好,60秒也好,全是纸上谈兵,因为代码根本没机会执行。
用生活里的例子理解:你和服务端约好每隔30秒对一次暗号,证明大家都还活着。结果手机锁屏后你睡着了,服务端等不到暗号,以为你跑了,于是挂断电话。等你想起来再打过去,联系人已经没了。
1.3 先接受一个现实:小程序没有真正的后台常驻
说完原理,必须泼一盆冷水:微信小程序在官方机制上,不可能做到原生App那种真后台长期保活。微信允许小程序在后台存活的时间非常有限,除非有特殊能力,比如正在播放音频、正在使用蓝牙、或者在后台定位,否则过一段时间就会被挂起。
你去看那些号称“小程序里MQTT永不断线”的方案,大概率是以下几种情况:
- 只是做了断线重连,不是真正保活,亮屏恢复后重新连上而已;
- 用了音频保活这类灰色手段,能多活一段时间,但存在被平台限制的风险;
- 把MQTT客户端挪到了服务器,小程序只做消息展示。
所以本文标题里的“强制保活”,准确理解应该是两层意思:第一,在微信允许的时间窗口内尽可能延长连接存活;第二,在连接必然断开的前提下,让小程序重新回到前台时能秒级自动恢复,并且在断开期间不丢失关键任务。这两个目标同时达成,才是实际可落地的方案。
2. 强制保活的整体设计:三层兜底让断线影响降到最低
2.1 三层保活体系从设计到选型
我最终在模拟项目X里的方案,是三层保活并行,而不是只在小程序端做重连。
第一层叫“客户端自愈”。小程序通过生命周期监听、网络状态监听、看门狗心跳检测,在连接断开后自动重连。这一层能解决大多数“亮屏时突然断开”“网络切换导致掉线”“关屏时间不长还能活”等场景。
第二层叫“云端代收”。MQTT Broker不在小程序里,而是在云端。真正需要“持续接收任务”的,不是用户的手机,而是云端的某个服务。设备上报消息到达云端Broker后,由云端程序负责解析、落库、触发后续动作。手机端掉线只影响“实时推送展示”,不影响业务本身。
第三层叫“订阅消息兜底”。当用户手机已经离线一段时间,无法通过MQTT通知到用户时,云端把关键任务通过微信订阅消息发给用户。用户点开订阅消息重新进入小程序,自动触发重连和页面数据刷新。
为什么不做纯客户端重连?因为客户端重连只能在用户打开小程序的时候执行。用户手机黑屏一小时,你重连十次也连不上,消息还是收不到。三层方案的意义在于:底层重连保证“随时能恢复”,云端代收保证“任务不丢”,订阅消息保证“人能被找到”。
2.2 方案对比与避坑决策
做技术选型时,需要先把几种常见方案列出来对比:
| 方案 | 能解决什么 | 致命问题 | 适用场景 |
|---|---|---|---|
| 小程序内MQTT直连 + 断线重连 | 亮屏、弱网、网络切换恢复 | 后台挂起后无能为力 | 用户会频繁刷屏的场景 |
| 原生Socket | 比MQTT更底层 | 同样受小程序后台限制 | 不推荐 |
| 后台音频保活 | 延长后台存活时间 | 平台审核有风险,耗电 | 仅作为辅助手段 |
| 云端Broker + 服务端逻辑 | 消息永不丢 | 需要额外服务器成本 | 推荐业务核心 |
| 订阅消息推送 | 用户离线也能收到任务 | 不能替代实时双向通信 | 必须做消息通知兜底 |
我踩过的坑是:一开始只做了第一层,上线后收到大量用户反馈“手机放口袋十分钟,再拿出来任务就丢了”。后来我加了一个“云端任务记录”,把设备上报的数据全部落库,用户重新打开小程序时拉取未读任务,这才把问题真正压下去。所以不要迷信重连,重连是体验兜底,不是业务兜底。
2.3 消息不丢失的关键设计
如果你做的是设备控制类小程序,MQTT的QoS策略得想清楚。QoS 0最多发一次,丢了就丢了;QoS 1至少一次,但可能有重复;QoS 2恰好一次,最稳妥但开销大。
在小程序和云端Broker之间,我建议控制类指令用QoS 1,状态上报用QoS 0就行,因为状态以最新为准。关键操作类消息,比如“设备已完成任务”“设备上报异常”,小程序端收到后要立即写入本地缓存,同时上报服务端,避免页面刷新后数据丢失。
另一个点是session。如果你用clean: false连接,Broker会帮你保留离线期间的消息,等客户端重连后按主题重新下发。但要注意,微信小程序经常被系统回收,会话状态不一定可靠,所以不能把宝全部押在MQTT离线消息上,云端数据库落库才是最稳的。
3. 小程序端自动检测与重连接的完整实现
3.1 封装MQTT客户端:兼容微信小程序的WebSocket适配
微信小程序不能直接用浏览器里的原生WebSocket,它提供的是wx.connectSocket。而mqtt.js库默认依赖WebSocket环境。解决办法是给mqtt.connect传一个createWebSocket函数,把我们自己的适配器塞进去。
先看适配器代码:
// mqttSocketAdapter.js function createMqttSocket(url, options = {}) { const task = wx.connectSocket({ url, header: options.headers || {}, method: 'GET', fail: (err) => { if (options.onerror) { options.onerror(err) } }, }) const socket = { close() { task.close({ code: 1000, reason: 'manual close' }) }, send(data) { try { task.send({ data: typeof data === 'string' ? data : JSON.stringify(data) }) } catch (e) { if (options.onerror) { options.onerror(e) } } }, onopen: options.onopen, onmessage: options.onmessage, onclose: options.onclose, onerror: options.onerror, } task.onOpen(() => { if (socket.onopen) socket.onopen() }) task.onMessage((res) => { if (socket.onmessage) socket.onmessage({ data: res.data }) }) task.onClose((res) => { if (socket.onclose) socket.onclose(res) }) task.onError((err) => { if (socket.onerror) socket.onerror(err) }) return socket } module.exports = { createMqttSocket }这段代码把wx.connectSocket的返回对象task包成了一个符合WebSocket接口的对象。注意,wx.connectSocket的onOpen、onMessage等回调是在task上注册的,不是直接作为构造参数传入。很多同学直接把浏览器写法搬过来,发现mqtt.js连不上,问题就出在这里。
然后连接部分的封装:
const mqtt = require('mqtt') const { createMqttSocket } = require('./mqttSocketAdapter') let client = null let reconnectAttempt = 0 let reconnectTimer = null let isConnecting = false let lastPongTime = Date.now() function connectWithToken(userToken) { const clientId = 'wx_' + (userToken || 'guest') + '_' + Date.now() client = mqtt.connect('wss://your-broker.example.com/mqtt', { clientId, username: userToken, password: signToken(clientId), keepalive: 30, clean: false, reconnectPeriod: 0, connectTimeout: 5000, createWebSocket: (url, opts) => createMqttSocket(url, opts), }) client.on('connect', () => { console.log('MQTT connected') reconnectAttempt = 0 isConnecting = false lastPongTime = Date.now() // 订阅主题 client.subscribe('device/+/task/notify', { qos: 1 }) }) client.on('message', (topic, payload) => { lastPongTime = Date.now() handleMessage(topic, payload) }) client.on('error', (err) => { console.error('MQTT error', err) }) client.on('close', () => { console.warn('MQTT closed, try reconnect') scheduleReconnect() }) }这里有几个关键参数说明:
reconnectPeriod: 0会让mqtt.js不做自动重连,改由我们自己的重连策略控制,避免后台重连风暴;keepalive: 30这个值和服务端要匹配,有些Broker默认阈值会踢掉超过值;clean: false表示保留会话,重连后能接收离线累积的消息,但这个可靠性依赖Broker配置,别完全指望它;- 连接地址必须用
wss://,微信小程序正式环境不允许ws://。
3.2 生命周期监听:onShow恢复、onHide暂停
小程序从后台回前台,是重连的最佳触发点。我们在App或Page里监听onShow,在检测到连接异常时强制重连。
App({ onLaunch() { this.mqttManager = require('./mqttManager') }, onShow() { const manager = this.mqttManager if (manager) { manager.handleAppShow() } }, onHide() { const manager = this.mqttManager if (manager) { manager.handleAppHide() } }, })对应的handleAppShow和handleAppHide逻辑:
function handleAppShow() { // 回到前台,立即检查连接状态 if (client && client.connected) { // 这里做一次“假活检测” if (Date.now() - lastPongTime > 30000) { console.warn('Connection looks dead, force reconnect') client.end(true) scheduleReconnect() return } return } // 未连接就重连 scheduleReconnect(true) } function handleAppHide() { // 退到后台,暂停重连计时器,回到前台时再处理 if (reconnectTimer) { clearTimeout(reconnectTimer) reconnectTimer = null } }退到后台时暂停重连,是有意为之。原因有两个:第一,后台的JS定时器随时可能被冻结,你设置的重连回调根本执行不了;第二,如果大量用户同时在后台疯狂重连,你的MQTT Broker可能会被打挂,这属于典型的雪崩问题。
3.3 指数退避重连算法与看门狗心跳
重连不能是固定间隔死循环,需要指数退避加随机抖动。我用的算法如下:
const BASE_DELAY = 1000 const MAX_DELAY = 60000 let reconnectAttempt = 0 function nextReconnectDelay() { // 指数递增,最大到60s const exp = Math.pow(2, Math.min(reconnectAttempt, 6)) const base = Math.min(BASE_DELAY * exp, MAX_DELAY) // 加随机抖动,避免多个客户端同时重连 const jitter = Math.floor(Math.random() * 1000) return base + jitter } function scheduleReconnect(force = false) { if (isConnecting || (client && client.connected)) { return } if (reconnectTimer) { clearTimeout(reconnectTimer) } const delay = force ? 0 : nextReconnectDelay() console.log(`Schedule reconnect in ${delay}ms, attempt=${reconnectAttempt}`) reconnectTimer = setTimeout(() => { reconnectAttempt++ isConnecting = true if (client) { client.end(true) } connectWithToken(getUserToken()) }, delay) }为什么用指数退避?因为断线往往不是单个用户问题,可能是网络波动、服务器抖动、甚至是机房故障。如果所有客户端都按1秒的频率重连,瞬间的高并发连接会让Broker陷入半开状态,反而更难恢复。指数退避加抖动的意思是:前几次快速重试恢复体验,后面逐步拉长,给服务端留出恢复时间。
看门狗心跳则是解决“连接假活”的问题。有些安卓机型在后台被系统回收时,WebSocket连接可能停留在半死不活的状态,onClose不触发,但你发消息也没响应。我加了一个定时任务,每15秒检查一次最近消息时间:
setInterval(() => { if (client && client.connected && Date.now() - lastPongTime > 30000) { console.warn('Watchdog: connection is stale, force close') client.end(true) scheduleReconnect() } }, 15000)正常情况下,设备端或服务端会定时推一些状态消息或心跳响应,只要收到任意消息,lastPongTime就会被更新。30秒无消息就主动断掉重建,这是一种异常兜底,代价比一直等onClose要小。
3.4 网络切换自动重连与异常兜底
移动端最常见的情况是WiFi切4G、4G切WiFi。一旦切换,原有TCP连接大概率失效,必须主动重连。用wx.onNetworkStatusChange监听:
wx.onNetworkStatusChange((res) => { console.log('Network changed', res) // 只要从无网变成有网,或者网络类型变化,都触发一次检测 if (res.isConnected) { handleAppShow() } else { // 断网时清掉重连计时器,等有网再重连 handleAppHide() } })注意:网络变化回调在后台也可能被挂起,所以不能只依赖它。正确的姿势是“回前台时统一检测”:网络监听负责亮屏时的快速恢复,onShow负责兜底。
还有一个小细节:wifi切4G后,原来基于WiFi的网络栈可能已经关闭,但mqtt.js的socket对象还虚挂着。此时如果直接调用client.end(),某些情况下会触发异常回调。我的办法是end(true)强制销毁,再干净重建。
4. 黑屏后任务照常处理:云端代收与订阅消息下发的实现
4.1 把“在线状态”从客户端挪到云端
前面反复强调,小程序客户端做不到真后台保活。所以业务架构上必须反过来说:让最核心的接收逻辑不依赖手机在线。
模拟项目X里,我是这么设计的:
- 智能设备通过MQTT连接云端Broker;
- 云端有一个任务处理服务,订阅了设备的全部上报主题;
- 设备上报“故障”“任务完成”“电量低”等事件时,云端服务马上落库并更新状态;
- 如果小程序端当前连接正常,云端直接通过MQTT向小程序下发一条实时通知;
- 如果小程序离线(云端维护了每个用户的最后上线时间戳),云端就把这条消息标记为“未读”,并通过订阅消息下发到用户微信。
这里有一个很反直觉的地方:很多人以为MQTT设备和小程序是“点对点直连”,其实根本不是。设备和小程序都只是云端Broker的客户端,它们之间通过主题交换数据。设备上报到Broker,Broker转发给订阅了该主题的所有客户端。小程序离线时,Broker如果没有为它保存离线消息,这条消息它就收不到。所以我们需要一个云端服务代替小程序守着这个主题。
4.2 云函数订阅MQTT并触发通知
有些同学会问:能不能用云函数直接连MQTT,一直保持长连接订阅?可以,但不建议。云函数的运行实例是有时限的,默认执行时间很短,撑不住长连接。更稳的做法是:让MQTT Broker把消息通过HTTP回调或消息队列推给后端服务,由后端服务处理落库和通知。
以微信云开发环境为例,链路可以这样搭:
- 云端Broker收到设备上报;
- Broker配置webhook回调,把消息POST到云函数入口;
- 云函数解析消息,写入云数据库某张任务表;
- 云开发数据库的“变更触发”再触发另一个云函数;
- 变更云函数查询该用户是否在线,若离线则调用
subscribeMessage.send发送订阅消息。
关键代码示例:
// 云函数 webhookHandler exports.main = async (event) => { const { topic, payload } = event const data = JSON.parse(payload) // 落库 await db.collection('deviceTasks').add({ data: { deviceId: data.deviceId, taskType: data.taskType, content: data.content, status: 'pending', createTime: Date.now(), }, }) return { ok: true } }// 变更触发器 notifyUser exports.main = async (event) => { const task = event.data const openid = task.openid await cloud.openapi.subscribeMessage.send({ touser: openid, templateId: '你的模板ID', page: 'pages/task/detail?id=' + task._id, data: { thing1: { value: task.content.slice(0, 20) }, time2: { value: formatTime(task.createTime) }, }, }) return { ok: true } }注意,订阅消息的templateId需要在小程序管理后台申请,用户需要主动授权过一次。一次性订阅模板只能下发一次,每次下发前都需要用户重新点按钮授权;长期订阅模板目前开放类目有限。所以实际产品设计上,要提示用户“开启任务通知”,否则离线期间的消息根本触达不到用户。
4.3 前端如何配合订阅消息做任务闭环
小程序前端的配合逻辑是:
- 页面加载时,检查用户是否订阅过任务通知,没有就弹窗引导点击“允许”;
- 每次进入首页时,拉取云端“未读任务”列表展示;
- 收到MQTT实时消息时,除了页面刷新,还要上报服务端“已读”,让服务端不要再补发订阅消息;
- 收到订阅消息通知后,点击消息进入页面,走一遍
handleAppShow重连逻辑,再拉取最新数据。
这套闭环的好处是:就算用户手机黑屏一晚上,第二天点亮屏幕,微信消息列表里依然能看到夜里设备发生的关键事件。这比单纯在MQTT连接层面死磕更有价值。MQTT保活是“渠道”,订阅消息是“终极送达”。
5. 真实踩坑记录与排查清单
5.1 重连风暴:一个锁屏就把服务器打崩两层
我第一次上线重连逻辑时,用了固定3秒重连。结果周末晚上服务器报警,Broker连接数从几千瞬间涨到几万。原因很简单:大量用户同时解锁手机,所有小程序同时从后台唤醒,同时检测到断线,同时发起重连。那感觉就像地铁早高峰所有人一起挤同一个闸机。
后来我做了三个限制:
- 指数退避,且加了随机抖动,让重连请求均匀分布;
- 退到后台时清掉重连计时器,回到前台才允许立刻重连;
- 服务端按
clientId维度做了连接数限制,同一个clientId旧连接未断开时,拒绝新连接或强制踢掉旧连接。
排查重连风暴时,要看Broker的日志。如果某个时间点大量CONNECT报文集中出现,大概率是重连策略没有加抖动。记得把reconnectPeriod设为0,所有重连节奏自己控制。
5.2 定时器在后台被冻结后,怎么做“伪心跳”
有同事问:我在小程序里用setInterval每5秒发一次业务心跳,为什么锁屏后服务端还是说设备离线?因为后台JS定时器被冻结了。定时器不是延时执行,而是在微信挂起小程序的瞬间直接暂停,等回前台再继续。
“伪心跳”的思路是:不在后台发心跳,而是记录时间戳。每次收到任意MQTT消息时更新lastPongTime。回到前台时检查Date.now() - lastPongTime,如果超过阈值,就说明在后台期间连接已经断了,直接强制重连。这套逻辑我之前在3.3节已经贴了代码,核心思想就是用“最后消息时间”替代“持续心跳”。
还有一种情况是前端和服务端时间不同步。做检测时要么统一用服务端时间戳,要么允许一定的时钟偏差,不然会频繁误判假死。
5.3 某些机型切后台后WebSocket被系统静默回收
实测中发现,某些国产安卓ROM对小程序后台限制非常激进。微信切后台几秒钟内,WebSocket就被系统回收,更离谱的是onClose回调都不触发,你根本不知道连接已经没了。
这种情况下,只能靠“回前台主动自检”。我是这么处理的:
function handleAppShow() { const isStale = !client || !client.connected || Date.now() - lastPongTime > 30000 if (isStale) { if (client) { try { client.end(true) } catch (e) { // ignore } } scheduleReconnect(true) } }关键点是:不要在onShow里先判断client.connected为true就认为万事大吉,一定要配合lastPongTime。因为系统静默回收后,client.connected可能还停留在true状态,发消息却没有任何响应。
5.4 调试与实测方法:真机锁屏、日志标记、断网模拟
小程序MQTT调试和普通网页调试不一样,开发者工具里的网络面板看不到真机锁屏后的真实表现。我自己的实测流程供参考:
- 用真机调试,打开调试vConsole,把MQTT生命周期事件都打印出来;
- 连接成功后,锁屏,等1分钟再解锁,观察vConsole日志中
close、error、reconnect的时间点; - 用飞行模式模拟断网,等10秒再关闭飞行模式,验证自动重连是否触发;
- 在服务端打印每个客户端的连接、断开、重连事件,对照小程序端日志,确认断线原因到底在客户端还是服务端;
- 专门准备一个“弱网测试机”,比如老款安卓机,验证后台被回收的极端情况。
我建议把以下字段打进日志:clientId、连接时间、最后收到消息时间、断开时间、重连次数。这样排查问题是才能快速定位到底是心跳超时、网络切换还是系统回收导致。
另外,真机测试时一定不要只在开发者工具里测。开发者工具是基于电脑浏览器的环境模拟,和真机微信的调度策略差得不是一点半点。我见过最典型的例子:开发者工具里连接特别稳定,一上真机就掉线,害得我排查了两天才发现是工具环境的问题。
另一个小技巧是:在开发版小程序里加一个隐藏的“网络状态面板”,实时显示连接状态、最后消息时间、重连次数。测试人员锁屏再解锁后,打开面板截图反馈,处理问题效率会高很多。别嫌麻烦,这种可观测性的投入在后期的回报非常大。
就我个人实际测试后的体会来说,没有哪种方案能拍胸脯保证“黑屏不断线”,这是平台边界决定的。最稳的组合就是小程序端做快速自愈,云端做消息落库和状态同步,最后用订阅消息把关键任务送达到用户微信。三层各自负责一段,即使第一层失效,后面的层也能把业务损失降到最低。真正上线前,建议把各种断网、锁屏、杀进程、切网络的情况在真机上完整跑一遍,把重连参数调到符合你业务场景的值——这个调试过程,比看再多理论都有用。