WebSocket 与长连接:从协议握手到断线重连的完整实战

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 请求,问服务端"有更新了吗?"

工作原理:

arduino 复制代码
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)

短轮询的改进版:客户端发起请求后,服务端不立即返回,而是"挂起"连接,直到有新数据时再响应。

arduino 复制代码
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 连接持续推送事件流给客户端。

arduino 复制代码
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 设计之初就瞄准了"全双工"这个目标:

arduino 复制代码
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 升级):

makefile 复制代码
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: websocketConnection: Upgrade:告诉服务端"我想把这条 HTTP 连接升级为 WebSocket"
  • Sec-WebSocket-Key:客户端生成的 16 字节随机值,Base64 编码。用于证明请求是"认真的",不是被缓存篡改的
  • Sec-WebSocket-Version: 13:协议版本号,目前仅 13 被主流支持

服务端响应(101 Switching Protocols):

makefile 复制代码
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。客户端收到后做同样的计算校验,确保握手正确。

css 复制代码
// 服务端计算方式(验证用):
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)。每一帧的结构如下:

lua 复制代码
 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" 文本消息):

ini 复制代码
二进制(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:

arduino 复制代码
'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 响应缓存。设想一个恶意页面:

  1. 用户访问 http://evil.com
  2. 该页面 JavaScript 连接 ws://victim-proxy/,
  3. 恶意页面通过 WebSocket 发送精心构造的二进制帧,其内容恰好包含 HTTP/1.1 200 OK 和恶意脚本
  4. 如果代理服务器识别不到 Upgrade 握手,可能认为这是一个 HTTP 响应,将其缓存
  5. 后续用户访问同一域名时,代理返回了缓存的"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):

scss 复制代码
// build.gradle.kts (Module)
dependencies {
    implementation("com.squareup.okhttp3:okhttp:4.12.0")
}

3.2 建立连接

kotlin 复制代码
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 代码的关键:

scss 复制代码
连接生命周期(完美情况):
  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 发送消息

kotlin 复制代码
// 发送文本消息
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 异常断开

kotlin 复制代码
// ✅ 优雅关闭(主动)
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 示例(生产级)

kotlin 复制代码
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
    }
}

几个值得注意的生产细节:

  1. readTimeout(0, ...) 是必须的--WebSocket 的使用场景天然是"持续等待数据",任何超时设置都会导致连接被自动断开。
  2. isClosedByUser 区分主动/被动断开--如果不加这个标志,用户主动断开时一样会触发断线重连。
  3. 回调都在 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 配置:

scss 复制代码
val client = OkHttpClient.Builder()
    .pingInterval(30, TimeUnit.SECONDS) // 每 30 秒发一次 Ping
    .build()

它的工作方式:

ini 复制代码
时间线:
  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 协议层心跳

很多大型项目会同时使用两层心跳:

makefile 复制代码
协议层心跳(OkHttp 自动 Ping/Pong):
  检测 TCP 层连通性,间隔 30-60 秒
  → 如果失败,触发 onFailure
  → 由 OkHttp 处理,应用层不需管

应用层心跳(自定义):
  携带业务数据(用户 ID、序列号、时间戳)
  间隔可调整(前台 30 秒、后台 120 秒)
  服务端回复中包含时间信息(用于客户端校准)
  → 应用层自己处理

什么时候需要应用层心跳:

  1. 需要服务端状态确认--协议层 Ping/Pong 是"你还在吗?→ 在。"应用层心跳可以是"用户 1001 还在线"--服务端可以据此清理无效用户数据
  2. 需要动态间隔--前台激活时 30 秒、后台挂起时 120 秒、省电模式下 300 秒
  3. 心跳中带数据--比如客户端时间戳,服务端回复时带回时间差,客户端自动计算到"服务器同步状态"
json 复制代码
// 应用层心跳消息示例(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 开发建议:

kotlin 复制代码
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 回调

kotlin 复制代码
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 的读取超时:

scss 复制代码
OkHttp 发送 Ping → 等待 Pong → 等待下一个 Ping interval
​
如果没有收到任何数据(包括 TCP keep-alive):
  → 最终触发 onFailure(SocketTimeoutException)
​
但这个过程可能需要:
  pingInterval(30s) + pingInterval(30s) + 余量 = ~60-90 秒

这 60-90 秒的"静默期"对用户体验来说毫无感知--但你在后台可以看到 Socket 层面的异常日志。

最佳实践: 不要只依赖一个检测信号,而是组合使用:

kotlin 复制代码
// 组合检测策略
// 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 一直失败。

arduino 复制代码
❌ 固定间隔每 3 秒重连:
  第 0 秒:连接失败
  第 3 秒:连接失败(WiFi 密码错,永远好不了)
  第 6 秒:连接失败
  ... 无限循环 ...

  结果:CPU 跑满、电量狂掉、用户卸载你的 App
css 复制代码
✅ 指数退避:
  第 0 秒:连接失败 → 等 1 秒
  第 1 秒:连接失败 → 等 2 秒
  第 3 秒:连接失败 → 等 4 秒
  第 7 秒:连接失败 → 等 8 秒
  第 15 秒:连接失败 → 等 16 秒
  第 31 秒:连接失败 → 等 32 秒(上限)
  ... 之后每 32 秒重试一次 ...

  结果:如果网络恢复,很快就能连上;
        如果网络一直坏,不会浪费太多资源

指数退避公式:

kotlin 复制代码
// 基础实现
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 最大重连次数与上限间隔

断线重连需要两个上限:

