Reduce networking delays for a more responsive app
System & Services 进阶 20m

减少网络延迟,打造更流畅的应用

Reduce networking delays for a more responsive app

2022年6月6日

在 Apple 官方观看视频

一句话判断

网络优化不是加个缓存就完事的——Apple 在这场 Session 里把从 DNS 解析到 TLS 握手的每一步延迟都拆开讲了,结论是大多数 app 的网络层还有很大的优化空间。

这场 Session 讲了什么

这场 Session 聚焦于网络延迟的精细化优化。Apple 从一个完整的网络请求生命周期出发,逐步拆解每个阶段的耗时:DNS 查询、TCP 连接、TLS 握手、请求发送、响应接收。每个阶段都有对应的优化手段,而大多数开发者只关注了最后两个阶段。

URLSession 在 iOS 16 中获得了多项改进。URLSessionTaskmetrics 属性提供了更细粒度的网络计时信息,你可以精确看到 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 包含 fetchStartDatedomainLookupStartDateconnectStartDatesecureConnectionStartDaterequestStartDateresponseStartDateresponseEndDate 等独立时间戳。你只需要实现 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 现在支持 expirescache-control 头部字段的更精确解析,缓存命中率会有提升。
  • NWConnectionnewConnectionGroup API 简化了 WebSocket 连接池的管理。
  • NWEstimate 类型提供了基于历史数据的网络质量预测,可以用来做预加载决策。
WWDC 2022