PAYATHON 2026

SwiftUI 如何通过 TCP/IP 持续接收数据,避免定时器轮询?

数据小许

结论

无需使用定时器或预先检查是否有数据可读。NWConnection.receive(...) 是异步接收调用:提交请求后立即返回,满足接收条件或出现结束、错误等情况时再执行回调。

连接进入 .ready 后启动一次接收。每次回调处理完数据后,只要连接尚未结束且没有错误,就提交下一次接收请求,持续读取数据。

原代码为什么需要调整

原代码在 .ready 中调用了 receiveOnce(),随后又通过定时器每秒调用一次。一次 receive(...) 对应一次完成回调,并不会自动持续接收。如果服务器暂时没有发送数据,之前提交的接收请求可能仍在等待,定时器却会继续提交新的请求。

定时器并不表示数据已经就绪,这里的接收流程也不需要它。应由上一次接收的完成回调安排下一次请求。

另外,原代码还有两个问题:

  • connection.start(queue: queue) 使用后台队列,原代码却直接在网络回调中修改界面读取的属性。@Observable 提供观察能力,不会自动把这些修改切换到主执行器。
  • myTCP 是视图中的普通存储属性。对于由视图持有的 @Observable 模型,使用 @State 可以让对象随视图身份保持稳定,避免视图重新构造时重复创建连接。

修改接收逻辑

下面沿用现有类名和 @Observable 写法,前提是项目支持 Observation。使用 @Observable 通常要求 iOS 17 或更高版本。以下修改需要配合使用。

1. 将界面模型隔离到 MainActor

在文件中显式导入 Observation,给现有类添加 @MainActor:

import SwiftUI
import Network
import Observation

@MainActor
@Observable
class ttcipiptest {
    // 保留现有属性、初始化方法和发送方法,
    // 按下文替换状态回调与接收方法。
}

网络连接仍使用原来的 queue。网络回调通过 Task { @MainActor in ... } 回到模型所在的执行器,更新状态并安排下一次接收。

2. 替换连接状态回调

将初始化方法中的 stateUpdateHandler 替换为:

connection.stateUpdateHandler = { [weak self] newState in
    Task { @MainActor [weak self] in
        guard let self else { return }

        switch newState {
        case .ready:
            self.statusData = "Connected!"
            self.receiveOnce()

        case .failed(let error):
            self.statusData = "Error connecting: \(error)"
            self.connection?.cancel()

        case .waiting(let error):
            self.statusData = "Waiting: \(error)"

        case .cancelled:
            self.statusData = "Connection cancelled"

        default:
            break
        }
    }
}

保留原来的启动语句:

connection.start(queue: queue)

3. 在完成回调中继续接收

用下面的方法替换原来的 receiveOnce():

func receiveOnce() {
    guard let connection else { return }

    connection.receive(
        minimumIncompleteLength: 1,
        maximumLength: 65536
    ) { [weak self] data, _, isComplete, error in
        Task { @MainActor [weak self] in
            guard let self else { return }

            // 先处理本次返回的数据;结束或错误也可能伴随数据返回。
            if let data, !data.isEmpty {
                // 仅用于展示当前数据块,不代表完整应用消息。
                self.receivedData =
                    String(data: data, encoding: .utf8)
                    ?? "<non-utf8>"
            }

            if let error {
                self.statusData = "Error receiving data: \(error)"
                self.connection?.cancel()
                return
            }

            if isComplete {
                self.statusData = "Receive complete"
                return
            }

            // 本次接收完成后,安排下一次异步接收。
            self.receiveOnce()
        }
    }
}

回调中再次调用该方法时,提交的是一个新的异步请求。它不会同步递归读取,也不会在没有数据时忙等。

接收链只从 .ready 启动。删除定时器后,不要从其他位置反复调用 receiveOnce()。如果以后增加多个启动入口,应额外维护“是否已有接收请求等待完成”的状态,避免重复启动。

简化 SwiftUI 视图

删除 timer、time_t、statusText、.onReceive 和用于同步状态的 .onChange,直接观察模型属性:

@MainActor
struct ContentView: View {
    @State private var myTCP = ttcipiptest()

    var body: some View {
        VStack {
            Text(myTCP.receivedData ?? "nothing here")
            Text(myTCP.statusData ?? "nothing")
        }
    }
}

示例保留了原代码初始化时连接的行为。实际应用应根据连接需要持续多久,明确安排启动和取消。如果连接需要跨页面持续存在,可以让生命周期更长的模型持有它。

TCP 数据还需要按协议拆分

maximumLength: 65536 表示一次接收最多返回的字节数,不表示会等满 65536 字节,也不表示一条消息的长度。

TCP 提供有序字节流,没有应用层消息边界。服务器连续发送两条消息时,客户端可能一次收到两条,也可能分多次才收到一条。因此:

  • isComplete 在这里用于判断接收方向是否结束,不能用来判断一条业务消息是否完整。
  • 如果协议使用换行符、固定长度或长度前缀划分消息,需要先把数据追加到缓冲区,再按协议提取完整消息。
  • UTF-8 字符也可能跨两次接收。如果每次直接调用 String(data:encoding:),即使服务器发送的是合法 UTF-8 文本,单个数据块也可能转换失败。

前面的字符串转换只适合观察数据块。原问题没有提供服务器的消息格式,无法确定该用哪种拆包方式;正式实现应按服务器协议缓冲和解析数据。

接收速率很高时,可以在专用执行器上缓冲、解析数据,再按界面需要的频率发布结果,避免每个数据块都触发一次界面刷新。

发送命令不需要停止接收

连接处于 .ready 状态时,可以继续使用现有的 connection.send(content:completion:) 发送命令,同时保持接收链运行。TCP 支持双向传输。

命令的编码、分隔符和确认方式必须遵循服务器协议。.contentProcessed 成功不能证明服务器已经执行了“提高数据速率”等命令;如果需要确认,应读取服务器定义的响应。

备注:内容仅供参考。