kotlin 复制代码
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 分钟一次(或更久),继续监听网络变化:

kotlin 复制代码
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。

监听网络变化,在切换发生时主动断开 + 重连,不需要等到心跳超时。

kotlin 复制代码
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)机制:

kotlin 复制代码
private var networkChangeJob: Job? = null
​
fun scheduleReconnect(delayMs: Long = 500L) {
    networkChangeJob?.cancel()
    networkChangeJob = scope.launch {
        delay(delayMs)  // 等待 500ms 确保网络稳定
        onReconnect()
    }
}

5.5 完整的断线重连管理器代码

结合以上所有策略,一个生产级的断线重连管理器:

kotlin 复制代码
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()
    }
}

这个管理器体现了几条关键原则:

  1. 网络变化时重置退避计数器--用户从地铁出来后连上 WiFi,应立刻尝试连接,而不用等 30 秒
  2. 成功连接后重置计数器--确保下次断开时从短间隔开始
  3. 协程驱动--避免线程泄露,生命周期可控
  4. 外部停止机制--Activity/Fragment 销毁时主动停止

6. 消息可靠性

很多开发者误以为"WebSocket 连接建立了,消息就一定能到"。事实并非如此。

WebSocket 基于 TCP,TCP 保证的是传输层的可靠交付--数据包按序到达、不丢包。但这只是底层保证。在应用层面,以下情况都可能导致消息"丢失":

  • 服务端发了消息,但客户端正好在重连中,消息发到了一个已断开的连接
  • 客户端发了消息,服务端收到了但处理失败
  • 客户端在弱网环境下发消息,TCP 缓冲区满了,连接被 reset
  • 进程被系统杀死,内存中未持久化的消息丢失

所以,应用层必须自己保证消息可靠性

6.1 应用层 ACK 机制

ACK(Acknowledgment)是最核心的可靠性保障。思路很简单:每条消息带一个唯一 ID,接收方收到后回一个 ACK,发送方收到 ACK 才算"送达"。

kotlin 复制代码
// 消息结构
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 流程:

scss 复制代码
发送方 A              服务端              接收方 B
  |--- msg(id=123) --->|                    |
  |                    |--- msg(id=123) --->|
  |<-- ack(id=123) ----|                    |
  |   (SENT)           |<-- ack(id=123) ----|
  |<-- delivered(123) -|                    |
  |   (DELIVERED)      |                    |
kotlin 复制代码
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 条消息,需要一个消息队列来管理发送顺序和重试逻辑。

kotlin 复制代码
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 离线消息同步

用户断线期间,其他人发来的消息怎么办?服务端需要暂存,客户端重连后主动拉取。

常见的两种方案:

方案一:基于时间戳拉取

kotlin 复制代码
// 客户端重连成功后
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 是多少",服务端就知道该推哪些消息。

kotlin 复制代码
// 服务端为每个会话维护一个递增 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"出发,一路深入到协议细节和生产级实践:

  1. 为什么需要 WebSocket--短轮询浪费、长轮询有延迟、SSE 单向,WebSocket 是真正的全双工
  2. 协议握手--HTTP Upgrade 请求 → 101 Switching Protocols → TCP 连接复用
  3. 数据帧格式--FIN、Opcode、Mask、Payload Length,每个字段都有其设计考量
  4. 心跳保活--Ping/Pong 帧 + 应用层心跳,对抗 NAT 超时和连接假死
  5. 断线重连--指数退避 + 随机抖动 + 最大重试次数 + 网络切换监听
  6. 消息可靠性--应用层 ACK + 消息队列 + 离线同步,TCP 可靠 ≠ 业务可靠
  7. 方案对比--WebSocket / MQTT / gRPC / FCM 各有适用场景

8.2 面试高频题速查

问题 关键回答
WebSocket 和 HTTP 的关系? 握手阶段是 HTTP,升级后是独立的 TCP 全双工协议
为什么需要心跳? 检测连接假死 + 维持 NAT 映射
心跳间隔怎么定? 小于 NAT 超时(通常 30s),平衡电量和实时性
断线重连用什么策略? 指数退避 + 随机抖动 + 最大次数 + 网络变化监听
WebSocket 能保证消息不丢吗? 不能,需要应用层 ACK + 消息持久化 + 离线同步
WebSocket vs MQTT 怎么选? 通用双向交互选 WebSocket,IoT/低带宽选 MQTT
移动端怎么保活 WebSocket? 前台心跳 + 后台降频 + 推送通道兜底唤醒
相关推荐
2501_921649494 小时前
企业定制金融数据 API:从架构设计到 Python 接入实战
大数据·开发语言·python·websocket·金融·量化
用户97436970725281 天前
5分钟搭建企业级实时消息推送系统
后端·websocket
Unbelievabletobe1 天前
港股api的WebSocket推送如何订阅多只股票
网络·websocket·网络协议
永远不会出bug1 天前
JAVA:WebSocket 「在线状态 + 强制挤下线通知」
网络·websocket·网络协议
net3m332 天前
所有esp_websocket_client_send。。。的地方都加锁,就不容易websocket 断线重连
网络·websocket·网络协议
琪露诺大湿3 天前
网页聊天系统——测试报告
java·软件测试·功能测试·websocket·html·项目·测试报告
iwS2o90XT3 天前
WebSocket编程:Java实现实时双向通信应用
java·websocket·网络协议
Rick19933 天前
SSE、WebSocket、HTTP
websocket·网络协议·http
Walter先生5 天前
Python 获取美股盘前盘后数据:yfinance 的坑与解法
websocket·实时行情数据源