减少网络延迟,打造更流畅的应用
Reduce networking delays for a more responsive app
2022年6月6日
一句话判断
网络优化不是加个缓存就完事的——Apple 在这场 Session 里把从 DNS 解析到 TLS 握手的每一步延迟都拆开讲了,结论是大多数 app 的网络层还有很大的优化空间。
这场 Session 讲了什么
这场 Session 聚焦于网络延迟的精细化优化。Apple 从一个完整的网络请求生命周期出发,逐步拆解每个阶段的耗时:DNS 查询、TCP 连接、TLS 握手、请求发送、响应接收。每个阶段都有对应的优化手段,而大多数开发者只关注了最后两个阶段。
URLSession 在 iOS 16 中获得了多项改进。URLSessionTask 的 metrics 属性提供了更细粒度的网络计时信息,你可以精确看到 DNS 解析花了多久、TLS 握手消耗了多少毫秒。这些数据不再需要用 CFNetwork 的底层 API 才能拿到。URLSessionConfiguration 新增了 wsMaximumMessageSize 用于 WebSocket 的消息大小控制,以及改进的 timeoutIntervalForResource 行为。
NWConnection(Network.framework)方面,Apple 强调了 waitUntilConnectedBeforeSending 模式的重要性。很多 app 的做法是先建立连接、等连接就绪、然后才发送数据——这个过程串行执行,白白浪费了一个 RTT。正确的方式是用 send() 配合连接建立的回调并行处理,数据在连接就绪的瞬间立刻发出。另外,NWPathMonitor 的更新频率在新版本中有所提高,在网络切换(Wi-Fi 到蜂窝、反之)时能更快感知变化。
值得深挖的点
网络请求计时:从 NSURLSessionTaskMetrics 获取真相
很多开发者用 Date() 打点来测量网络请求耗时,这种方式把 DNS、TCP、TLS 的时间全混在一起,根本无法定位瓶颈。URLSessionTaskMetrics 提供了 transactionMetrics 数组,每个 URLSessionTaskTransactionMetrics 包含 fetchStartDate、domainLookupStartDate、connectStartDate、secureConnectionStartDate、requestStartDate、responseStartDate、responseEndDate 等独立时间戳。你只需要实现 urlSession(_:task:didCompleteWithError:),从 task.metrics 读取这些数据,就能知道瓶颈到底在 DNS(可能是本地缓存未命中)、TLS(可能是证书链太长)、还是服务端处理慢。iOS 16 的改进让这些 metrics 在更多场景下可用,包括重定向请求。
连接复用和 Early Data
TLS 1.3 的 Early Data(0-RTT)是一个被严重低估的优化手段。当客户端和服务器之间已经建立过 TLS 会话后,下一次连接时可以在握手的同时发送请求数据。NWConnection 通过 allowFastOpen 参数支持这个特性。对于频繁发起短请求的场景(比如消息同步、状态上报),Early Data 可以省掉一个完整的 RTT。需要注意的是,Early Data 有重放攻击的风险,不要用它在第一次请求中发送非幂等的操作(比如支付请求)。
代码片段
从 URLSessionTaskMetrics 提取各阶段耗时
func urlSession(_ session: URLSession, task: URLSessionTask, didFinishCollecting metrics: URLSessionTaskMetrics) {
for transaction in metrics.transactionMetrics {
guard let fetchStart = transaction.fetchStartDate,
let responseEnd = transaction.responseEndDate else { continue }
// 计算各阶段耗时
let dnsTime: TimeInterval?
if let dnsStart = transaction.domainLookupStartDate,
let dnsEnd = transaction.domainLookupEndDate {
dnsTime = dnsEnd.timeIntervalSince(dnsStart) * 1000
}
let tlsTime: TimeInterval?
if let tlsStart = transaction.secureConnectionStartDate,
let tlsEnd = transaction.secureConnectionEndDate {
tlsTime = tlsEnd.timeIntervalSince(tlsStart) * 1000
}
print("DNS: \(dnsTime ?? 0)ms, TLS: \(tlsTime ?? 0)ms")
print("总耗时: \(responseEnd.timeIntervalSince(fetchStart) * 1000)ms")
}
}
NWConnection 并行建立连接和发送数据
import Network
let connection = NWConnection(
to: NWEndpoint.host("api.example.com", .init(rawValue: 443)!),
using: .tls
)
// 使用 send 配合连接建立,减少一个 RTT
connection.start(queue: .global())
let requestData = "GET /api/status HTTP/1.1\r\nHost: api.example.com\r\n\r\n".data(using: .utf8)!
connection.send(
content: requestData,
completion: .contentProcessed { error in
if let error = error {
print("发送失败: \(error)")
}
}
)
// 接收响应
connection.receive(minimumIncompleteLength: 1, maximumLength: 65536) { data, context, isComplete, error in
guard let data = data else { return }
// 处理响应数据
print("收到 \(data.count) 字节")
}
使用 NWPathMonitor 感知网络变化
import Network
let monitor = NWPathMonitor()
monitor.pathUpdateHandler = { path in
if path.status == .satisfied {
// 网络可用——检查连接类型
if path.usesInterfaceType(.wifi) {
print("Wi-Fi 连接,可以拉取大文件")
} else if path.usesInterfaceType(.cellular) {
print("蜂窝网络,建议延迟大文件下载")
}
} else {
print("网络不可用,切换到离线模式")
}
// iOS 16: path.availableInterfaces 提供更详细的信息
print("可用接口: \(path.availableInterfaces)")
}
monitor.start(queue: DispatchQueue(label: "network.monitor"))
最佳实践
先用 URLSessionTaskMetrics 量化你的网络请求各阶段耗时,再决定优化方向。如果 DNS 解析耗时长,考虑在本地缓存解析结果或使用 NWConnection 的内置 DNS 缓存。如果 TLS 握手慢,确认服务器支持 TLS 1.3 并启用了 session resumption。如果服务端响应慢,那不是客户端能解决的问题——去找后端团队。
连接复用是最简单有效的优化。URLSession 默认会复用 TCP 连接,但前提是你用的是同一个 URLSession 实例。每次请求都 URLSession(configuration: .default) 创建新实例的做法会白白浪费连接复用的机会。正确做法是把 URLSession 作为单例持有。
对延迟敏感的请求(比如搜索建议、实时数据),用 HTTP/2 的多路复用比 HTTP/1.1 的连接池更高效。Apple 的 URLSession 对 HTTP/2 的支持是透明的,你只需要确保服务器开启了 HTTP/2。
还有什么值得关注
URLSession现在支持expires和cache-control头部字段的更精确解析,缓存命中率会有提升。NWConnection的newConnectionGroupAPI 简化了 WebSocket 连接池的管理。NWEstimate类型提供了基于历史数据的网络质量预测,可以用来做预加载决策。