CodeGym /课程 /Swift SELF /URLSession.dataTask 和 completion handler

URLSession.dataTask 和 completion handler

Swift SELF
第 63 级, 课程 1
可用

1. 网络请求的异步性

如果你刚开始学习,最自然的愿望大概会是这样: “我调用 fetchBooks(),它直接返回一个书籍数组,我再继续往下走。” 这个愿望很美好,就像“周五晚上没有 bug 的代码”。但是网络运行得又慢又不可预测:服务器可能 50 ms 就响应,可能要 5 秒,甚至可能根本不响应。 如果把请求做成“同步”的,程序就会一直卡住,等待响应。

所以在 Swift 中(以及几乎所有地方),经典的网络 API 都是这样设计的:你现在发起请求,而结果会在 稍后 通过回调函数,也就是 completion handler 返回。 这就是 callback 模式: “我会回来找你,真的真的。”

为了让这件事不显得像魔法,而是像工程,我们先看一张简单的思维图:

flowchart LR
    A[你的代码] -->|创建任务| B[URLSession]
    B -->|发送到网络| C[互联网和服务器]
    C -->|响应 / 错误| B
    B -->|调用 completion handler| A

dataTask 的签名和 completion handler 参数

这门课里最重要的一行,就是 URLSession.dataTask 的 completion handler 签名。 从学习角度看,它比励志名言更有价值。

在 Swift 中,它大致长这样(从意义上说是简化版,但关键部分完全准确):

// 来自文档/Swift 演进的签名思路:
func dataTask(
    with url: URL,
    completionHandler: @escaping (Data?, URLResponse?, Error?) -> Void
) -> URLSessionDataTask

也就是说,completion handler 会接收 三个值,而且它们每一个都是 Optional:

  • Data? —— 响应体(二进制字节)。可能是 nil。
  • URLResponse? —— 响应元数据。可能是 nil。
  • Error? —— 传输错误(例如没有网络)。可能是 nil。

这里一定不要混淆:HTTP 错误(例如 404) 通常 不是 Error。 它是“成功收到的响应”,只是状态码表示在协议层面“失败”。 而 Error 通常指的是“我们根本没能正常发起请求”。

为了不把这些概念混成一团,你可以通过一个小表格来看:

收到的内容 类型 通俗含义
主体
Data?
“这是字节,你来尝试解释它”
响应
URLResponse?
“这是响应信息(包括状态码,但不一定能直接拿到)”
错误
Error?
“请求没能完成:网络、DNS、超时、TLS 等等”

2. completion handler 里的 @escaping

当你看到 @escaping 时,编译器并不是想破坏你的心情。 它是在保护你不要掉进“闭包会在稍后被调用,而你以为它会立刻执行”的陷阱。

在 Swift 中,闭包默认 不是 escaping。 这意味着:函数保证这个闭包会在 函数内部 被使用(调用),而且是在函数还在执行的时候。 一旦函数结束,闭包就不会再被保存到别处。

但 URLSession 的行为不同。 你调用 dataTask(...) 后,函数会 立刻返回,而 completion handler 会在网络响应之后 稍后 被调用。 也就是说,completion handler “逃到外面去了”,因此它是 escape 的。

先看一个不涉及网络的小例子,借助干净但诚实的代码来理解这个概念。

非 escaping:调用后立即结束

func runNow(_ action: () -> Void) {
    action()
}

runNow {
    print("我现在就执行") // 我现在就执行
}

这里 action 不需要标记为 @escaping, 因为它不会活到 runNow 结束之后。

Escaping:先保存起来,稍后再执行

var pendingActions: [() -> Void] = []

func storeForLater(_ action: @escaping () -> Void) {
    pendingActions.append(action)
}

storeForLater {
    print("我会稍后执行") // (此时什么都不会打印)
}

pendingActions.first?()        // 我会稍后执行

这就是为什么需要 @escaping:我们把闭包 保存 到数组里,并在稍后、也就是 storeForLater 结束之后再调用它。

URLSession 也是这样做的

URLSession 内部大致是在做“把 completion handler 存到某个任务里,等一切完成后再调用”。 因此 completion handler 被标记为 @escaping。 这不是装饰性的属性,而是 API 合同的一部分。

3. resume():任务不会自己启动

和 URLSession 一起使用时,有一个特别常见的坑:你以为“我已经创建了请求,那它就已经发出去了”。其实并没有。

