简介:一份面向微信小程序开发者的 TCP/IP 长连接源码示例,适合需要在小程序端与服务器保持实时通信、实现消息推送、在线状态维护或物联网设备交互等场景的开发者学习参考。资源同时提供 Go 编写的服务端与小程序客户端实现,前后端配合给出 TCP 长连接的建立、数据收发、心跳维持与断开重连示例,能够帮助理解原生 TCP 在微信小程序中的接入方式。压缩包共 35 个文件,约 39KB,其中 18 个 Go 文件承载服务端网络逻辑,7 个 JS、3 个 WXML、3 个 WXSS 及 2 个 JSON 组成小程序页面与交互层,另有 HTML 辅助页面、README 说明与 License 文件,结构明确,便于按模块对照阅读。已有 2017 人学习,适合希望快速在小程序项目中落地 TCP 长连接、或深入理解移动端网络协议编程细节的开发者,作为基础工程模板直接使用或改造。
1. 微信小程序 TCP/IP 长连接:为什么官方 WebSocket 满足不了私有协议
很多团队第一次接到「微信小程序 TCP/IP 长连接」这个需求,是设备厂商甩过来一个 IP 端口、一套私有二进制协议,然后留下一句「直接连就行」。小程序官方只给了 wx.connectSocket,那是 WebSocket 不是 TCP;服务端不改造协议,就只能在中间塞一层桥接,延迟翻倍、故障点也翻倍。本文讲另一条路:通过小程序插件机制直接建立 TCP/IP 长连接,从源码级别拆解连接建立、分包缓冲、心跳重连三段代码,以及真机上最容易踩的五个坑。适合做 IoT 设备控制、行情推送、实时对战小游戏,或者要连 WiFi 打印机、硬件网关、地图坐标上报的小程序开发者。
2. 建 TCP 长连接的三条路线:插件直连、原生组件与中转网关怎么选
2.1 小程序网络能力的边界:WebSocket 和 TCP 差在哪
wx.connectSocket 底层走的也是 TCP,但协议栈里多了一层 HTTP Upgrade 握手和消息帧,所以它只能连 ws:// 或 wss:// 地址。而私有 TCP 协议通常是服务端监听一个裸端口,比如 9502,客户端连上后直接收发二进制字节流,没有 HTTP 升级那套东西。两边协议对不上,这就是「官方 API 不够用」的根源。
小程序逻辑层跑在 JavaScript 引擎里,没有暴露 BSD socket 接口,整个小程序生态里没有 wx.createTCPSocket 这类官方 API。所以想直连 TCP,必须绕到原生层去。这里有一个老生常谈的误区:很多人以为 TCP 和 WebSocket 都是「连上发数据」,差别只是前缀从 ws 改成 tcp。实际上 TCP 是字节流语义,和 C 语言 socket 编程里的 recv/send 完全一致——没有消息边界,一次 send 可能拆成多次到达,多次 send 也可能拼成一次到达;而 WebSocket 是消息帧语义,天然有边界。这个差异直接决定了后面源码里必须有一块分包缓冲。
如果你的服务端能改成 WebSocket,那直接用官方 wx.connectSocket 最省事,域名校验、自动重连生态都现成。TCP 长连接存在的意义,恰恰是服务端改不了或者改造成本太高,比如老旧的设备网关、证券行情源、工业采集网关。这些场景下,与其在后端硬包一层 WebSocket 桥接,不如让小程序端直接走 TCP。桥接方案不是不行,但它把连接可靠性从「两端可控」变成「三段不可控」,出了问题要查链路每一跳。
2.2 原生插件是唯一直连 TCP 的合规路径
微信小程序的插件机制允许插件包携带原生模块,iOS 上是 framework,Android 上是 aar/so。原生代码跑在独立进程或独立线程里,可以调用系统 socket 接口。这就是小程序直连 TCP 的合规通道——不需要破解、不需要走私有协议,地图、音视频、打印类插件一直用的都是同一套机制。插件把原生 socket 能力封装成 JS 接口暴露给小程序端,常见能力包括 createConnection、send、onData、onClose、close,收到的数据以 ArrayBuffer 形式回传。
插件市场里搜「TCP」「Socket」「长连接」能找到不少现成方案。选型时重点看三样东西:最近更新记录是不是在持续维护,文档里有没有真机验证的说明,以及插件是否提供了数据分包或自定义心跳的 API。很多插件只封装了最基础的 connect/send,剩下粘包、半包、重连全丢给你自己写,这种反而更适合本文后面的源码思路。另外要注意主体限制,个人主体小程序能申请的插件比企业主体少,申请前先看插件详情页的适用范围。
插件包体积独立于主包,所以不会直接压爆 2MB 限制,但真机首次进入时要额外下载。用 uniapp 打包时经常遇到 source size 超过 2MB 的上传失败,插件按需申请、按需初始化,别在启动时把一堆用不到的能力全部加载。我一般会把插件引用收敛到一个独立的连接管理模块里,业务页面永远不直接触碰插件 API,这样将来换插件实现,业务层零改动。
2.3 三条路线对比:插件、原生组件、中转网关
| 路线 | 数据链路 | 适用场景 | 主要短板 |
|---|---|---|---|
| 原生 TCP 插件直连 | 小程序逻辑层 → 插件原生 socket → 目标 TCP 服务 | 私有协议、设备直连、低延迟控制 | 依赖第三方插件,协议解析要自己维护 |
| 原生组件封装 | 页面内常驻原生组件(同层渲染),组件持有 socket,事件抛数据 | socket 生命周期跟随页面、需要原生 UI 同步 | 页面销毁要手动收尾,事件转发有开销 |
| 中转网关 | 小程序 → WebSocket → 自建网关 → 目标 TCP 服务 | 多端复用一个协议、不想暴露端口 | 多一跳延迟,网关要自建自维,排障两头查 |
原生组件方案的核心是把 socket 封装进一个原生自定义组件,借助同层渲染让组件常驻在页面里,数据通过组件事件一层层抛出来。它的好处是连接生命周期可以跟着页面走,适合页面即连接、关页即断开的场景;缺点是页面销毁时如果不手动 close,连接会成为野资源,而且每次数据都要过一遍组件事件转发,高频小包场景不划算。
中转网关适合后端团队力量强、想把私有协议解析收拢到服务端的项目。小程序端只负责 WebSocket 通信,网关去和目标 TCP 服务维持长连接。代价是每一条客户端连接都要占网关一条 TCP 出站连接,像 smart-socket 那样单机百万长连接的服务端确实存在,但那需要投入专门的人力去维护。我的建议是:设备厂商不给改协议、服务端就是裸 TCP 端口,优先选插件直连;项目是多端复用同一套协议且有小程序之外的其他端,再考虑中转网关。
3. 用插件跑通最小 TCP 长连接:配置、连接与分包代码
3.1 申请插件与 app.json 配置
先说操作路径:登录微信公众平台,进入「设置 → 第三方设置 → 插件管理」,添加插件后搜索 TCP 或 Socket 关键词,找到提供 TCP 连接能力的插件,点击申请。审核通过后,插件详情页会给出 AppID 和版本号,这两个值要原样填进 app.json。
{ "plugins": { "tcpSocketPlugin": { "version": "1.0.0", "provider": "wx1234567890abcdef" } } }provider 是插件的 AppID,version 必须精确匹配插件详情页展示的版本号,少一位或多一位都会在编译阶段报「插件不存在」。如果你用的是 uniapp,需要在 manifest.json 的 mp-weixin 节点里同样声明 plugins,再在代码里通过 requirePlugin 引用。配置完成后,开发者工具里要先点「构建 npm」或重新编译,插件才会被拉取下来。
这里有个容易卡壳的点:插件申请后,开发版和体验版默认可以用,但正式版需要插件作者在你的小程序后台把版本授权放开。如果真机上提示「插件未授权」,先回公众平台确认授权状态,别急着改代码。
提示:个人主体小程序能申请的插件种类有限,申请前先看插件详情页的「可用主体」说明,避免白等审核。
3.2 建立连接的最小代码与事件说明
拿到插件后,建立连接的最小代码可以浓缩成下面这一段。注意把连接管理收敛到一个独立文件里,别在每个页面里直接 new 连接,否则后面页面跳转时会有一堆状态同步问题。
// services/tcp-client.js const tcp = requirePlugin('tcpSocketPlugin') let session = null function connect(options) { const { host, port, timeout = 4000 } = options if (session) { session.close() // 避免重复建连,先清掉旧连接 } session = tcp.createConnection({ host, // 域名或 IP,正式环境建议用域名 port, timeout, // 建连超时,单位 ms,设太短在弱网下必挂 }) session.on('connect', () => { console.log(`[tcp] connected ${host}:${port}`) startHeartbeat() // 建连成功后立即启动心跳 }) session.on('data', (chunk) => { // chunk 是 ArrayBuffer,注意它是字节片段不是完整包 appendBuffer(chunk) }) session.on('error', (err) => { console.error('[tcp] error', err) }) session.on('close', () => { scheduleReconnect() }) return session } module.exports = { connect }参数里有三个值得说明的地方。timeout 我习惯设 4000ms,设太短在电梯、地下车库这类弱网场景基本连不上,设太长用户会感觉页面卡死。host 字段能用域名就别用 IP,后续服务端换 IP 不用发版。另外插件自带的 autoReconnect 选项,我建议直接关掉,自己实现重连逻辑——插件内置重连一般是固定间隔,没有退避,断线瞬间大量客户端同时重连会把服务端打崩。
3.3 数据处理:粘包、半包与分包缓冲
TCP 是字节流,没有消息边界。插件每次 data 事件返回的 chunk 大小,由底层接收缓冲和网络状况决定,跟业务包大小没有任何关系。假设服务端封包格式是「2 字节消息体长度 + 2 字节类型 + 消息体」,那么在客户端必须维护一个累加缓冲,把每次 chunk 追加进去,再从缓冲头部按长度字段切包。下面这段代码是这套源码的核心:
// services/packet-parser.js let buffer = new Uint8Array(0) const MAX_PACKET_SIZE = 1024 * 1024 // 单包上限 1MB,防止恶意长度字段打爆内存 function appendBuffer(chunk) { const incoming = new Uint8Array(chunk) const merged = new Uint8Array(buffer.length + incoming.length) merged.set(buffer, 0) merged.set(incoming, buffer.length) buffer = merged while (buffer.length >= 4) { const view = new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength) const bodyLen = view.getUint16(0, true) // 前 2 字节是消息体长度,小端序 if (bodyLen > MAX_PACKET_SIZE) { console.error('[tcp] packet too large, close connection') closeSession() // 异常包直接断开,避免内存持续膨胀 buffer = new Uint8Array(0) return } if (buffer.length < 4 + bodyLen) { break // 半包:数据还不够一个完整包,等下一个 chunk 到达 } const packet = buffer.slice(4, 4 + bodyLen) handlePacket(packet) buffer = buffer.slice(4 + bodyLen) // 切掉已处理的部分 } }逻辑拆开看是三步:合并、判长、切包。合并是把新 chunk 追加到旧缓冲尾部;判长是先读长度字段,超过 1MB 视为非法包直接断开;切包用 while 循环,因为一次到达的 chunk 里可能包含多个完整业务包,只切一个会留下残余数据。handlePacket 是业务侧的消息分发函数,你要解析什么协议都从那里进去。
有两个实现细节值得注意。第一,Uint8Array 的 slice 会拷贝内存,高频大包场景下这里是性能热点,优化思路是用偏移量游标代替反复 slice,只在切完整包时做一次拷贝。第二,数据事件里千万不要做业务处理,只做缓冲追加和切包,业务逻辑放在 handlePacket 里同步执行,避免阻塞下一批数据到达。这个文件就是「源码」里最容易写崩的部分,我见过太多人把每次 data 事件当成一个完整包直接解析,然后被粘包问题折磨到怀疑人生。
4. 心跳与重连的 5 个参数:把连接质量从「能通」调到「可靠」
4.1 心跳间隔的设定:服务端超时的三分之一原则
长连接建立起来只是开始,真正考验代码的是连接维持能力。TCP 本身有 KeepAlive,默认探测周期动辄几小时,对小程序这种移动端场景没有任何实际意义,必须业务层自己发心跳。心跳间隔的设定有一条经验法则:先问服务端「多久没收到数据会踢连接」,然后把客户端心跳间隔设成这个时间的三分之一到二分之一。
比如服务端 60 秒无数据判死,客户端心跳就设 20 秒左右。留出的余量是为了覆盖一次心跳发出后丢失、重传、服务端响应延迟的全过程。心跳也不是越快越好,每 5 秒一次会让手机额外耗电、耗流量,服务端还要为每一条连接处理大量无意义包。心跳包要带序列号和时间戳,服务端原样回声,客户端根据回声判断对端活着。
如果业务本身有高频数据流,心跳可以做成自适应:每次发完业务包就重置心跳计时器,只有超过阈值没有业务数据时才补发心跳包。这样既省资源,又不会在业务高峰误判对端死亡。
| 参数 | 推荐值 | 说明 |
|---|---|---|
| 心跳间隔 | 20000ms | 服务端 60s 超时的三分之一,留足余量 |
| 心跳超时 | 5000ms | 发出心跳后 5s 未收到回声记为一次丢失 |
| 判死阈值 | 3 次 | 连续 3 次心跳无回声,主动断开重连 |
| 业务数据重置 | 最近 16s 内有发送则跳过心跳 | 自适应心跳,减少无用包 |
4.2 断线重连:指数退避加抖动的代码实现
连接断开后不能无脑重连。固定 1 秒重连一次,会让服务端在断网恢复瞬间收到所有客户端的集中冲击。正确的做法是指数退避加抖动:间隔从 1 秒开始,每次翻倍,涨到 30 秒封顶,再叠加一个随机抖动值。
const BASE_DELAY = 1000 // 第一次重连延迟 1s const MAX_DELAY = 30000 // 最大延迟 30s,避免无限退避 const JITTER_RANGE = 1000 // 抖动范围 0~1s let retryDelay = BASE_DELAY let retryTimer = null function scheduleReconnect() { if (retryTimer) clearTimeout(retryTimer) const delay = retryDelay + Math.floor(Math.random() * JITTER_RANGE) retryTimer = setTimeout(() => { connect() // 执行真正的建连 }, delay) retryDelay = Math.min(retryDelay * 2, MAX_DELAY) } // 建连成功后复位退避间隔 function onConnected() { retryDelay = BASE_DELAY }抖动的作用是错峰。几十万台设备同时断线再同时恢复,如果大家都按同一个间隔重连,服务端会在每个整数秒被请求打满。加了随机抖动后,重连请求在时间轴上摊开,服务端压力曲线平滑很多。另外一个容易忽略的点:小程序切到后台时,要暂停重连计时器;回到前台时,先发一个探测包,如果 1 秒内没回声再走重连流程。否则后台重连不断,既耗电又费流量。
4.3 三个隐藏参数:TCP_NODELAY、读写缓冲与连接数
插件层有几个参数容易被忽略,但它们对连接质量的影响很大。第一个是 TCP_NODELAY,对应 TCP/IP 协议里的 Nagle 算法开关。如果你的业务是小包频发型,比如地图坐标上报、操作指令下发,Nagle 算法会把多个小包合并发送,增加几十毫秒的额外延迟,这时要打开 TCP_NODELAY 关闭合并。反过来,如果业务本来就是一秒一包左右的大包,开不开影响不大。
第二个是读写缓冲。插件一般会暴露 bufferSize 配置,读缓冲默认值可能只有 2KB,遇到服务端一次性下发几百 KB 的配置数据,会被拆成几十次回调,效率很低。我一般把初始读缓冲设成 4KB,按需动态扩容到 64KB,超过 64KB 再分配大块内存。第三个是连接数约束。小程序端单条 TCP 长连接的内存占用并不高,但系统对 socket 数量有上限,页面多了之后尤其明显。整个小程序只维护一条长连接,所有业务通过消息类型区分,不要每个页面各建各的。
这里多说一句服务端的并发常识:像 smart-socket 那种单机百万长连接的故事,靠的是 epoll 事件驱动和极低的内存占用,那是服务端的事。小程序端的瓶颈从来不在并发,而在系统进程被挂起、NAT 超时、弱网抖动。把一条连接维护好,比同时维护十条连接有价值得多。
5. 微信小程序长连接避坑:5 个把连接搞失效的现场与解法
长连接写起来容易,真机跑一阵子就全是血泪经验。下面五个场景是按真实故障频率排的,每一条我都踩过,按「现象 → 原因 → 解决」拆开讲。
5.1 切后台连接被系统回收:息屏和切出要分开处理
现象:小程序切到后台再回前台,连接显示还在,但发任何数据都没有响应,要等自动重连跑完才恢复。真机按 Home 键切后台尤其明显。
原因:iOS 对后台小程序的网络资源回收比 Android 激进,退后台后 socket 很快被系统释放。息屏和切出是两回事:息屏触发的是 onHide,切出触发的也是 onHide,但息屏后系统会更快回收资源,所以不能只用 onHide 作为唯一判断依据。
解决:在 onHide 里暂停心跳和重连定时器,切出后台这段时间不做任何网络动作;在 onShow 里恢复后,不要等下一次心跳周期,立即发一个带序号的探测包,1 秒内没有回声就主动 close 旧连接再重建。主动重建比等自动重连快得多,用户感知不到断线。WiFi 打印机、硬件网关类场景都是这个套路。
5.2 把 data 事件当成一包数据:半包粘包现场
现象:收到的数据长度忽长忽短,按协议解析时字段错位,解密失败率突然升高,服务端和客户端日志对不上。
原因:这是 TCP 字节流语义带来的经典翻车。有人习惯了 WebSocket 的 message 事件天然是一整条消息,拿到插件 data 事件里的 chunk 就直接解析,完全没做分包缓冲。那一层 buffer 不是可有可无,是整个协议的命门。
解决:统一走 3.3 节的分包缓冲代码。data 事件只做两件事:把 chunk 追加进 buffer,然后按长度字段切包。任何一张协议表都只跟 handlePacket 里的完整包打交道,绝不能跟 chunk 直接打交道。日志里同时打印收包长度和切出的包数,排查时一眼能看出粘包还是断包。
5.3 插件没 ready 就 connect:冷启动失败
现象:小程序冷启动后立刻进入业务页,页面 onLoad 里马上调 connect,结果报「插件未就绪」或返回 -1。第二次进入同一个页面又好了。
原因:插件首次使用要先完成下载和初始化,冷启动时这个流程还没走完,连接调用就被拒绝了。
解决:监听插件的 ready 事件或等一个 ready 状态,等状态就绪再调 createConnection;如果插件没暴露 ready 事件,就在 connect 失败后加一个 500ms 延迟重试。更稳妥的做法是在启动页就做一次预热连接——建立一个连接再立刻关闭,把插件的初始化成本放在用户无感知的阶段,业务页进去之后直接复用。
5.4 多个页面各建各的连接:状态不同步
现象:页面 A 连接正常,跳转到页面 B 后 A 的数据停止刷新,返回 A 时还要重新连接;偶尔还会出现两个页面同时持有连接,互相抢数据。
原因:每个页面在 onLoad 里各调了一次 connect,页面卸载时又各自 close。TCP 是单连接状态机,两条连接同时存在各收各的数据,业务层状态必然乱。
解决:连接生命周期收归一个单例 ConnectionManager,页面只负责订阅和退订消息,永远不直接调 connect。页面 onShow 订阅自己关心的消息类型,onHide 退订;连接只在 manager 内部建立和销毁。数据分发用发布订阅模式,别用全局变量裸写状态,否则页面一多照样绕晕。
5.5 WiFi 切 4G 后连接假死:NAT 超时与心跳判死
现象:用户从 WiFi 切到 4G,界面还显示在线,但任何推送都收不到,发出去的消息也没有回应。重连日志一片空白,没有 error 也没有 close。
原因:运营商 NAT 会把长期空闲的映射表项回收,WiFi 切 4G 后原连接的 IP 和端口已经失效,但两端都不知道,连接处于半开状态。这种问题修到最后很像玄学,其实就是 TCP 半开检测没做。
解决:依靠心跳回声判死机制。连续 3 次心跳无回声,不要等系统抛错,主动 close 再走重连流程。判死阈值可以调得更激进一点,在 4G 场景下 2 次无回声就重建,用户体感反而好——重建花费的时间比干等超时要短得多。
6. 上线前怎么验证长连接:弱网脚本、日志与后续优化点
6.1 事件日志加时间戳:断线的后悔药
长连接故障最难复现,因为问题往往发生在切换网络的瞬间,等你看日志时现场已经没了。所以从第一天起,就要给每个事件打上时间戳和连接 ID:connect、data、heartbeatSent、heartbeatAck、miss、close、reconnect 全部记录。本地内存里缓存最近 200 条事件,出问题时一键导出成 JSON,两端日志对时间轴,断线那一刻发生了什么一目了然。这套日志就是后悔药,没有它全凭猜。
6.2 弱网模拟:飞行模式、限速与抓包
上线前必须做一轮弱网验证。我的固定动作是:真机连上后开飞行模式 10 秒再关掉,观察重连次数和重连成功耗时;再用开发者工具的网络面板模拟慢速网络,确认心跳在弱网下不会误判;PC 端可以用 reqable 这类工具抓小程序流量,确认数据帧确实发出去了,而不是卡在插件层没出网。性能测试时被问到长连接占多少内存,就看分包缓冲水位和最大包长这两个指标,对应调整 bufferSize。
6.3 长连接之后的三个优化方向
连接跑通后,值得做的优化有三个方向。一是多路复用:一条 TCP 连接上按 streamId 区分业务流,适合页面多、消息频发的场景,减少连接数就是减少系统资源占用。二是数据压缩:大 payload 走 gzip 或自定义二进制压缩,IoT 场景能省一半以上流量。三是中转网关:当多个端都要用同一套私有协议时,把小程序的连接层收敛成 WebSocket 到网关,网关统一维持 TCP 长连接,协议解析只写一份。
我现在的习惯是每次发版前都做一遍「飞行模式 10 秒 + WiFi 切换 + 冷启动三连」弱网脚本,跑完日志里没有异常重连才敢提审。翻车翻多了之后回头看,长连接项目的核心从来不是把连接建起来,而是把连接断开后的每个细节都想清楚。希望帮到你。
本文还有配套的精品资源,点击获取