Android 网络深度系列 · 第 5 篇
系列导航:第1篇:HTTP 协议全解 | 第2篇:HTTPS 与网络安全 | 第3篇:OkHttp 架构剖析 | 第4篇:Retrofit 原理与实战 | 第5篇:WebSocket 与长连接 | 第6篇:网络实战场景
前言
IM 消息推送、实时股价行情、协作编辑文档、在线游戏对战......当你打开手机上的任意一款"实时"应用,背后几乎都有一条默默工作的长连接。
为什么不用 HTTP 拉数据就好?因为"实时"二字背后藏着巨大的技术债务--传统 HTTP 请求-响应模型天生是为"一问一答"设计的,面对服务端主动推送的场景,显得力不从心。
WebSocket 是目前移动端最主流的长连接方案。但很多 Android 开发者对它的理解停留在"听过、用过、断过、重连过"的阶段--写过 OkHttp 的 WebSocket 代码,却不一定清楚握手协议的具体流程;加了心跳,却不明白为什么心跳间隔设成 30 秒而非 10 秒;实现了断线重连,却用的是粗暴的固定间隔。
这篇文章不讲"怎么用",而是讲"为什么这么用"。从协议字节到生产级代码,把长连接这件事彻底讲透。
1. 为什么需要 WebSocket
在 WebSocket 出现之前,想要让客户端实时收到服务端消息,开发者们尝试了各种方案。每个方案都有其代价。
1.1 短轮询(Polling)
最朴素的思路:客户端每隔一段时间发一次 HTTP 请求,问服务端"有更新了吗?"
工作原理:
Client → GET /api/messages → Server
Server → { messages: [...] } → Client
← 等待 n 秒 →
Client → GET /api/messages → Server
Server → { messages: [...] } → Client
核心问题:
- 资源浪费:大量请求在"空转"。对于每 10 秒轮询一次的 IM 客户端,99% 的请求返回空数据。每个请求都要经过 DNS 解析、TCP 握手、TLS 握手、HTTP 头传输--这些开销远大于数据本身。
- 延迟不可控:理想情况下延迟 = 轮询间隔 / 2,但最坏情况下 = 轮询间隔。如果要做到 1 秒内延迟,就得每 1 秒发一次请求,这对服务端和客户端电量都是巨大负担。
- 移动端电量灾难:每次 HTTP 请求都需要唤醒蜂窝/WiFi 模块,而无线模块的电量消耗是"固定成本"--发 1KB 和发 100KB 的功耗几乎相同。频繁唤醒比一次传输大量数据耗电得多。
1.2 长轮询(Long Polling)
短轮询的改进版:客户端发起请求后,服务端不立即返回,而是"挂起"连接,直到有新数据时再响应。
Client → GET /api/messages (Connection kept alive)
... 挂起等待 ...
Server → { messages: [newMsg] } → (响应)
Client → (收到后立即发起下一次长轮询)
改进点: 大幅减少了空请求次数,延迟有所降低。
但仍存在问题:
- 每次请求仍有 HTTP 头的固定开销(通常 400-800 字节),而消息体可能只有几十字节
- 需要保持大量挂起的服务端连接,每个连接都要占用线程/资源
- 超时重发机制复杂(代理服务器、网关往往有 30-120 秒的超时限制)
- 服务端重启或负载均衡切换时,所有长轮询连接会同时断掉,造成"雷击"效应
1.3 SSE(Server-Sent Events)
SSE 是 HTML5 规范的一部分,允许服务端通过 HTTP 连接持续推送事件流给客户端。
Client → GET /api/stream (Accept: text/event-stream)
Server → data: 第一条消息\n\n
Server → data: 第二条消息\n\n
优点: 基于标准 HTTP 协议,兼容性好,对服务端要求低。
局限性:
- 单向:仅服务端→客户端,客户端要发送数据仍需走 HTTP 请求
- 浏览器连接数限制:大多数浏览器限制每个域名 6 个 SSE 连接
- Android 生态不友好:Android 原生没有 SSE 的官方实现,需要引入第三方库或自己解析
text/event-stream格式
1.4 WebSocket:终极方案
WebSocket 设计之初就瞄准了"全双工"这个目标:
Client ---- (一次握手升级) ----→ Server
←- 全双工双向通信 --→
(一个连接上双向自由发送)
- 握手完成后,数据传输开销极低(帧头仅 2-14 字节)
- 天然双向,无需额外 HTTP 请求
- 支持文本(UTF-8)和二进制(可自定义格式)数据传输
- 控制帧(Ping/Pong/Close)内建于协议层面
对比总结:
| 方案 | 方向 | 延迟 | 头开销 | 服务端资源 | 移动端友好度 |
|---|---|---|---|---|---|
| 短轮询 | 单向拉 | 高(间隔决定) | 大 | 低 | 差(频繁唤醒) |
| 长轮询 | 单向拉 | 中 | 大 | 高(挂起连接) | 一般 |
| SSE | 服务端推 | 低 | 小 | 中 | 一般(Android 无原生) |
| WebSocket | 全双工 | 极低 | 极小 | 中 | 好(OkHttp 原生支持) |
一句话结论: 如果客户端需要频繁收发双向数据(IM、推送、协作、游戏),WebSocket 是最优选择。如果仅仅是服务端推送通知且不频繁,SSE 或 FCM 可能更简单。
2. WebSocket 协议详解
很多开发者会用 WebSocket,但不一定看过它的"线缆上的样子"。理解协议细节,能帮你更好地解决握手失败、帧解析异常等实际问题。
2.1 协议升级握手
WebSocket 连接不是凭空产生的--它始于一次普通的 HTTP 请求,通过 Upgrade 机制协商切换到 WebSocket 协议。
客户端请求(从 HTTP 升级):
GET /chat HTTP/1.1
Host: server.example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
Origin: http://example.com
关键字段解释:
- Upgrade: websocket 和 Connection: Upgrade:告诉服务端"我想把这条 HTTP 连接升级为 WebSocket"
- Sec-WebSocket-Key:客户端生成的 16 字节随机值,Base64 编码。用于证明请求是"认真的",不是被缓存篡改的
- Sec-WebSocket-Version: 13:协议版本号,目前仅 13 被主流支持
服务端响应(101 Switching Protocols):
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
服务端收到 Sec-WebSocket-Key 后,拼接一个固定的 UUID 字符串 258EAFA5-E914-47DA-95CA-C5AB0DC85B11,计算 SHA-1 摘要后再 Base64 编码,得到 Sec-WebSocket-Accept。客户端收到后做同样的计算校验,确保握手正确。
// 服务端计算方式(验证用):
val magicKey = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
val accept = MessageDigest.getInstance("SHA-1")
.digest((key + magicKey).toByteArray())
.encodeBase64()
// 结果应与 Sec-WebSocket-Accept 一致
握手完成后的关键点:
握手完成后,这条连接不再走 HTTP 协议栈。服务端不再解析 HTTP 头,Agent 和负载均衡器需要显式支持 WebSocket 协议才能传输后续数据帧。
2.2 WebSocket 帧结构
握手完成后,双方开始交换 WebSocket 帧(Frame)。每一帧的结构如下:
0 1 2 3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-------+-+-------------+-------------------------------+
|F|R|R|R| opcode|M| Payload len | Extended payload length |
|I|S|S|S| (4) |A| (7) | (16/64) |
|N|V|V|V| |S| | (if payload len==126/127) |
| |1|2|3| |K| | |
+-+-+-+-+-------+-+-------------+ - - - - - - - - - - - - - - - +
| Extended payload length continued, if payload len == 127 |
+ - - - - - - - - - - - - - - - +-------------------------------+
| |Masking-key, if MASK set to 1 |
+-------------------------------+-------------------------------+
| Masking-key (continued) | Payload Data |
+-------------------------------+ - - - - - - - - - - - - - - - +
: Payload Data continued ... :
+ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +
| Payload Data (continued) |
+---------------------------------------------------------------+
逐字段解读:
| 字段 | 位置 | 长度 | 说明 |
|---|---|---|---|
| FIN | bit 0 | 1 bit | 是否为最后一帧。消息可分多个帧发送,FIN=1 表示结束 |
| RSV1-3 | bits 1-3 | 3 bits | 保留位,无扩展时必须为 0 |
| opcode | bits 4-7 | 4 bits | 帧类型:0x1=文本、0x2=二进制、0x8=关闭、0x9=Ping、0xA=Pong |
| MASK | bit 8 | 1 bit | 是否掩码。客户端→服务端必须为 1,服务端→客户端必须为 0 |
| Payload Len | bits 9-15 | 7 bits | 数据长度。0-125 直接代表长度;126 表示后跟 16 位长度;127 表示后跟 64 位长度 |
| Extended Length | - | 0/2/8 字节 | 当 Payload Len=126 时读 2 字节,=127 时读 8 字节 |
| Masking Key | - | 0/4 字节 | MASK=1 时有 4 字节密钥,用于解码数据 |
| Payload Data | - | 变长 | 实际数据内容 |
最简帧范例(客户端发 "Hello" 文本消息):
二进制(HEX):81 85 37 fa 21 3d 5c 78 59 58 69
解析:
81 → FIN=1, opcode=0x1(文本帧)
85 → MASK=1, Payload Len=5(5 字节数据)
37 fa 21 3d → Masking Key(4 字节)
5c 78 59 58 69 → 掩码后的数据
解码时,每字节与 Masking Key 的对应字节进行 XOR:
'5c' XOR '37' = 'H'(0x48)
'78' XOR 'fa' = 'e'(0x65)
'59' XOR '21' = 'l'(0x6C)
'58' XOR '3d' = 'l'(0x6C)
'69' XOR '37' = 'o'(0x6F) ← 注意 Key 循环使用
2.3 数据帧 vs 控制帧
WebSocket 帧分为两类:
数据帧(Data Frames):
| Opcode | 类型 | 说明 |
|---|---|---|
| 0x0 | Continuation | 分片消息的后续帧 |
| 0x1 | Text | UTF-8 文本数据 |
| 0x2 | Binary | 二进制数据 |
控制帧(Control Frames):
| Opcode | 类型 | 说明 |
|---|---|---|
| 0x8 | Connection Close | 关闭连接 |
| 0x9 | Ping | 心跳探测 |
| 0xA | Pong | 心跳响应 |
控制帧的特殊规则:
- 控制帧的 Payload Length 最大为 125 字节
- 控制帧不能被分片(FIN 必须为 1)
- 控制帧可以出现在分片消息之间(即一个长消息的分片过程中,服务端可以插播 Ping)
- 收到 Ping 后应立即回复 Pong,但同一帧中不能同时包含 Ping 和 Pong
2.4 为什么客户端发送要 Mask,服务端不用?
这是 WebSocket 协议中一个著名的问题。原因涉及一种特定的安全攻击--缓存投毒(Cache Poisoning) 。
攻击场景设想:
早期 HTTP 代理(尤其是透明代理)可能错误地将 WebSocket 数据当成 HTTP 响应缓存。设想一个恶意页面:
- 用户访问
http://evil.com - 该页面 JavaScript 连接
ws://victim-proxy/, - 恶意页面通过 WebSocket 发送精心构造的二进制帧,其内容恰好包含
HTTP/1.1 200 OK和恶意脚本 - 如果代理服务器识别不到 Upgrade 握手,可能认为这是一个 HTTP 响应,将其缓存
- 后续用户访问同一域名时,代理返回了缓存的"HTTP 响应"(实际上是 WebSocket 数据),导致恶意代码执行
Masking 的解决方案:
要求客户端发送的所有帧必须 Mask。即使攻击者构造了符合 HTTP 响应格式的字节,经过 XOR 掩码后,中间代理看到的是一堆随机字节,无法解析为有效的 HTTP 响应。
服务端→客户端的帧不需要 Mask,因为服务端返回的数据不会经过客户端侧的代理(客户端通常直接连接服务端),而且即使被服务端侧的代理缓存,影响也远小于客户端侧。
一个有意思的注脚: 设计好协议后,工作组才发现 IEEE 的某些 Wi-Fi 嗅探器也会被未 Mask 的数据影响--算是意外收获。
3. OkHttp WebSocket 实战
OkHttp 是 Android 上最广泛使用的 HTTP 客户端,它对 WebSocket 有原生支持。在 Kotlin 协程和 Flow 大行其道的今天,它仍然使用经典的回调模式,但足够稳定好用。
3.1 环境准备
首先,确认 build.gradle 包含 OkHttp(4.x 及以上版本原生支持 WebSocket):
// build.gradle.kts (Module)
dependencies {
implementation("com.squareup.okhttp3:okhttp:4.12.0")
}
3.2 建立连接
class WebSocketClient(private val url: String) {
private val client = OkHttpClient.Builder()
.pingInterval(30, TimeUnit.SECONDS) // 协议层心跳,后面详谈
.build()
private var webSocket: WebSocket? = null
fun connect() {
val request = Request.Builder()
.url(url)
.addHeader("Origin", "app://myapp") // 可选,一些服务端校验
.build()
webSocket = client.newWebSocket(request, object : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: Response) {
// 连接建立成功
}
override fun onMessage(webSocket: WebSocket, text: String) {
// 收到文本消息
}
override fun onMessage(webSocket: WebSocket, bytes: ByteString) {
// 收到二进制消息
}
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
// 收到服务端关闭帧(1000 表示正常关闭)
webSocket.close(code, reason)
}
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
// 连接已完全关闭
}
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
// 连接失败或异常断开
}
})
// 重要:OkHttp WebSocket 的回调在 OkHttp 的线程池中执行
// 不能直接更新 UI,需要切换到主线程
}
fun disconnect() {
webSocket?.close(1000, "Client closing")
// 1000 = Normal Closure,表示期望的优雅关闭
}
}
3.3 WebSocketListener 各回调的触发时机
理解回调的触发顺序和条件,是写好 WebSocket 代码的关键:
连接生命周期(完美情况):
connect() → onOpen → onMessage(多次)→ onClosing → onClosed
异常情况:
connect() → onOpen → onMessage → onFailure(网络断开)
connect() → onFailure(连接失败,如 DNS 解析失败、连接超时)
connect() → onOpen → onClosing → onFailure(服务端关闭后网络异常)
优雅关闭流程:
1 发起方调用 close(1000, "reason")
2 发送 Close 帧给对端(opcode = 0x8)
3 对端收到 Close 帧 → 触发 onClosing
4 在 onClosing 中应调用 webSocket.close(code, reason) 回复
5 发起方收到 Close 回复 → 触发 onClosed
6 连接彻底关闭
关注两点:
- onClosing 不是关闭信号,而是"对方想关闭"的通知。你需要在 onClosing 中回复
close()来完成握手关闭。 - onClosed 才是"已关闭" 。只有收到 onClosed 或 onFailure,才能确定连接不再可用。
3.4 发送消息
// 发送文本消息
val textSent = webSocket?.send("Hello 服务端!")
if (!textSent) {
// 连接已关闭或不可用
}
// 发送二进制消息
val data = byteArrayOf(0x00, 0x01, 0x02)
val byteSent = webSocket?.send(ByteString.of(*data))
// 注意:send() 返回 Boolean,但不是"送达确认"
// true 仅表示消息已从应用层交付给 OkHttp 的写入缓冲区
// false 表示缓冲区满或连接已关闭
关于 send() 返回值的误解:
webSocket.send("...") 返回 true 只表示消息进入了 OkHttp 内部缓冲区。它没有被服务端确认收到。对于需要可靠投递的场景,必须在应用层实现 ACK(后面第 6 节详谈)。
3.5 优雅关闭 vs 异常断开
// ✅ 优雅关闭(主动)
webSocket?.close(1000, "User disconnected")
// 1000: Normal Closure - 正常关闭
// ✅ 优雅关闭(被动)
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
// 必须回复 close,否则连接不会真正关闭
webSocket.close(code, reason)
// 持有 code 和 reason 可以用于业务判断,如:
// 1001 = Going Away(服务端重启)
// 1008 = Policy Violation(协议违规被踢)
}
// ❌ 强制关闭(不推荐)
// webSocket?.cancel() // 立即断开,不发送 Close 帧
关闭状态码参考(RFC 6455):
| 状态码 | 含义 | 场景 |
|---|---|---|
| 1000 | Normal Closure | 正常关闭 |
| 1001 | Going Away | 客户端离开/服务端宕机 |
| 1002 | Protocol Error | 协议错误 |
| 1006 | Abnormal Closure | 不应该用于 close() ,仅状态指示 |
| 1008 | Policy Violation | 数据不符合规范,服务端踢人 |
| 1009 | Message Too Big | 消息超长 |
| 1011 | Internal Error | 服务端内部错误 |
3.6 完整 WebSocket 示例(生产级)
class ChatWebSocket(
private val url: String,
private val onMessageReceived: (String) -> Unit,
private val onConnectionChanged: (ConnectionState) -> Unit
) {
enum class ConnectionState {
CONNECTING, CONNECTED, DISCONNECTED, FAILED
}
private var webSocket: WebSocket? = null
private var isClosedByUser = false
private val client = OkHttpClient.Builder()
.pingInterval(30, TimeUnit.SECONDS) // 协议层心跳
.readTimeout(0, TimeUnit.MILLISECONDS) // WebSocket 刚需:禁用超时
.writeTimeout(0, TimeUnit.MILLISECONDS)
.connectionSpecs(listOf(
ConnectionSpec.MODERN_TLS // 强制 TLS 安全连接
))
.build()
fun connect() {
if (webSocket != null) return
isClosedByUser = false
onConnectionChanged(ConnectionState.CONNECTING)
val request = Request.Builder().url(url).build()
webSocket = client.newWebSocket(request, object : WebSocketListener() {
override fun onOpen(ws: WebSocket, response: Response) {
onConnectionChanged(ConnectionState.CONNECTED)
}
override fun onMessage(ws: WebSocket, text: String) {
onMessageReceived(text)
}
override fun onMessage(ws: WebSocket, bytes: ByteString) {
// 二进制消息按需处理
}
override fun onClosing(ws: WebSocket, code: Int, reason: String) {
ws.close(code, reason)
}
override fun onClosed(ws: WebSocket, code: Int, reason: String) {
onConnectionChanged(ConnectionState.DISCONNECTED)
}
override fun onFailure(ws: WebSocket, t: Throwable, response: Response?) {
if (!isClosedByUser) {
onConnectionChanged(ConnectionState.FAILED)
}
}
})
}
fun send(text: String): Boolean {
return webSocket?.send(text) ?: false
}
fun disconnect() {
isClosedByUser = true
webSocket?.close(1000, "User disconnected")
webSocket = null
}
}
几个值得注意的生产细节:
readTimeout(0, ...)是必须的--WebSocket 的使用场景天然是"持续等待数据",任何超时设置都会导致连接被自动断开。isClosedByUser区分主动/被动断开--如果不加这个标志,用户主动断开时一样会触发断线重连。- 回调都在 OkHttp 线程池--需要切到主线程才能更新 UI 或触发 LiveData/Flow。
4. 心跳保活机制
长连接最大的敌人不是"断开",而是"不知不觉断开"。心跳机制是应对这个问题的第一道防线。
4.1 为什么需要心跳
一个 WebSocket 连接建立后,如果双方长时间(通常 60-600 秒,取决于网络环境)没有任何数据交换,连接可能会被中间设备静默断开。
罪魁祸首们:
| 设备/场景 | 典型空闲超时 | 行为 |
|---|---|---|
| NAT 路由器(电信) | 5 分钟 | 删除映射表项,连接"假掉" |
| NAT 路由器(移动 4G/5G) | 30-120 秒 | 部分运营商会更激进 |
| 企业防火墙 | 4-60 分钟 | 取决于规则配置 |
| CDN / 反向代理(Nginx) | 60 秒(默认) | 断开空闲连接 |
| 云负载均衡器(AWS ALB) | 350 秒 | 空闲超时 |
| 移动基站(空闲态) | 约 10 秒(RRC 释放) | 虽然不直接影响 TCP,但影响心跳性能 |
关键是:这些设备断开连接时不会发送 TCP RST 或 FIN。你的 webSocket 对象看起来还是 "open" 的,但任何数据发送都会静默丢失。这就是所谓的 "僵尸连接" 。
心跳保活就是定期发送小数据包,让中间设备认为这条连接仍然"活跃",从而保持其 NAT 映射表和连接状态。
4.2 OkHttp 的自动 Ping/Pong
OkHttp 在内置了协议层的心跳支持,通过 pingInterval 配置:
val client = OkHttpClient.Builder()
.pingInterval(30, TimeUnit.SECONDS) // 每 30 秒发一次 Ping
.build()
它的工作方式:
时间线:
T+0s 连接建立
T+30s OkHttp 自动发送 Ping 帧(opcode = 0x9)
T+30s+ 服务端回复 Pong 帧(opcode = 0xA)
T+60s 再次 Ping → Pong
...
如果 Pong 未在超时(约 60 秒,即 2 个 interval)内回复:
→ 触发 onFailure(t = SocketTimeoutException)
OkHttp 自动 Ping/Pong 的优缺点:
✅ 优点:
- 零配置,一行代码搞定
- 基于 WebSocket 协议层实现,与服务端有标准互通性
- CPU 和电量开销极低
❌ 缺点:
- 可定制度低--不能自定义心跳间隔策略(移动网络 vs WiFi 不同)
- 超时检测依赖 Socket 超时,不够灵活
- 只检测"连接是否活着",无法携带应用层数据(如用户在线状态)
4.3 自定义应用层心跳 vs 协议层心跳
很多大型项目会同时使用两层心跳:
协议层心跳(OkHttp 自动 Ping/Pong):
检测 TCP 层连通性,间隔 30-60 秒
→ 如果失败,触发 onFailure
→ 由 OkHttp 处理,应用层不需管
应用层心跳(自定义):
携带业务数据(用户 ID、序列号、时间戳)
间隔可调整(前台 30 秒、后台 120 秒)
服务端回复中包含时间信息(用于客户端校准)
→ 应用层自己处理
什么时候需要应用层心跳:
- 需要服务端状态确认--协议层 Ping/Pong 是"你还在吗?→ 在。"应用层心跳可以是"用户 1001 还在线"--服务端可以据此清理无效用户数据
- 需要动态间隔--前台激活时 30 秒、后台挂起时 120 秒、省电模式下 300 秒
- 心跳中带数据--比如客户端时间戳,服务端回复时带回时间差,客户端自动计算到"服务器同步状态"
// 应用层心跳消息示例(JSON)
// 发送:
{ "type": "heartbeat", "clientTime": 1714291200000, "userId": "1001" }
// 收到回复:
{ "type": "heartbeat_ack", "serverTime": 1714291200050, "userId": "1001" }
4.4 心跳间隔的选择(移动网络 vs WiFi)
没有"正确"的间隔,只有"适合场景"的间隔。
几个真实项目的经验值:
| 场景 | 推荐间隔 | 理由 |
|---|---|---|
| WiFi 环境 | 60-120 秒 | NAT 超时通常 5 分钟起,60 秒已足够保活 |
| 4G/5G 移动网络 | 25-45 秒 | 运营商 NAT 超时可能低至 30 秒 |
| 前台活跃 | 30 秒 | 用户体验优先,保持低延迟 |
| 后台运行 | 120-300 秒 | 省电,牺牲一点延迟 |
| 海外网络 | 15-25 秒 | 部分国外运营商的 NAT 更激进(低至 20 秒) |
Android 开发建议:
fun calculateHeartbeatInterval(context: Context): Long {
val connectivityManager =
context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
val activeNetwork = connectivityManager.activeNetworkInfo ?: return 30_000L
return when (activeNetwork.type) {
ConnectivityManager.TYPE_WIFI -> 60_000L // WiFi 60 秒
ConnectivityManager.TYPE_MOBILE -> {
if (isUsingWideAreaNetwork(context)) 25_000L else 45_000L
}
else -> 30_000L
}
}
// 更现代的方式(API 23+):
val networks = connectivityManager.getNetworkCapabilities(activeNetwork)
if (networks?.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) == true) {
return 60_000L
}
if (networks?.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) == true) {
return 30_000L
}
黄金法则: 宁可心跳过于频繁浪费一点流量(每 30 秒大约 150 字节 × 24 小时 ≈ 430KB/天),不要因为心跳间隔太长导致连接频繁断开。连接重建的开销(TLS 握手通常需要 1-3 个 RTT)远远大于心跳消耗的带宽。
5. 断线重连策略
长连接一定会断--这不是"如果"的问题,而是"什么时候"的问题。
网络不稳定、NAT 超时、移动网络切换、后台进程被杀死......你的长连接必然会被各种原因打断。而衡量一个长连接系统好不好的关键指标不是"断不断",而是"断多快恢复"。
5.1 检测断线
断线的检测有两个路径:
路径一:onFailure 回调
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
// 这是断线的"直接通知"
when (t) {
is SocketTimeoutException -> { /* 读写超时,通常是心跳没回复 */ }
is IOException -> { /* 网络异常:DNS 失败、连接被拒、SSL 握手失败、RST */ }
is SSLException -> { /* TLS 层错误:证书不匹配、证书过期 */ }
}
}
路径二:心跳超时
如果 OkHttp 的 pingInterval 设置了,但服务端在网络异常时没有及时回复 Pong,OkHttp 不会立刻触发 onFailure。超时检测依赖于 Socket 的读取超时:
OkHttp 发送 Ping → 等待 Pong → 等待下一个 Ping interval
如果没有收到任何数据(包括 TCP keep-alive):
→ 最终触发 onFailure(SocketTimeoutException)
但这个过程可能需要:
pingInterval(30s) + pingInterval(30s) + 余量 = ~60-90 秒
这 60-90 秒的"静默期"对用户体验来说毫无感知--但你在后台可以看到 Socket 层面的异常日志。
最佳实践: 不要只依赖一个检测信号,而是组合使用:
// 组合检测策略
// 1. onFailure 直接触发重连
// 2. 应用层单独维护一个"最近收到消息的时间戳"
// 3. 如果超过 N 秒(如 90 秒)未收到任何数据(包括心跳回复),主动断开并重连
class ReconnectManager {
private var lastMessageTime = 0L
fun onDataReceived() {
lastMessageTime = System.currentTimeMillis()
}
fun isStale(timeoutMs: Long = 90_000L): Boolean {
return System.currentTimeMillis() - lastMessageTime > timeoutMs
}
}
5.2 重连策略:指数退避
不要用固定间隔重连!
为什么?想象一个场景:WiFi 密码不对,连接 WebSocket 一直失败。
❌ 固定间隔每 3 秒重连:
第 0 秒:连接失败
第 3 秒:连接失败(WiFi 密码错,永远好不了)
第 6 秒:连接失败
... 无限循环 ...
结果:CPU 跑满、电量狂掉、用户卸载你的 App
✅ 指数退避:
第 0 秒:连接失败 → 等 1 秒
第 1 秒:连接失败 → 等 2 秒
第 3 秒:连接失败 → 等 4 秒
第 7 秒:连接失败 → 等 8 秒
第 15 秒:连接失败 → 等 16 秒
第 31 秒:连接失败 → 等 32 秒(上限)
... 之后每 32 秒重试一次 ...
结果:如果网络恢复,很快就能连上;
如果网络一直坏,不会浪费太多资源
指数退避公式:
// 基础实现
fun getNextDelay(attempt: Int, maxDelay: Long = 30_000L): Long {
val delay = minOf(
(1L shl minOf(attempt, 15)) * 1000L, // 2^attempt 秒
maxDelay
)
// 加随机抖动(jitter),避免多客户端同时重连造成雷击
return delay + Random.nextLong(0, delay / 2)
}
// 重连序列:
// attempt=0 → 1+(0~0.5) 秒
// attempt=1 → 2+(0~1) 秒
// attempt=2 → 4+(0~2) 秒
// attempt=3 → 8+(0~4) 秒
// attempt=4 → 16+(0~8) 秒
// attempt=5 → 30+(0~15) 秒(到达上限)
// attempt=6 → 30+(0~15) 秒(维持上限)
// 为什么还加随机抖动?
// 想象 1000 个手机同时断线,如果没有 jitter:
// 它们会在同一秒发起连接 → 服务器瞬间收到 1000 个握手请求 → 雪崩效应
// 加了 0~15 秒的 jitter → 请求均匀分布在 30~45 秒内 → 服务器平稳接收
5.3 最大重连次数与上限间隔
断线重连需要两个上限:
class ReconnectPolicy {
companion object {
// 上限 1:单次重连的最大间隔
const val MAX_DELAY_MS = 30_000L // 30 秒
// 上限 2:连续重连的最大次数
const val MAX_RECONNECT_ATTEMPTS = 20 // 20 次
// 达到上限后的行为
const val RETRY_INTERVAL_AFTER_CAP_MS = 5 * 60_000L // 5 分钟后降频尝试
}
}
为什么不无限重试?
如果网络彻底不可用(比如用户在飞机上、手机信号盲区),无限重连就是无限浪费。20 次重试覆盖了大约 1+2+4+8+16+30*15 ≈ 480 秒 ≈ 8 分钟。8 分钟后如果还连不上,说明大概率是网络环境出了问题。
此时降频为每 5 分钟一次(或更久),继续监听网络变化:
fun shouldRetry(attempt: Int): Boolean {
if (attempt < MAX_RECONNECT_ATTEMPTS) return true
return System.currentTimeMillis() - lastAttemptTime > RETRY_INTERVAL_AFTER_CAP_MS
}
5.4 网络切换时的重连(ConnectivityManager 监听)
移动设备最典型的断线场景是网络切换:WiFi → 4G、4G → WiFi、飞行模式开关。这些场景下,原有的 TCP 连接已经失效,必须重建 WebSocket。
监听网络变化,在切换发生时主动断开 + 重连,不需要等到心跳超时。
class NetworkAwareReconnect(context: Context) {
private val connectivityManager =
context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
private var lastNetworkId: Int? = null
private var callback: NetworkCallback? = null
fun startMonitoring(onNetworkChanged: () -> Unit) {
val currentNetwork = getNetworkId()
if (currentNetwork != null) lastNetworkId = currentNetwork
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
callback = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
val newId = getNetworkId()
val oldId = lastNetworkId
lastNetworkId = newId
// 网络类型变化时才触发重连
if (oldId != null && newId != null && oldId != newId) {
onNetworkChanged()
}
}
override fun onLost(network: Network) {
// 当前网络丢失,需要重连
onNetworkChanged()
}
}
connectivityManager.registerDefaultNetworkCallback(callback!!)
} else {
@Suppress("DEPRECATION")
val filter = IntentFilter(ConnectivityManager.CONNECTIVITY_ACTION)
// 旧 API 需要注册 BroadcastReceiver
}
}
private fun getNetworkId(): Int? {
val activeNetwork = connectivityManager.activeNetwork ?: return null
val caps = connectivityManager.getNetworkCapabilities(activeNetwork) ?: return null
return when {
caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> 1
caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> 2
caps.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) -> 3
else -> 4
}
}
fun stopMonitoring() {
callback?.let { connectivityManager.unregisterNetworkCallback(it) }
}
}
注意: CONNECTIVITY_ACTION 在 API 28 起已被弃用,应使用 registerDefaultNetworkCallback 替代。另外,不要每次 onAvailable 都重连--WiFi 信号波动可能导致短时间内频繁触发。应结合去抖(debounce)机制:
private var networkChangeJob: Job? = null
fun scheduleReconnect(delayMs: Long = 500L) {
networkChangeJob?.cancel()
networkChangeJob = scope.launch {
delay(delayMs) // 等待 500ms 确保网络稳定
onReconnect()
}
}
5.5 完整的断线重连管理器代码
结合以上所有策略,一个生产级的断线重连管理器:
class ReconnectionManager(
private val scope: CoroutineScope,
private val onConnect: () -> Unit,
private val maxAttempts: Int = 20,
private val maxDelayMs: Long = 30_000L
) {
private var currentAttempt = 0
private var isStopped = false
private var reconnectJob: Job? = null
fun start() {
isStopped = false
currentAttempt = 0
scheduleReconnect(0)
}
fun onConnectionSuccess() {
currentAttempt = 0
reconnectJob?.cancel()
}
fun onConnectionFailed() {
if (!isStopped) {
scheduleReconnect()
}
}
private fun scheduleReconnect(forcedDelay: Long? = null) {
if (isStopped || currentAttempt > maxAttempts) return
reconnectJob?.cancel()
reconnectJob = scope.launch {
val delay = forcedDelay ?: calculateDelay(currentAttempt)
delay(delay)
currentAttempt++
onConnect()
}
}
private fun calculateDelay(attempt: Int): Long {
val exponential = minOf(
(1L shl minOf(attempt, 15)) * 1000L,
maxDelayMs
)
val jitter = Random.nextLong(0, exponential / 2 + 1)
return exponential + jitter
}
fun stop() {
isStopped = true
reconnectJob?.cancel()
currentAttempt = 0
}
fun resetAttempts() {
currentAttempt = 0
}
}
// 使用示例
class ChatService(context: Context) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private var webSocket: ChatWebSocket? = null
private val reconnectionManager = ReconnectionManager(
scope = scope,
onConnect = { connect() }
)
private val networkMonitor = NetworkAwareReconnect(context)
fun start() {
networkMonitor.startMonitoring {
reconnectionManager.resetAttempts()
reconnectionManager.start()
}
reconnectionManager.start()
}
private fun connect() {
webSocket?.disconnect()
webSocket = ChatWebSocket(
url = "wss://chat.example.com/ws",
onMessageReceived = { msg ->
reconnectionManager.onConnectionSuccess()
handleMessage(msg)
},
onConnectionChanged = { state ->
when (state) {
ChatWebSocket.ConnectionState.CONNECTED ->
reconnectionManager.onConnectionSuccess()
ChatWebSocket.ConnectionState.FAILED,
ChatWebSocket.ConnectionState.DISCONNECTED ->
reconnectionManager.onConnectionFailed()
else -> {}
}
}
)
webSocket?.connect()
}
fun destroy() {
networkMonitor.stopMonitoring()
reconnectionManager.stop()
webSocket?.disconnect()
scope.cancel()
}
}
这个管理器体现了几条关键原则:
- 网络变化时重置退避计数器--用户从地铁出来后连上 WiFi,应立刻尝试连接,而不用等 30 秒
- 成功连接后重置计数器--确保下次断开时从短间隔开始
- 协程驱动--避免线程泄露,生命周期可控
- 外部停止机制--Activity/Fragment 销毁时主动停止
6. 消息可靠性
很多开发者误以为"WebSocket 连接建立了,消息就一定能到"。事实并非如此。
WebSocket 基于 TCP,TCP 保证的是传输层的可靠交付--数据包按序到达、不丢包。但这只是底层保证。在应用层面,以下情况都可能导致消息"丢失":
- 服务端发了消息,但客户端正好在重连中,消息发到了一个已断开的连接
- 客户端发了消息,服务端收到了但处理失败
- 客户端在弱网环境下发消息,TCP 缓冲区满了,连接被 reset
- 进程被系统杀死,内存中未持久化的消息丢失
所以,应用层必须自己保证消息可靠性。
6.1 应用层 ACK 机制
ACK(Acknowledgment)是最核心的可靠性保障。思路很简单:每条消息带一个唯一 ID,接收方收到后回一个 ACK,发送方收到 ACK 才算"送达"。
// 消息结构
data class ChatMessage(
val msgId: String = UUID.randomUUID().toString(), // 唯一 ID
val type: String, // "text", "image", "ack", ...
val content: String,
val timestamp: Long = System.currentTimeMillis(),
val status: MessageStatus = MessageStatus.SENDING
)
enum class MessageStatus {
SENDING, // 已发出,等待 ACK
SENT, // 收到服务端 ACK
DELIVERED, // 收到对方 ACK
READ, // 对方已读
FAILED // 发送失败
}
ACK 流程:
发送方 A 服务端 接收方 B
|--- msg(id=123) --->| |
| |--- msg(id=123) --->|
|<-- ack(id=123) ----| |
| (SENT) |<-- ack(id=123) ----|
|<-- delivered(123) -| |
| (DELIVERED) | |
class AckManager(
private val scope: CoroutineScope,
private val sendFunc: (String) -> Unit,
private val onTimeout: (String) -> Unit
) {
// 等待 ACK 的消息池
private val pendingAcks = ConcurrentHashMap<String, Job>()
private val ACK_TIMEOUT_MS = 10_000L // 10 秒超时
fun waitForAck(msgId: String) {
pendingAcks[msgId] = scope.launch {
delay(ACK_TIMEOUT_MS)
// 超时未收到 ACK,触发重发
pendingAcks.remove(msgId)
onTimeout(msgId)
}
}
fun onAckReceived(msgId: String) {
pendingAcks.remove(msgId)?.cancel() // 取消超时任务
}
}
6.2 消息队列与重发策略
单靠 ACK 超时重发还不够。如果用户在弱网环境下连续发了 10 条消息,需要一个消息队列来管理发送顺序和重试逻辑。
class MessageQueue(
private val scope: CoroutineScope,
private val dao: MessageDao // Room 数据库
) {
private val sendQueue = Channel<ChatMessage>(Channel.UNLIMITED)
private var isProcessing = false
fun enqueue(message: ChatMessage) {
// 先持久化到数据库,防止进程被杀
scope.launch(Dispatchers.IO) {
dao.insert(message.copy(status = MessageStatus.SENDING))
sendQueue.send(message)
}
}
fun startProcessing(webSocket: WebSocket) {
if (isProcessing) return
isProcessing = true
scope.launch {
for (msg in sendQueue) {
var retryCount = 0
val maxRetries = 3
while (retryCount < maxRetries) {
try {
val json = Gson().toJson(msg)
val success = webSocket.send(json)
if (success) {
dao.updateStatus(msg.msgId, MessageStatus.SENT)
break
}
} catch (e: Exception) {
// 发送异常
}
retryCount++
delay(1000L * retryCount) // 线性退避
}
if (retryCount >= maxRetries) {
dao.updateStatus(msg.msgId, MessageStatus.FAILED)
}
}
}
}
// 重连后,重发所有 SENDING 状态的消息
fun resendPending(webSocket: WebSocket) {
scope.launch(Dispatchers.IO) {
val pendingMessages = dao.getByStatus(MessageStatus.SENDING)
pendingMessages.forEach { msg ->
sendQueue.send(msg)
}
}
}
fun stop() {
isProcessing = false
sendQueue.close()
}
}
关键设计点:
- 先存库再发送--进程被杀也不怕丢消息
- SENDING 状态--重连后扫描这个状态,把未确认的消息重发
- 有限重试--避免某条消息卡住整个队列
6.3 离线消息同步
用户断线期间,其他人发来的消息怎么办?服务端需要暂存,客户端重连后主动拉取。
常见的两种方案:
方案一:基于时间戳拉取
// 客户端重连成功后
fun syncOfflineMessages() {
val lastMsgTimestamp = dao.getLatestTimestamp() // 本地最新消息时间
val request = SyncRequest(
userId = currentUserId,
since = lastMsgTimestamp,
limit = 200
)
// 通过 WebSocket 或 HTTP 请求离线消息
webSocket.send(Gson().toJson(mapOf(
"type" to "sync",
"data" to request
)))
}
方案二:基于消息序列号(Sequence)
更可靠的方案是给每条消息分配一个递增的序列号(类似 Kafka 的 offset)。客户端只需要告诉服务端"我收到的最大 seq 是多少",服务端就知道该推哪些消息。
// 服务端为每个会话维护一个递增 seq
// 客户端重连后:
fun syncBySequence() {
val lastSeq = dao.getMaxSequence(conversationId)
webSocket.send(Gson().toJson(mapOf(
"type" to "sync",
"conversationId" to conversationId,
"lastSeq" to lastSeq
)))
}
序列号方案的优势:
- 不怕时间戳不同步
- 天然支持"是否有遗漏"的校验--seq 不连续就知道丢了
- 和消息去重天然配合--相同 seq 不重复入库
面试高频: "你们的 IM 怎么保证消息不丢?"--回答 ACK + 消息持久化 + 离线同步 + 去重(幂等),四个关键词缺一不可。
7. 与其他方案的对比
WebSocket 不是唯一的实时通信方案。不同场景下,其他协议可能更合适。
7.1 WebSocket vs MQTT
MQTT(Message Queuing Telemetry Transport)是 IoT 领域的事实标准,也被用在移动端推送场景(如早期的 Facebook Messenger)。
| 维度 | WebSocket | MQTT |
|---|---|---|
| 设计目标 | 通用双向通信 | 低带宽、高延迟网络下的消息传递 |
| 协议层 | 应用层,基于 TCP | 应用层,基于 TCP(也可跑在 WebSocket 上) |
| 消息模型 | 无内置模型,自由定义 | 发布/订阅(Pub/Sub) |
| QoS 支持 | 无(应用层自己实现) | 三级 QoS(0=最多一次,1=至少一次,2=恰好一次) |
| 消息大小 | 无限制(受配置约束) | 协议头极小(最小 2 字节),适合小消息 |
| 典型场景 | IM、实时协作、游戏 | IoT 传感器数据、消息推送 |
| Android 库 | OkHttp 内置 | Eclipse Paho |
选 MQTT 的场景: 设备资源有限(嵌入式)、网络极不稳定(2G/卫星)、需要协议级 QoS、Pub/Sub 模型天然匹配业务。
选 WebSocket 的场景: 需要灵活的双向交互(不只是消息分发)、与 Web 前端统一协议栈、自定义协议格式。
7.2 WebSocket vs gRPC Stream
gRPC 基于 HTTP/2,天然支持四种通信模式:Unary、Server Stream、Client Stream、Bidirectional Stream。其中双向流(Bidirectional Stream)在功能上和 WebSocket 很像。
| 维度 | WebSocket | gRPC Bidirectional Stream |
|---|---|---|
| 传输协议 | HTTP/1.1 Upgrade → TCP | HTTP/2 |
| 序列化 | 自定义(JSON/Protobuf 都行) | Protobuf(强类型) |
| 代码生成 | 无 | .proto 文件生成客户端/服务端代码 |
| 浏览器支持 | 原生支持 | 需要 gRPC-Web 代理 |
| 多路复用 | 一个连接一个通道 | HTTP/2 原生多路复用 |
| 负载均衡 | 成熟(Nginx 等) | 需要 L7 负载均衡(Envoy 等) |
| 典型场景 | IM、实时 Web 应用 | 微服务间通信、强类型 RPC |
选 gRPC Stream 的场景: 后端微服务已全面使用 gRPC、需要强类型接口约束、Protobuf 的性能优势明显。
选 WebSocket 的场景: 需要 Web 端兼容、团队更熟悉 WebSocket、不想引入 Protobuf 工具链。
7.3 WebSocket vs FCM
FCM(Firebase Cloud Messaging,前身 GCM)是 Android 的官方推送通道。
| 维度 | WebSocket | FCM |
|---|---|---|
| 连接维护 | 应用自己维护 | 系统级维护(共享连接) |
| 电量消耗 | 需要 WakeLock/前台服务 | 系统优化,省电 |
| 实时性 | 毫秒级 | 通常秒级,高峰期可能延迟 |
| 可靠性 | 应用层保证 | 不保证送达顺序和时效 |
| 离线消息 | 应用层实现 | FCM 自动暂存(最多 4 周) |
| 国内可用性 | ✅ | ❌(需要 Google 服务) |
| 数据限制 | 无 | 4KB(data message) |
| 适合场景 | IM、实时交互 | 通知推送、静默唤醒 |
实际方案通常是两者结合:
- FCM 负责唤醒--当 App 在后台被系统杀死,通过 FCM 推送一个信号唤醒 App
- WebSocket 负责实时通信--App 在前台时走 WebSocket 传输数据
- 国内市场用各厂商推送通道(小米推送、华为 Push 等)替代 FCM
7.4 选型建议
| 场景 | 推荐方案 | 原因 |
|---|---|---|
| IM / 聊天 | WebSocket + 推送通道兜底 | 需要双向实时通信 + 后台唤醒 |
| 实时行情 / 股票 | WebSocket | 高频数据推送,服务端主导 |
| IoT 设备通信 | MQTT | 低带宽、协议级 QoS |
| 微服务实时流 | gRPC Stream | 强类型、多路复用 |
| 纯通知推送 | FCM / 厂商推送 | 省电、系统级保活 |
| 协作编辑(如文档) | WebSocket + OT/CRDT | 双向实时 + 冲突解决 |
| 在线游戏 | WebSocket 或 UDP | 低延迟优先 |
8. 总结
8.1 要点回顾
这篇文章从"为什么需要 WebSocket"出发,一路深入到协议细节和生产级实践:
- 为什么需要 WebSocket--短轮询浪费、长轮询有延迟、SSE 单向,WebSocket 是真正的全双工
- 协议握手--HTTP Upgrade 请求 → 101 Switching Protocols → TCP 连接复用
- 数据帧格式--FIN、Opcode、Mask、Payload Length,每个字段都有其设计考量
- 心跳保活--Ping/Pong 帧 + 应用层心跳,对抗 NAT 超时和连接假死
- 断线重连--指数退避 + 随机抖动 + 最大重试次数 + 网络切换监听
- 消息可靠性--应用层 ACK + 消息队列 + 离线同步,TCP 可靠 ≠ 业务可靠
- 方案对比--WebSocket / MQTT / gRPC / FCM 各有适用场景
8.2 面试高频题速查
| 问题 | 关键回答 |
|---|---|
| WebSocket 和 HTTP 的关系? | 握手阶段是 HTTP,升级后是独立的 TCP 全双工协议 |
| 为什么需要心跳? | 检测连接假死 + 维持 NAT 映射 |
| 心跳间隔怎么定? | 小于 NAT 超时(通常 30s),平衡电量和实时性 |
| 断线重连用什么策略? | 指数退避 + 随机抖动 + 最大次数 + 网络变化监听 |
| WebSocket 能保证消息不丢吗? | 不能,需要应用层 ACK + 消息持久化 + 离线同步 |
| WebSocket vs MQTT 怎么选? | 通用双向交互选 WebSocket,IoT/低带宽选 MQTT |
| 移动端怎么保活 WebSocket? | 前台心跳 + 后台降频 + 推送通道兜底唤醒 |