
1. 锁屏断线之谜先搞清楚小程序的后台机制我最早被这个问题坑到是小程序里接入 MQTT 做设备控制的时候。现场测试时一切正常结果用户一锁屏过几分钟再打开控制页面直接变成离线状态。一开始我以为是服务器把连接踢了后来查了日志才发现手机黑屏的那一刻小程序的 JS 执行环境就已经被挂起了。这不是某个手机厂商的 bug也不是 MQTT 库的问题而是微信小程序从设计上就不允许在你退到后台之后继续跑 JS。微信自己有一套完整的前后台切换机制小程序在前台时你的代码正常运行一旦用户按了 Home 键、切到别的应用或者直接锁屏小程序会进入后台态。进入后台态之后页面会被浏览器容器冻结JS 定时器、网络请求、WebSocket 消息回调全部暂停。这么说可能有点绕简单类比一下小程序后台就像电脑的休眠CPU 停下来网卡虽然还插着但应用层的代码已经叫不醒了。1.1 你看到的断线和实际发生的断线很多人在开发时有个错觉以为 MQTT 是长连接只要不断网就能一直保活。实际发生的情况要复杂一些。MQTT 客户端一般会有一个 keepalive 机制客户端每隔一段时间给服务器发一个 PINGREQ 数据包服务器收到后回 PONG双方确认连接还活着。这个周期通常在 30 秒到 60 秒之间。微信小程序进入后台后JS 被挂起PINGREQ 自然发不出去。服务器等了两个 keepalive 周期都没有收到心跳就会判定连接失效主动断开 TCP 连接并且根据遗嘱消息把你的设备标记为离线。你以为是“手机黑屏导致网络断了”其实很多时候网络没断但应用层的心跳断了服务器单方面认为你掉线了。等用户重新打开小程序小程序重新激活MQTT 客户端才发现底层连接已经关闭于是触发重连。这个重连过程如果设计得不好用户就会看到设备状态同步慢、历史消息收不到甚至出现“连接成功但消息不推送”的假象。1.2 为什么心跳包救不了黑屏后的连接有人会问那我能不能把 keepalive 调短一点或者在前台持续发心跳保证后台哪怕冻结几秒也能维持连接先说结论不行。小程序后台冻结的不是某一个函数而是整个页面的 JS 执行。不管你把心跳间隔设成 1 秒还是 5 秒只要进入后台定时器就完全停摆。更极端的是有些手机上微信连 WebSocket 的底层连接也会被系统回收因为系统在壁纸、省电策略上会强制关闭长时间空闲的 socket。另外就算你的小程序因为某些特殊权限能短暂在后台执行几秒那也只是微信给的低优先级窗口一旦内存紧张立刻就会被杀掉。所以单纯靠客户端心跳无法解决锁屏断线问题必须调整整体思路。从那之后我基本形成了一个判断小程序里的 MQTT“保活”不能按原生 App 的思路做。原生 App 可以靠前台服务、长连接、推送通道把消息实时送到用户手上但小程序既没有常驻进程也没有后台联网权限。与其硬顶着系统限制去保活不如想办法让连接断开得足够优雅恢复得足够快。2. 别踩“强制保活”的坑小程序保活的真实边界在搜这个问题的时候你大概率会看到各种“强制保活”方案。有些来自技术博客有些来自卖课机构听起来很猛什么后台播放无声音频、屏幕常亮、原生插件保活。我基本都试过这里一个个给你拆开说。2.1 屏幕常亮不等于锁屏保活先说最容易被误导的一个wx.setKeepScreenOn。这个 API 的作用是让小程序在前台时屏幕不自动熄灭避免用户在长页面阅读或者扫码时黑屏。它只在“小程序处于前台”这个前提下生效用户一旦手动锁屏或者切到别的 App这个 API 就控制不了屏幕了。我见过有人写了个“保活方案”把setKeepScreenOn放在onShow里然后跟用户说锁屏也不会断。实际测试一下就知道锁屏后屏幕照样熄连接照样断。这个方案唯一的用处是当你正在使用小程序且需要长时间显示内容时防止系统自动锁屏仅此而已。2.2 后台音频玩法能用但别当饭吃小程序里确实有一个后台音频能力就是在退到后台后继续播放音频的能力。于是有人想到一个招用一段无声的音频循环播放让小程序保持后台活跃状态从而让 JS 继续执行。实测下来这个方案“曾经可行”但现在越来越难用。微信对后台音频的限制很严格用户锁屏后虽然音频会继续播但你的 JS 代码不一定能拿到完整的执行权。而且用户看到通知栏里一直有个音频在播第一反应是啧一声然后划掉。更麻烦的是如果你在后台长期占用音频通道用户自己的音乐 App 就放不了歌了这种体验对 C 端产品来说几乎是劝退级别的。所以我的观点是后台音频可以作为特定场景的兜底方案比如需要连续播报语音提示的工具类小程序但绝不要用它来给 MQTT 做保活。商业产品这么搞迟早被用户骂。2.3 官方能力里真正有用的几个点微信小程序官方其实给了一些后台相关能力比如订阅消息、后台定位、Background Fetch但每一项都有明确的使用场景不是给 MQTT 当通用保活通道的。wx.requestSubscribeMessage可以给用户推送服务通知适合下发设备报警、订单状态等低频消息。wx.getBackgroundFetchData可以在特定时机拉取服务器上的最新数据但触发频率由微信控制不能当实时通道用。wx.startLocationUpdateBackground后台持续定位定位类的场景才适合跟 MQTT 消息推送没关系。所以真实结论很扎心小程序不存在真正的“强制保活”让 MQTT 在黑屏后保持长连接这件事从平台能力上走不通。你唯一能做的是让断线这件事对用户无感并且回到前台后尽快恢复。这也是后面整个方案的核心思想。3. 更优雅的方案主动断线 秒级恢复既然不能保活那就换一个思路与其花大力气维持一条注定会断的连接不如接受“锁屏必断”这个事实把精力放在“断了之后怎么快速恢复”上。这个思路我称之为“优雅降级”。听起来有点抽象但落地其实很清晰在用户离开小程序时主动记录连接状态让服务器知道这个设备要去“休息”了在用户回到小程序时立刻重建连接并且把离线期间错过的消息补回来。这样既不会因为断线导致服务器误判也不会让用户看到长时间加载中的状态。3.1 服务器端配合遗嘱消息和会话保持MQTT 协议里有两个专门为这种场景设计的机制遗嘱消息和持久会话。遗嘱消息的作用是当客户端异常断开时服务器自动帮它发布一条预先设定好的消息。比如设备端上线时订阅一个“设备状态”的 topic断线后服务器立刻往这个 topic 发一条“离线”消息其他客户端就能第一时间感知到状态变化。在小程序的场景里如果你有一个后台管理端在实时看设备在线状态这个特性非常有用。持久会话则是告诉服务器这个客户端的会话别丢。客户端再次上线时服务器会把离线期间积压的 QoS 1 和 QoS 2 消息推送给它。比如用户锁屏期间某台设备上报了一条报警消息用户重新打开小程序后就能立刻收到这条消息而不是永远丢失。在 MQTT 客户端初始化时对应参数一般是{ clean: false, // 关闭 clean session启用持久会话 clientId: mini-program-device-001, keepalive: 30, reconnectPeriod: 1000 }这里的关键是clean: false。很多刚接触 MQTT 的人习惯默认用clean: true意思是每次连接都重新开始不保留历史会话。锁屏期间积压的消息就会直接被服务器丢弃。如果你的业务需要离线补发一定要把clean设成false。服务端也要注意对应配置。我实际用的比较多的是 EMQX它在 Dashboard 里可以配置最大会话过期时间默认值如果设得太短持久会话的消息保留不上设得太长服务器压力又会增大。对于普通小程序项目建议把会话过期时间调成 2 到 5 分钟既能覆盖用户常见的锁屏时间又不会让服务器堆积大量僵尸会话。3.2 页面端的状态机设计客户端这边不要只是傻傻地连上就完事而是要把连接状态抽象成几个状态让页面的 UI 和重连逻辑都跟着状态走。我自己常用的状态有这么几个已连接连接中已断开被动断线主动离线小程序进入后台时网络不可用小程序从后台回到前台时先检查当前状态。如果是“已断开”或者“主动离线”就直接执行重连如果是“已连接”也不代表万事大吉因为底层连接可能已经被服务器悄悄断开只是客户端还没收到关闭事件。所以正确的做法是回到前台后不管当前状态是什么都主动做一次连接校验通过则恢复订阅不通过则立即重连。这个逻辑在代码里的表达大概是App({ onShow() { if (this.globalData.mqttClient) { this.globalData.mqttClient.reconnect(); } }, onHide() { // 进入后台前主动标记离线 if (this.globalData.mqttClient) { this.globalData.mqttClient.end(); } } })3.3 重连时如何避免消息丢失和重复重连之后的问题往往比断线本身更麻烦。最常见的是消息重复。因为客户端断线后不知道服务器到底有没有把自己最后一条 PUBLISH 消息收进会话里重连成功后会重新订阅 topic此时可能又把旧消息收一遍。处理重复消息最可靠的方式是在业务消息里带上消息 ID 或者时间戳接收方维护一个已处理的 ID 队列。如果发现新到的消息 ID 已经处理过直接丢弃。这个机制在 MQTT 里本身也有个对应的“会话标志”但小程序端不容易直接拿到所以业务层去重是最实用的手段。另外重连成功之后之前订阅过的 topic 有可能丢失。如果你使用的是持久会话服务器会记住订阅关系但如果之前是用临时会话跑过的就需要在connect事件回调里重新执行订阅操作。为了避免重复订阅堆积每次重连后先取消旧订阅再重新订阅或者订阅前判断一下this.subscribed标记。client.on(connect, () { if (!this.subscribed) { client.subscribe(device//status, { qos: 1 }); this.subscribed true; } });顺便说一句subscribed标记要在断线时重置否则重新连接后订阅不会恢复。我第一次做的时候就踩了这个坑重新连接后一直收不到消息查了半天才发现这个标记没有重置。4. 可运行的 demo小程序 MQTT 自动重连实现理论讲了一大堆接下来给一个可以直接抄作业的实现。示例里用的是 mqtt.js通过 npm 安装后在小程序里用构建 npm 的方式引入。微信开发者工具支持 npm 包构建但要注意部分 Node 环境的库没法直接用需要选择能在小程序容器里运行的版本。4.1 引入 MQTT 库并配置连接参数先安装依赖npm install mqtt然后在工具里点击“构建 npm”生成miniprogram_npm目录。接下来在项目里引入import mqtt from mqtt;连接配置我一般单独放一个文件里方便调试// config/mqtt.js export const MQTT_CONFIG { host: wxs://your-mqtt-broker.example.com, port: 8084, clientId: mini_ Date.now() _ Math.random().toString(16).slice(2, 8), keepalive: 30, clean: false, reconnectPeriod: 1000, connectTimeout: 10000, username: mini_program_user, password: your_password_here };这里要注意一下host和port。小程序里不能直接用tcp://或者裸mqtt://连服务器因为小程序的网络请求只支持 WebSocket 协议。所以服务器端必须开启 WebSocket 监听端口客户端用wxs://开头来连接。如果你本地测试用的是明文 WebSocket就写ws://正式环境建议上 TLS 加密用wxs://。clientId一定要保证唯一。多个客户端用同一个 clientId 连接会导致前面那个被服务器挤下线。我在测试时经常遇到“连接 1 秒就断”的情况最后发现就是 clientId 写死了。4.2 订阅消息与处理离线事件连接成功后订阅设备和指令相关的 topicexport function initMqttManger() { const client mqtt.connect(MQTT_CONFIG); client.on(connect, () { client.subscribe(device//status, { qos: 1 }); client.subscribe(device//command, { qos: 1 }); }); client.on(message, (topic, payload) { const data JSON.parse(payload.toString()); wx.setStorageSync(last_${topic}, data); // 通知页面更新 UI wx.eventCenter.emit(mqtt_message, { topic, data }); }); client.on(close, () { // 这里不要立即重连交给 onShow 统一处理 }); client.on(error, (error) { console.error(MQTT error:, error); }); return client; }wx.eventCenter是我自己封的简单事件总线你可以在项目里用其他方式代替。核心就一个消息到达后要把数据从连接层同步到页面层否则页面还在只管自己渲染消息到了也白搭。4.3 onShow / onHide / 网络切换三管齐下这是整个 demo 的重点。我把它放在app.js的全局逻辑里App({ globalData: { mqttClient: null, isManualOffline: false }, onLaunch() { this.globalData.mqttClient initMqttManger(); // 监听网络状态变化 wx.onNetworkStatusChange((res) { if (res.isConnected) { // 网络恢复后触发重连 this.tryReconnect(); } }); }, onShow() { if (this.globalData.isManualOffline) { this.globalData.isManualOffline false; this.tryReconnect(); } else { this.checkMqttAndReconnect(); } }, onHide() { // 进入后台前主动断开并置为手动离线状态 this.globalData.isManualOffline true; if (this.globalData.mqttClient) { this.globalData.mqttClient.end(); } }, tryReconnect() { const client this.globalData.mqttClient; if (client !client.connected) { client.reconnect(); } }, checkMqttAndReconnect() { if (this.globalData.mqttClient this.globalData.mqttClient.connected) { // 已连接但可能需要重新订阅 return; } if (!this.globalData.isManualOffline) { this.globalData.mqttClient initMqttManger(); } } });这段代码的意图很简单用户离开小程序时主动end()连接让服务器立刻知道设备离线用户回来时不管是手动切后台还是网络切换导致的错误离线都走统一的恢复逻辑网络断开再恢复时也触发一次重连。4.4 重连后的订阅恢复与 UI 反馈重连成功之后页面上要给出明确的状态反馈不能让用户一直看到“加载中”。我在实际项目中会做一个简单的状态指示器wx.eventCenter.on(mqtt_connection_change, (status) { this.setData({ connectionStatus: status }); });同时在每个需要 MQTT 数据的页面onShow里检查一下全局连接状态如果是断线状态就显示“正在重连…”连接成功后再刷新数据。这样做的好处是用户从后台回到小程序时页面不会一直卡在旧数据上给人“设备一直在线”的错觉。另外订阅恢复的逻辑要放到connect事件里不能在外部手动触发。MQTT 每次重连都是一个全新的会话周期只有等connect事件真正触发才表示服务器已经完全接受这个连接。提前订阅会导致订阅失败。5. 实战中的坑从日志里扒出来的经验教训代码跑通只代表基本流程没问题真正花时间的往往在排查各种奇怪的现象上。下面整理了一份高频问题速查表都是我从实际日志里扒出来的。5.1 常见问题速查表问题现象可能原因解决方案锁屏后回来设备显示离线很久服务器判定心跳超时但页面没触发重连在onShow里强制检查连接状态不要等错误事件重连成功后收不到消息持久会话过期或订阅标记未重置检查clean是否设置重连后重新执行 subscribe网络上恢复但 MQTT 一直断客户端没监听网络切换或者旧连接没清理wx.onNetworkStatusChange里主动调用 reconnect两个客户端互相踢下线clientId 重复每次启动生成唯一 clientId使用随机串或时间戳消息重复收到没有做业务层去重在消息体里加消息 ID接收端过滤已处理 IDiOS 黑屏后彻底断连安卓却还活着系统后台策略不同不要依赖平台差异一律以 onShow 触发恢复为准这里特别想提一下 clientId 重复的问题。多人开发的时候如果 clientId 是从配置文件里复制的线上就会出现“抢连接”的现象表现为设备状态疯狂抖动A 刚上线 B 就被踢。排查方法也很简单在服务器端 Dashboard 看在线记录两个 clientId 一模一样基本就实锤了。5.2 调试小技巧用日志和抓包定位问题小程序端调试 MQTT最常见的困惑是“到底有没有连上”“消息到底有没有发出去”。我一般会在三个地方打日志客户端connect和close事件服务器端 WebSocket 连接日志消息订阅和发布的 console 输出。用开发者工具的 Network 面板可以观察到 WebSocket 的帧数据重点是看有没有规律性的 PINGREQ/PINGRESP。如果进入后台前心跳正常进入后台后 PINGREQ 全部消失那就说明问题出在小程序后台冻结而不是网络断开。另外在真机上测试时建议打开“真机调试”模式把日志输出到远程控制台。你会发现很多在开发工具里正常的行为到了真机上会有完全不同的表现尤其跟锁屏和省电策略相关的部分。开发工具的模拟环境不会冻结 JS所以锁屏断线问题必须真机测。我自己还习惯在服务器端加一个简单的“上下线记录”订阅把每次客户端上线、正常断开、异常断开的时间都打出来。这样一来当用户反馈“消息收不到”时我能直接从记录里看到是断线导致的还是消息根本没发到。6. 写在最后一点真心话做小程序 MQTT 这件事心态很重要。不要看到“强制保活”四个字就觉得有杀手锏小程序本质上是微信替你托管的应用运行环境后台能力就是被切掉的。你要做的不是跟平台较劲而是顺着平台的规则设计好“断线重连”的闭环。我最后总结一下这段实践中最重要的三点经验算是给后来人划个重点。第一锁屏断线不是 bug是平台机制。服务器的心跳超时判定、客户端的 JS 冻结、WebSocket 被系统回收这三点都是不可违背的。理解了这一点你就不会再花两天时间去找什么“保活捷径”。第二恢复速度比连接时长更重要。用户从后台回到小程序的那一瞬间就是你的黄金恢复窗口。在onShow里做一次快速检查该重连就重连该补消息就补消息用户根本感知不到刚才断过。第三消息可靠性要两层设计。底层靠 MQTT 的 QoS 和持久会话兜底上层靠业务去重和状态同步保底。只依赖任何一层线上都会出事。如果业务上确实需要绝对实时的后台推送那小程序就不是最佳载体了。更现实的路线是引导用户使用 App或者搭配订阅消息机制做低频通知。小程序适合做轻量入口不适合做全天候值守的实时通道。这就是我在几个项目里摸爬滚打总结出来的全部经验了。你照着这个思路做不敢说完全不出问题但至少再遇到“锁屏断线”的时候不会慌得满世界找偏方。