在即时通讯、行情推送、协同编辑这类业务里,WebSocket几乎是标配。iOS 13之后系统原生提供了URLSessionWebSocketTask,不再需要依赖第三方库就能实现长连接。但很多开发者在实际接入时发现,仅仅完成“连接、收发消息”还不够,连接在移动网络环境下经常莫名其妙断开,而代码层却拿不到任何错误回调。要让连接真正“稳”,必须补上心跳检测和断线重连这两块拼图。本文会从一个基础封装开始,逐步加入心跳、超时判定、指数退避重连,并梳理前后台切换时的处理思路。

URLSessionWebSocketTask的基础连接与消息收发
创建WebSocket连接的第一步是构造URLSession实例,并调用webSocketTask(with:)方法生成任务。这里需要注意,URLSession的delegate队列建议使用串行队列,避免回调并发导致状态混乱。连接成功后调用resume()启动任务,然后通过task的send方法发送消息,receive方法异步等待下一条消息。receive的回调只触发一次,收到消息后必须再次调用receive才能持续接收,这是很多新手容易漏掉的地方。
下面是一个最小可用的连接封装。代码里把WebSocketTask的创建、连接、发送文本消息、接收消息循环都放在了同一个类中,方便后续扩展心跳和重连逻辑。消息类型包括字符串和二进制数据,发送二进制时直接传Data即可。接收时通过switch枚举判断类型,处理完当前消息后立即再次调用receive,形成一个循环。
import Foundation
final class WebSocketClient: NSObject {
private var webSocketTask: URLSessionWebSocketTask?
private let urlSession: URLSession
private let serverURL: URL
init(serverURL: URL) {
self.serverURL = serverURL
let configuration = URLSessionConfiguration.default
configuration.timeoutIntervalForRequest = 30
self.urlSession = URLSession(configuration: configuration,
delegate: nil,
delegateQueue: OperationQueue())
super.init()
}
func connect() {
webSocketTask = urlSession.webSocketTask(with: serverURL)
webSocketTask?.resume()
receiveMessage()
}
func send(text: String) {
let message = URLSessionWebSocketTask.Message.string(text)
webSocketTask?.send(message) { [weak self] error in
if let error = error {
print("发送失败: \(error.localizedDescription)")
}
}
}
func send(data: Data) {
let message = URLSessionWebSocketTask.Message.data(data)
webSocketTask?.send(message) { error in
if let error = error {
print("二进制发送失败: \(error.localizedDescription)")
}
}
}
private func receiveMessage() {
webSocketTask?.receive { [weak self] result in
guard let self = self else { return }
switch result {
case .success(let message):
switch message {
case .string(let text):
print("收到文本: \(text)")
case .data(let data):
print("收到二进制,长度: \(data.count)")
@unknown default:
break
}
// 继续监听下一条消息
self.receiveMessage()
case .failure(let error):
print("接收失败: \(error.localizedDescription)")
// 触发重连逻辑,实际项目中在这里调用重连方法
}
}
}
func disconnect() {
webSocketTask?.cancel(with: .goingAway, reason: nil)
webSocketTask = nil
}
}
上面的代码只覆盖了正常连接时的收发,一旦网络断开或服务端关闭连接,receive会返回failure。此时如果不做任何处理,连接就死掉了。而且很多情况下,连接断开并不会立刻触发failure,可能是一条消息都发不出去,但send方法一直不报错,直到TCP超时。这就要求客户端必须主动探测连接是否还活着,心跳检测就是为了解决这个问题。
心跳检测机制的设计与实现
WebSocket协议本身支持Ping和Pong控制帧,服务端通常会在收到Ping后回复Pong。URLSessionWebSocketTask提供了sendPing方法,可以显式发送一次Ping。客户端可以定时发送Ping,并等待Pong回调。如果没有在指定时间内收到Pong,就认为连接已经不可用,主动断开并触发重连。这比单纯依赖receive的failure要灵敏得多。
实现心跳时需要注意两点:一是定时器必须独立于主线程,避免界面卡顿影响心跳准确性;二是要处理发送Ping失败的情况,比如连接已经进入关闭状态时sendPing会抛出错误,此时也应视为连接失效。下面代码在原有封装基础上增加了heartbeatTimer和pingTimeoutTimer两个定时器:前者周期性地发起Ping,后者在发送Ping后启动,如果在超时时间内没有收到Pong回调,则判定连接失效。
import Foundation
final class HeartbeatWebSocketClient: NSObject {
private var webSocketTask: URLSessionWebSocketTask?
private let urlSession: URLSession
private let serverURL: URL
private var heartbeatTimer: Timer?
private var pingTimeoutTimer: Timer?
private let heartbeatInterval: TimeInterval = 15
private let pingTimeout: TimeInterval = 5
private var isConnected = false
init(serverURL: URL) {
self.serverURL = serverURL
let configuration = URLSessionConfiguration.default
configuration.timeoutIntervalForRequest = 30
self.urlSession = URLSession(configuration: configuration,
delegate: nil,
delegateQueue: OperationQueue())
super.init()
}
func connect() {
webSocketTask = urlSession.webSocketTask(with: serverURL)
webSocketTask?.resume()
isConnected = true
receiveMessage()
startHeartbeat()
}
private func startHeartbeat() {
stopHeartbeat()
let timer = Timer(timeInterval: heartbeatInterval, repeats: true) { [weak self] _ in
self?.sendPing()
}
RunLoop.main.add(timer, forMode: .common)
heartbeatTimer = timer
}
private func sendPing() {
guard isConnected else { return }
webSocketTask?.sendPing { [weak self] error in
if let error = error {
print("Ping发送失败: \(error.localizedDescription)")
self?.handleConnectionFailure()
return
}
// Ping发送成功,启动Pong超时定时器
self?.startPingTimeoutTimer()
}
}
private func startPingTimeoutTimer() {
pingTimeoutTimer?.invalidate()
let timer = Timer(timeInterval: pingTimeout, repeats: false) { [weak self] _ in
print("Pong超时,判定连接失效")
self?.handleConnectionFailure()
}
RunLoop.main.add(timer, forMode: .common)
pingTimeoutTimer = timer
}
private func handleConnectionFailure() {
isConnected = false
stopHeartbeat()
pingTimeoutTimer?.invalidate()
webSocketTask?.cancel(with: .abnormalClosure, reason: nil)
webSocketTask = nil
// 触发重连,本文下一节会完善重连策略
scheduleReconnect()
}
private func receiveMessage() {
webSocketTask?.receive { [weak self] result in
guard let self = self else { return }
switch result {
case .success(let message):
// 收到任何消息都说明连接活跃,如果之前有Pong超时定时器,这里可以取消
self.pingTimeoutTimer?.invalidate()
self.pingTimeoutTimer = nil
switch message {
case .string(let text):
print("收到文本: \(text)")
case .data(let data):
print("收到二进制,长度: \(data.count)")
@unknown default:
break
}
self.receiveMessage()
case .failure(let error):
print("接收失败: \(error.localizedDescription)")
self.handleConnectionFailure()
}
}
}
private func scheduleReconnect() {
// 占位,下一节实现
}
private func stopHeartbeat() {
heartbeatTimer?.invalidate()
heartbeatTimer = nil
}
func disconnect() {
isConnected = false
stopHeartbeat()
pingTimeoutTimer?.invalidate()
webSocketTask?.cancel(with: .goingAway, reason: nil)
webSocketTask = nil
}
}
上面代码中,sendPing的回调只代表系统成功调用了发送Ping的操作,并不代表服务端已经回复了Pong。如果连接底层已经断开,sendPing可能会返回错误,此时直接判定失败。如果sendPing成功,则启动一个5秒的超时定时器,一旦定时器触发说明Pong没有及时回来。另外在receiveMessage的success分支中,只要收到任意消息就取消Pong超时定时器,因为消息到达证明连接是通的,这样能避免误判。
断线重连策略:指数退避与随机抖动
客户端检测到连接失效后,不能立刻无脑重连。如果服务端因为瞬时故障重启,大量客户端同时重连会造成类似“惊群”的压力,可能再次压垮服务端。更合理的做法是使用指数退避:每次重连失败后,等待时间成倍增加,例如1秒、2秒、4秒、8秒,并设置一个最大等待时间(比如60秒)。同时加入随机抖动,让每个客户端的等待时间在基础值上下浮动,避免所有客户端在同一时刻发起连接。
另一个容易被忽略的点是网络环境变化。移动设备经常在Wi-Fi和蜂窝网络之间切换,切换过程中IP地址会改变,旧连接必然失效。如果只在receive失败或Ping超时时才触发重连,可能在网络切换后等待较长时间才发现。更好的做法是监听网络状态变化事件,在网络从不可用变为可用时,立即尝试重连,这样可以大幅缩短断线时间。iOS中可以使用NWPathMonitor来监听网络状态,但要注意它只告诉你网络是否可用,并不代表之前的WebSocket连接已经恢复,所以仍然需要重新建立连接。
下面给出完整的重连实现。通过一个DispatchWorkItem延迟执行重连任务,每次失败后基于重连次数计算等待时间,并使用arc4random_uniform加入随机因子。同时监听网络状态,当网络从不可用变为可用时,重置重连次数并立即连接。需要特别注意的是,所有对连接状态的修改都要保证线程安全,这里使用了一个串行队列来同步状态变更。
import Foundation
import Network
final class ReconnectingWebSocketClient: NSObject {
private var webSocketTask: URLSessionWebSocketTask?
private let urlSession: URLSession
private let serverURL: URL
private var heartbeatTimer: Timer?
private var pingTimeoutTimer: Timer?
private let heartbeatInterval: TimeInterval = 15
private let pingTimeout: TimeInterval = 5
private var isConnected = false
private var reconnectAttempts = 0
private var reconnectWorkItem: DispatchWorkItem?
private let syncQueue = DispatchQueue(label: "com.websocket.sync")
private let networkMonitor = NWPathMonitor()
private var wasNetworkAvailable = true
init(serverURL: URL) {
self.serverURL = serverURL
let configuration = URLSessionConfiguration.default
configuration.timeoutIntervalForRequest = 30
self.urlSession = URLSession(configuration: configuration,
delegate: nil,
delegateQueue: OperationQueue())
super.init()
setupNetworkMonitor()
}
private func setupNetworkMonitor() {
networkMonitor.pathUpdateHandler = { [weak self] path in
let isAvailable = path.status == .satisfied
guard let self = self else { return }
self.syncQueue.async {
let wasAvailable = self.wasNetworkAvailable
self.wasNetworkAvailable = isAvailable
if isAvailable && !wasAvailable && !self.isConnected {
// 网络恢复可用,立即重连
self.reconnectAttempts = 0
self.scheduleReconnect(delay: 0.5)
}
}
}
networkMonitor.start(queue: DispatchQueue.global())
}
func connect() {
webSocketTask = urlSession.webSocketTask(with: serverURL)
webSocketTask?.resume()
isConnected = true
reconnectAttempts = 0
receiveMessage()
startHeartbeat()
}
private func handleConnectionFailure() {
syncQueue.async {
guard self.isConnected else { return }
self.isConnected = false
self.stopHeartbeat()
self.pingTimeoutTimer?.invalidate()
self.pingTimeoutTimer = nil
self.webSocketTask?.cancel(with: .abnormalClosure, reason: nil)
self.webSocketTask = nil
self.scheduleReconnect(delay: self.nextReconnectDelay())
}
}
private func nextReconnectDelay() -> TimeInterval {
let baseDelay: TimeInterval = 1.0
let maxDelay: TimeInterval = 60.0
let exponential = min(maxDelay, baseDelay * pow(2.0, Double(reconnectAttempts)))
reconnectAttempts += 1
// 加入随机抖动,范围是基础值的50%到100%
let jitter = Double(arc4random_uniform(50)) / 100.0
return exponential * (1.0 + jitter)
}
private func scheduleReconnect(delay: TimeInterval) {
reconnectWorkItem?.cancel()
let workItem = DispatchWorkItem { [weak self] in
self?.connect()
}
reconnectWorkItem = workItem
DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: workItem)
}
private func startHeartbeat() {
stopHeartbeat()
let timer = Timer(timeInterval: heartbeatInterval, repeats: true) { [weak self] _ in
self?.sendPing()
}
RunLoop.main.add(timer, forMode: .common)
heartbeatTimer = timer
}
private func sendPing() {
guard isConnected else { return }
webSocketTask?.sendPing { [weak self] error in
if let error = error {
print("Ping发送失败: \(error.localizedDescription)")
self?.handleConnectionFailure()
return
}
self?.startPingTimeoutTimer()
}
}
private func startPingTimeoutTimer() {
pingTimeoutTimer?.invalidate()
let timer = Timer(timeInterval: pingTimeout, repeats: false) { [weak self] _ in
print("Pong超时,判定连接失效")
self?.handleConnectionFailure()
}
RunLoop.main.add(timer, forMode: .common)
pingTimeoutTimer = timer
}
private func receiveMessage() {
webSocketTask?.receive { [weak self] result in
guard let self = self else { return }
switch result {
case .success(let message):
self.pingTimeoutTimer?.invalidate()
self.pingTimeoutTimer = nil
switch message {
case .string(let text):
print("收到文本: \(text)")
case .data(let data):
print("收到二进制,长度: \(data.count)")
@unknown default:
break
}
self.receiveMessage()
case .failure(let error):
print("接收失败: \(error.localizedDescription)")
self.handleConnectionFailure()
}
}
}
private func stopHeartbeat() {
heartbeatTimer?.invalidate()
heartbeatTimer = nil
}
func disconnect() {
syncQueue.async {
self.isConnected = false
self.stopHeartbeat()
self.pingTimeoutTimer?.invalidate()
self.reconnectWorkItem?.cancel()
self.reconnectWorkItem = nil
self.webSocketTask?.cancel(with: .goingAway, reason: nil)
self.webSocketTask = nil
}
}
}
这段代码中,nextReconnectDelay计算指数退避并累加重连次数,这样每次失败后等待时间翻倍。随机抖动部分使用arc4random_uniform生成0到49的随机数,转换为0到0.49的系数,最终等待时间在基础值的1.0到1.49倍之间浮动。网络监听使用了NWPathMonitor,当网络从不可用变为可用时,重置重连次数并快速发起连接。需要注意的是,所有对isConnected和webSocketTask的修改都放在syncQueue串行队列里执行,防止多个线程同时修改状态导致竞态。
前后台切换与连接生命周期的配合
iOS应用进入后台后,系统会冻结大部分执行操作,定时器也可能被暂停。如果App在后台还保持WebSocket连接,心跳定时器可能不会按时触发,导致误判连接失效。更合适的做法是:进入后台时主动断开连接,或者暂停心跳但保持连接,回到前台时判断连接状态,如果失效则立即重连。对于需要后台持续接收推送的业务,建议使用APNs等系统推送通道,而不是依赖WebSocket在后台存活,因为系统对后台网络访问有严格限制。
实现前后台处理并不复杂,在AppDelegate或者SceneDelegate中监听UIApplication.didEnterBackgroundNotification和willEnterForegroundNotification。进入后台时调用disconnect()释放资源,回到前台时调用connect()重新建立连接。这种做法牺牲了后台的实时推送能力,但换来了更稳定的前台体验和更低的电量消耗。如果业务要求后台也能接收WebSocket消息,可以考虑申请后台任务,但实际效果依赖系统调度,可靠性不高。
import UIKit
extension ReconnectingWebSocketClient {
func observeAppLifecycle() {
NotificationCenter.default.addObserver(self,
selector: #selector(appDidEnterBackground),
name: UIApplication.didEnterBackgroundNotification,
object: nil)
NotificationCenter.default.addObserver(self,
selector: #selector(appWillEnterForeground),
name: UIApplication.willEnterForegroundNotification,
object: nil)
}
@objc private func appDidEnterBackground() {
// 进入后台主动断开,释放资源
disconnect()
}
@objc private func appWillEnterForeground() {
// 回到前台重新连接
connect()
}
}
经过以上几个步骤,一个具备心跳检测、Pong超时判定、指数退避重连和前后台处理的WebSocket客户端就基本完成了。实际项目中还需要根据业务需求处理消息分发、序列化、重连后的数据补拉等细节,但核心的长连接稳定性问题已经解决。使用URLSessionWebSocketTask而非第三方库的优势在于系统级支持和更好的网络栈集成,缺点是无法像Starscream等库那样提供丰富的回调事件,需要自己封装一层,但整体可控性更高。
URLSessionWebSocketTaskWebSocket长连接心跳检测修改时间:2026-09-29 20:57:36