dataTask 会返回一个 URLSessionDataTask 对象。 它只是一个“任务”,但会处于暂停状态,直到你调用 resume()。

我们先做一个最小示例。它可以编译,但有一个重要提醒:在 CLI 应用里(就像我们的 LibraryCLI),程序可能会在网络响应回来之前就结束。这个问题我们会在下一节解决。

import Foundation

guard let url = URL(string: "https://example.com") else {
    fatalError("无效的常量 URL")
}

let task = URLSession.shared.dataTask(with: url) { data, response, error in
    print("回调已调用!")                  // 回调已调用!(如果程序活得够久)
    print("字节数:", data?.count ?? 0)
}

task.resume() // 没有这一行,什么都不会发生。

思维模型很简单:

sequenceDiagram
    participant You as 你的代码
    participant Session as URLSession
    participant Task as URLSessionDataTask
    participant Net as 网络

    You->>Session: dataTask(with:completion:)
    Session-->>You: 返回 Task(还没启动)
    You->>Task: resume()
    Task->>Net: 执行请求
    Net-->>Task: 响应/错误
    Task-->>You: 调用 completion handler

如果你忘了 resume(),那你只是创建了“请求容器”,却没有真正启动它。 这就像买了火车票然后还待在家里。

4. Completion handler:基本检查顺序

在 completion handler 里,很容易写出那种“偶尔能工作”的代码。这是一种危险的可运行性:它比“永远不能工作”更糟,因为它会让你开始怀疑现实。

虽然完整优雅的错误模型我们会在下一课里构建,但现在就必须记住 completion handler 里的基本思考顺序。 我们不一定要做出完美架构,但一定不能把失败原因搞混。

先从骨架开始:

import Foundation

func handleResponse(data: Data?, response: URLResponse?, error: Error?) {
    if let error {
        print("传输错误:", error)
        return
    }

    guard let httpResponse = response as? HTTPURLResponse else {
        print("不是 HTTP 响应")
        return
    }

    print("状态码:", httpResponse.statusCode)

    let bodySize = data?.count ?? 0
    print("主体字节数:", bodySize)
}

这里要立刻注意两件事。

第一,我们先检查 error。如果存在传输错误,讨论状态码就没有意义:因为响应可能根本不存在。

第二,我们把 URLResponse 通过 as? 转成 HTTPURLResponse,而不是用 as!。 因为 as! 的意思是“我发誓它一定是 HTTP”。 在网络代码里,发誓通常和周五晚上的大多数誓言一样,最后都会以“为什么它崩了”收场。

关于状态码,我们现在先只做最简单的事:把它打印出来。 下一课里我们会把它变成一个更整洁的契约(这样调用方就不用靠 print 猜发生了什么)。 但即使现在,你也应该感受到:statusCode 不是“额外选项”,它是响应的一部分。

5. CLI 实战:等待 callback 并实现 PingClient

在 iOS 应用里(或者服务端应用里),通常有一个进程“存活”的环境:event loop 一直在转,callback 也就有机会执行。

而 CLI 程序通常是这样运行的:执行完顶层代码,然后就结束了。所以你可能会看到这样的情况:

  • 请求已经启动,
  • resume() 也调用了,
  • 但 completion handler 还是没有触发,因为进程已经结束了。

为了在 教学上 等到响应(先不做完整架构),可以使用带超时的 DispatchSemaphore。 是的,这已经有点同步化的味道了,但我们把它当作“演示用拐杖”,而不是生活方式。

如何在 CLI 中教学性地等待响应

import Foundation

let semaphore = DispatchSemaphore(value: 0)

guard let url = URL(string: "https://example.com") else {
    fatalError("无效的常量 URL")
}

URLSession.shared.dataTask(with: url) { data, response, error in
    print("收到响应,字节数:", data?.count ?? 0)
    semaphore.signal()
}.resume()

let timeout = DispatchTime.now() + 5
if semaphore.wait(timeout: timeout) == .timedOut {
    print("等待响应超时") // 等待响应超时(如果没赶上)
}

这里的意思非常直接: “最多等 5 秒,如果还没回来,就输出一条清楚的消息。” 我们不是要把它做成理想的网络层,只是确保程序不会瞬间结束。

在 LibraryCLI 中做一个小型嵌入:基于 callbacks 的 PingClient

