2ab37567d68979e18871d64f5a37b45a
网络的发展

本文是Session 712 Advances in Networking, Part 1Session 713 Advances in Networking, Part 2 的读后感,这两篇 session 主要介绍了 iOS 13 中网络相关新特性。

低数据模式

iOS 13 在系统设置中,针对每个 Wi-fi 和蜂窝数据,可以分别设置是否在该网络下开启低数据模式。针对低数据模式,系统会在系统策略和应用适配两方面来降低流量消耗。
低数据模式

低数据模式

系统策略

  • 酌情推迟任务
  • 禁止后台应用刷新

应用适配

在不影响用户体验的情况下始终要节省流量。

  • 降低图片质量
  • 减少预取
  • 降低同步频率
  • 标记任务为可酌情处理
  • 禁止自动播放
  • 不要阻塞用户创建的任务

低数据模式相关 API

iOS 13 在 URLSessionNetwork 中也对应增加了低数据模式相关的 API:

URLSession

URLSession 中,可以通过在 URLSessionConfigurationURLRequest 中分别设置 Session 级别和 Request 级别的配置。

  • 针对数据量大的请求和预取请求,设置 allowsConstrainedNetworkAccess = false(默认值为 true
  • 在请求因error.networkUnavailableReason == .constrained失败时,选择处于低数据模式下时的处理分支

Network.framework

  • NWParameters中设置prohibitConstrainedPaths
  • NWPath中检查isConstrained
  • 处理 path 的更新

受限(Constrained)与昂贵(Expensive)

  • Constrained——开启低数据模式时生效,用户可设置
  • Expensive——连接蜂窝数据或个人热点时生效,系统自动判断

Expensive 相关的属性在 iOS 12 引入的 Network.framework 中就已引入,在 iOS 13 中,URLSession 也有了对应属性。URLSession 中为allowsExpensiveNetworkAccess,在 Network.framework 中则为 NWParameters 中的prohibitExpensivePathsNWPath 中的isExpensive。Constrained 相关属性为用户可设置的,比 Expensive 相关属性更符合用户预期,所以推荐使用 Constrained 相关接口来适配低流量模式。

URLSession 中的 Combine

iOS 13 中官方引入了响应式框架 Combine,通过声明式的 API 进行数据的传递,并且在很多官方框架中都增加了对应接口,URLSession中自然也不例外。关于 Combine 框架的介绍,推荐观看Session 711 Introducing Combine and Advances in FoundationSession 721 Combine in Practice,以及本专栏另一篇文章Apple 官方异步编程框架:Swift Combine 简介
URLSession 中,引入了 DataTaskPublisher(当前的 Demo 版 iOS 13 SDK 还未更新该接口,但在正式版推出后应该会更新),接口定义如下:

public struct DataTaskPublisher: Publisher {
       public typealias Output = (data: Data, response: URLResponse)
       public typealias Failure = URLError
}

DataTaskPublisher支持包括retry在内的所有 Publisher中定义的 Operator,但是在使用retry时,须注意:

  • 重试次数应尽量少
  • 仅在幂等的情况下进行重试

下面是一个通用的网络请求的 Publisher 的代码示例:

// 一个通用的 URL 加载自适应 Publisher
func adaptiveLoader(regularURL: URL, lowDataURL: URL) -> AnyPublisher<Data, Error> {
     var request = URLRequest(url: regularURL)
     request.allowsConstrainedNetworkAccess = false
     return URLSession.shared.dataTaskPublisher(for: request)
         .tryCatch { error -> URLSession.DataTaskPublisher in
              guard error.networkUnavailableReason == .constrained else {
                   throw error
              }
              return URLSession.shared.dataTaskPublisher(for: lowDataURL)
          }
          .tryMap { data, response -> Data in
              guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else {
                  throw MyNetworkingError.invalidServerResponse
              }
              return data
          }
          .eraseToAnyPublisher()
}

WebSocket

WebSocket 是一个基于 TLS/TCP 连接的一个全双工的应用层通信协议,现有的网络技术,包括防火墙、CDN、代理等,都能用于 WebSocket,多用于聊天软件、对战游戏等场景。过去一年苹果网络方面收集到的最多的需求就是提供官方的 WebSocket 支持,因此在 iOS 13 ,苹果基于现有的 Foundation 的 URLSession 和 Network.framework 提供了官方的 WebSocket 接口。

WebSocket 的优势

HTTP1.1长轮询

HTTP1.1长轮询

WebSocket-切换协议

WebSocket-切换协议

WebSocket-双向数据流

WebSocket-双向数据流

对比 HTTP 长轮询,WebSocket 有以下优势:

  • 较少的控制开销。在连接创建后,服务器和客户端之间传输数据时,用于协议控制的数据包头部相对较小。在不包含扩展的情况下,对于服务器到客户端的内容,此头部大小只有 2至 10 字节(和数据包长度有关);对于客户端到服务器的内容,此头部还需要加上额外的4字节的掩码。相对于 HTTP 请求每次都要携带完整的头部,此项开销显著减少了。
  • 更强的实时性。由于协议是全双工的,所以服务器可以随时主动给客户端下发数据。相对于 HTTP 请求需要等待客户端发起请求服务端才能响应,延迟明显更少;即使是和 Comet 等类似的长轮询比较,其也能在短时间内更多次地传递数据。
  • 保持连接状态。与 HTTP 不同的是,WebSocket 需要先创建连接,这就使得其成为一种有状态的协议,之后通信时可以省略部分状态信息。而 HTTP 请求可能需要在每个请求都携带状态信息(如身份认证等)。
  • 更好的二进制支持。WebSocket 定义了二进制帧,相对 HTTP,可以更轻松地处理二进制内容。
  • 可以支持扩展。WebSocket 定义了扩展,用户可以扩展协议、实现部分自定义的子协议。如部分浏览器支持压缩等。
  • 更好的压缩效果。相对于HTTP压缩,WebSocket 在适当的扩展支持下,可以沿用之前内容的上下文,在传递类似的数据时,可以显著地提高压缩率。

URLSession 中的 WebSocket 接口

iOS 13 中,苹果增加了一个 URLSessionTask 的子类 URLWebSocketTask,以及对应的工厂方法和对应协议 URLWebSocketTaskDelegate。新增接口如下:

// URLSession
func webSocketTask(with: URL) -> URLSessionWebSocketTask
func webSocketTask(with: URLRequest) -> URLSessionWebSocketTask
func webSocketTask(with: URL, protocols: [String]) -> URLSessionWebSocketTask

// URLSessionWebSocketTask
var closeCode: URLSessionWebSocketTask.CloseCode
var closeReason: Data?
var maximumMessageSize: Int
func cancel(with closeCode: URLSessionWebSocketTask.CloseCode, 
     reason: Data?)
func receive(completionHandler: (Result<URLSessionWebSocketTask.Message, Error>) -> Void)
func send(URLSessionWebSocketTask.Message, completionHandler: (Error?) -> Void)
func sendPing(pongReceiveHandler: (Error?) -> Void)

// URLSessionWebSocketTaskDelegate
func urlSession(URLSession, webSocketTask: URLSessionWebSocketTask, didCloseWith: URLSessionWebSocketTask.CloseCode, reason: Data?)
func urlSession(URLSession, webSocketTask: URLSessionWebSocketTask, didOpenWithProtocol: String?)

下面是 URLSession 中创建 webSocketTask 的代码示例:

// 使用 URL 创建 Task
let task = URLSession.shared.webSocketTask(with: URL(string: "wss://websocket.example")!)
task.resume()

// 发送消息
task.send(.string("Hello")) { error in /* 错误处理的代码 */}

// 接收消息
task.receive { result in /* 消息处理的代码 */}

在 iOS 13 之前,客户端 Native 开发需要使用 WebSocket 协议时,使用最广泛的第三方库应该是 Facebook 的 SocketRocket,对比两个框架,用法上大同小异,但目前使用 SocketRocket 发现还是存在一些缺陷,且这个第三方库维护较少,现在既然官方提供了支持,在 iOS 13 以上,还是推荐使用官方接口,感兴趣的读者可以下载 SocketRocket 看看。

Network framework 中的 WebSocket 接口

Network.framework 中同样添加了 WebSocket 相关接口,并提供了更细粒度的支持。比如搭建服务端、或者读取部分消息等。下面是代码示例:

// 基于 TLS 连接创建 WebSocket 的参数
let parameters = NWParameters.tls
let websocketOptions = NWProtocolWebSocket.Options()
parameters.defaultProtocolStack.applicationProtocols.insert(websocketOptions, at: 0)

// 使用这些参数创建连接
let websocketConnection = NWConnection(to: endpoint, using: parameters)

// 使用这些参数创建监听
let websocketListener = try NWListener(using: parameters)

iOS 中的WebSocket API

WebSocket APIs

WebSocket APIs

现在在 iOS 中,可以通过 WebKit 中的 JavaScript WebSocket 接口、URLSession 中的 URLSessionWebSocketTask、Network.framework 中的 WebSocket Connection 和 WebSocket Listener 三种方式使用 WebSocket。

移动性改进

切网时的体验一直是移动应用网络场景优化中的一个重点也是难点。在 iOS 13 之前,苹果就一直在这方面进行努力,从 iOS 7 中,Siri 就已经支持 Mutipath TCP 了,在 Mutipath TCP 下,客户端和服务端之间会在 Wi-fi 和 蜂窝网络下创建两条 TCP 连接,通过两条 TCP 连接的竞争和并用对网络的可用性达到最大程度的保证。到 iOS 9 ,苹果增加了 Wi-fi 助理,在 Wi-fi 信号弱时,能自动切到蜂窝网络。iOS 11,苹果公开了 Multipath 的 API。开发者可以直接通过设置 URLSessionConfigurationmultipathServiceType 属性来使客户端支持 Multipath TCP,并参考 https://multipath-tcp.org 进行服务端配置。
Mobility up to iOS 12

Mobility up to iOS 12

在 iOS 13 中,苹果针对移动性改进进行了系统级全方位的优化。Wi-fi 助理会跨层全方位地采集和探测数据来判断当前 Wi-fi 的可用性,为底层提供更准确和实时的数据来判断是否需要切换到蜂窝数据。
Wi-fi Assist in iOS 13

Wi-fi Assist in iOS 13

开发者要用到苹果提供的 Wi-fi 助理优化,只需要:

  • 使用 URLSession 或 Network.framework 中提供的高级 API
  • 因为预判网络状态并不可靠,所以请重新考虑 SCNetworkReachability 的用法
  • 使用 allowsExpensiveNetworkAccess = false 控制访问

在 iOS 13 中,除了 Siri,地图和音乐也支持 Multipath TCP 了,在使用地图应用和听音乐时,能有更流畅的体验。

Bonjour

Bonjour 是一个苹果开发的用于局域网中,在不建立连接、也不需要 ip 或 host 的情况下寻找服务的方法,主要用于寻找打印机和文件共享服务器等。目前 Bonjour 支持所有主流平台,除了苹果的全平台外,还支持 Android、ChromeOS、Linux 和 Windows 10。
Bonjour Support

Bonjour Support

iOS 13 之前,Bonjour 只能用于同一子网下的服务。而在 iOS 13,即使不在同一个子网下,也可以通过位于同一子网下的代理来发现服务。
Bonjour in Network.framework
Bonjour in Network.framework

下面是示例代码:

// 创建一个在所有域中查找 "_myapp._tcp"服务的浏览器
let browser = NWBrowser(for: .bonjour(type: "_myapp._tcp", domain: nil), using: NWParameters())
browser.browseResultsChangedHandler = { browseResults, _ in
    for browseResult in browseResults {
        print("Discovered \(browseResult.endpoint) over \(browseResult.interfaces)")
    }
}

// 开始浏览
broswer.start(queue: myQueue)

帧协议

在 iOS 13 中,除了新增了 WebSocket 协议,我们还可以通过 Network.framework 来自定义帧协议

我们在应用层从传输层读取消息时,每一个包都需要分别读取和解析,当协议比较复杂时,这样做很没有效率。
逐包读取和解析

逐包读取和解析

我们也可以一次性读取尽可能大的一个固定大小的包,但这样会导致同一个包割裂,重新组包依然很影响效率。
固定大小读取和解析

固定大小读取和解析

iOS 13 中,引入了帧协议,定义传输层上的帧协议,用帧协议封装或编码消息,应用层直接逐帧接收消息。
逐帧接收消息

逐帧接收消息

定义帧协议,我们只需要两步:

  • 实现一个可复用的帧协议
  • 将帧协议添加到连接中

实现帧协议

  • 定义一个类,实现 NWProtocolFramerImplementation
    • handleOutput() 中封装消息
    • handleInput() 中将字节解析为消息
  • 选择性地在 NWProtocolFramer.Message 储存元数据

下面是定义帧协议的示例代码:
``` Swift
// 定义一个帧协议
class MyProtocol: NWProtocolFramerImplementation {

static let definition = NWProtocolFramer.Definition(implementation: MyProtocol.self)

required init(framer: NWProtocolFramer.Instance) { }
func start(framer: NWProtocolFramer.Instance) -> NWProtocolFramer.StartResult {
    return .ready
}
func wakeup(framer: NWProtocolFramer.Instance) { }
func stop(framer: NWProtocolFramer.Instance) -> Bool { return true }
func cleanup(framer: NWProtocolFramer.Instance) { }

// 前面有头部 的 output handler
 func handleOutput(framer: NWProtocolFramer.Instance, message: NWProtocolFramer.Message, messageLength: Int, isComplete: Bool) { 
    // 初始化并写入 header
    var header: MyHeader = MyHeader(...)
    framer.writeOutput(data: header.serializedData)
top Created with Sketch.