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 成功不能证明服务器已经执行了“提高数据速率”等命令;如果需要确认,应读取服务器定义的响应。
备注:内容仅供参考。