我们继续发展这个教学用 CLI 项目。现在我们需要一个很小的工具:检查我们是否能发起 HTTP GET 并拿到响应。 这会是我们的“第一步网络操作”,以后它会成为更复杂命令的基础模块。

我们来定义一个 PingClient,它:

  • 接收 URLRequest(这样就能使用上一课里已经构造好的内容),
  • 启动 dataTask,
  • 通过 completion handler 返回结果。

现在还不做“正确的 Result 和 NetworkError”——那会放到下一课。 这里我们练的是 callbacks 的机制,以及 @escaping。

import Foundation

struct PingClient {
    func ping(request: URLRequest, completion: @escaping (Data?, URLResponse?, Error?) -> Void) {
        URLSession.shared.dataTask(with: request) { data, response, error in
            completion(data, response, error)
        }.resume()
    }
}

注意 completion 上的 @escaping。 我们必须加上这个属性,因为在 ping 内部,我们把 completion 继续传递给 URLSession,而它会在稍后调用它。

现在来看一个使用示例(假设在 main.swift 中,写得很简化,而且没有完整的 CLI 参数解析):

import Foundation

let semaphore = DispatchSemaphore(value: 0)

guard let url = URL(string: "https://example.com") else {
    fatalError("无效的常量 URL")
}

var request = URLRequest(url: url)
request.httpMethod = "GET"

let client = PingClient()
client.ping(request: request) { data, response, error in
    if let error {
        print("Ping 失败:", error)
        semaphore.signal()
        return
    }

    let code = (response as? HTTPURLResponse)?.statusCode ?? -1
    print("Ping 状态:", code)                 // 例如:Ping 状态:200
    print("字节数:", data?.count ?? 0)

    semaphore.signal()
}

_ = semaphore.wait(timeout: .now() + 5)

这已经开始有“真实网络代码”的感觉了:

  • 请求被构造出来,
  • 客户端执行它,
  • 结果进入闭包,
  • 程序也等到了结果(不是无限等待)。

没错,这还不是梦想中的架构。但它是诚实而最小的基础:你知道自己在时间线上处于哪里,也明白为什么 @escaping 不是随便来的。

6. 常见错误

错误 1:忘记调用 resume()。
这是最常见的“隐形 bug”:编译器不报错,代码看起来也合理,但请求就是没有发出去。 你脑子里应该有一条规则:dataTask(...) 负责创建任务,而 resume() 负责启动它。 如果没有 resume(),你只是创建了一个对象。

错误 2:以为 dataTask 之后的代码会在“请求完成后”执行。
completion handler 会在稍后被调用,所以 dataTask(...).resume() 之后的代码会立刻执行。 这会带来奇怪的效果:你打印出“完成了”,但请求其实才刚开始。 解决方法只有一个:所有依赖结果的逻辑都放进 callback 里(或者在下一课中放进 Result 合同里)。

错误 3:只检查 data,忽略 error。
有人会写“如果 data 不是 nil,那就是成功”。 但实际上,在出错时 data 可能是 nil,而 error 会包含真正原因。 反过来,在 HTTP 错误时 data 可能存在,但这并不代表“成功结果”。 正确习惯是:先看 error,再看 response(转换为 HTTPURLResponse),然后看状态码,最后再处理 data。

错误 4:把 response 直接写成 as! HTTPURLResponse。
这种写法会把网络不稳定变成程序崩溃。 as! 的含义是“我很确定”。 而在网络里,确定性通常不是奖励,而是新麻烦的来源。 请使用 as?,并处理不能把响应解释为 HTTP 的情况。

错误 5:在 CLI 中没有等到 completion handler,就以为“网络有问题”。
如果你在命令行里测试,程序可能会在响应到来之前就结束。 这不是网络的问题,而是进程生命周期的问题。 对于教学示例,请使用带超时的 semaphore 等待,这样结果能来得及打印,同时应用也不会永远卡住。

1
任务
Swift SELF, 第 63 级, 课程 1
已锁定
第一次网络 ping
第一次网络 ping
1
任务
Swift SELF, 第 63 级, 课程 1
已锁定
事件顺序
事件顺序
1
任务
Swift SELF, 第 63 级, 课程 1
已锁定
Ping 客户端
Ping 客户端
1
任务
Swift SELF, 第 63 级, 课程 1
已锁定
三个并发请求
三个并发请求
评论
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION