Chapter 07

网络请求与异步编程

掌握 async/await、URLSession 网络请求、JSON 解码与错误处理,构建数据驱动的 iOS 应用

7.1 同步 vs 异步编程

理解同步与异步的区别是学习网络编程的第一步。这两种编程模式决定了代码的执行顺序和应用的响应体验。

什么是同步(Synchronous)?

同步意味着代码一行一行顺序执行,当前行未完成时,下一行不会开始。这就像排队买票——必须等前面的人买完,你才能上窗口。

// 同步代码:每行按顺序执行,会阻塞当前线程
func syncExample() {
    print("第1步:开始")
    let result = heavyComputation()  // 假设这需要5秒,期间UI会卡死
    print("第2步:\(result)")          // 必须等第1步完成才执行
    print("第3步:结束")
}
⚠️
同步网络请求的危害

如果在主线程(UI线程)执行同步网络请求,应用会完全冻结——用户无法滑动、点击任何内容,直到请求完成。这是 iOS 开发中严禁的做法,系统甚至会直接杀死卡死超过一定时间的应用。

什么是异步(Asynchronous)?

异步意味着代码可以发起一个耗时操作,然后继续执行其他代码,等操作完成后再处理结果。就像点外卖——下单后不必站在门口等待,可以做其他事,快递到了再去取。

对比维度同步异步
执行方式顺序阻塞执行发起后立即返回,结果稍后回调
线程影响阻塞当前线程不阻塞当前线程
UI 体验界面卡死界面保持响应
代码复杂度简单直线需要处理回调/async-await
适用场景纯计算、本地操作网络请求、文件I/O、数据库

回调地狱(Callback Hell)

在 async/await 出现之前,异步代码通过回调函数(Completion Handler)来处理结果。当操作之间有依赖关系时,代码会不断嵌套,变得极难维护,这就是"回调地狱":

// 旧式回调风格 —— 层层嵌套,难以阅读和维护
fetchUser(id: 1) { user in
    fetchPosts(for: user) { posts in
        fetchComments(for: posts.first!) { comments in
            fetchLikes(for: comments.first!) { likes in
                // 终于到了真正的逻辑,但代码已经缩进了4层
                print(likes)
            }
        }
    }
}

7.2 async/await — 现代异步编程

Swift 5.5(iOS 15+)引入了 async/await,让异步代码可以像同步代码一样线性书写,彻底解决了回调地狱问题。

async 函数 在函数声明中加 async 关键字,表示该函数是异步的,内部可能包含耗时操作,调用者必须用 await 等待。
await 在调用 async 函数前加 await,表示"在此暂停当前任务,等待结果返回,但不阻塞线程"——其他任务可以趁此机会在同一线程上执行。
挂起点(Suspension Point) 每个 await 都是一个挂起点。Swift 运行时在此处"暂停"当前任务(保存执行上下文),线程得以执行其他工作,结果就绪时再"恢复"执行。
// 声明一个 async 函数
func fetchUserName(id: Int) async throws -> String {
    // async: 标识这是异步函数
    // throws: 标识可能抛出错误
    let url = URL(string: "https://api.example.com/users/\(id)")!
    let (data, _) = try await URLSession.shared.data(from: url)
    // ↑ try: 处理可能的错误;await: 等待网络响应(不阻塞线程)
    let user = try JSONDecoder().decode(User.self, from: data)
    return user.name
}

// 与旧版回调对比:async/await 线性书写,逻辑清晰
func loadData() async throws {
    let user = try await fetchUser(id: 1)       // 暂停1:等待用户数据
    let posts = try await fetchPosts(for: user) // 暂停2:等待文章列表
    let comments = try await fetchComments(for: posts.first!)
    print("加载完成,评论数:\(comments.count)")
    // 无嵌套,逻辑一目了然
}
ℹ️
await 不等于阻塞

很多人误以为 await 会让线程等待(阻塞)。实际上,await 只是"挂起当前任务",线程本身是自由的,可以继续处理其他任务。当异步操作完成时,Swift 会在适当的时候"恢复"被挂起的任务。

7.3 Task {} — 在 SwiftUI 中启动异步任务

SwiftUI 视图的 body 是同步环境,不能直接调用 async 函数。Task {} 是连接同步世界与异步世界的桥梁。

Task Swift Concurrency 中异步工作的基本单位,类似于一个"轻量级线程"。Task { } 创建一个新的异步任务,其中可以使用 await
结构化并发(Structured Concurrency) Swift 的并发模型保证子任务的生命周期不超过父任务,避免了"野火式"的后台任务泄漏问题。
struct ProfileView: View {
    @State private var username = ""

    var body: some View {
        Text(username)
            .onAppear {
                // onAppear 是同步环境,不能直接 await
                // 必须用 Task {} 创建异步上下文
                Task {
                    do {
                        username = try await fetchUserName(id: 1)
                    } catch {
                        username = "加载失败"
                    }
                }
            }
    }
}

// Task 的优先级控制
Task(priority: .userInitiated) {
    // .userInitiated: 用户主动触发,高优先级
    // .background: 后台任务,低优先级
    // .utility: 进度条等中等优先级任务
    await doSomething()
}

// 取消任务
let task = Task {
    await longRunningOperation()
}
task.cancel()  // 发送取消信号,任务内部需检查 Task.isCancelled

7.4 URLSession 发起网络请求

URLSession 是 iOS 中进行网络通信的标准框架,几乎所有的 HTTP/HTTPS 请求都通过它来完成。

URLSession.shared 系统提供的共享单例会话,适合大多数普通网络请求,无需额外配置。
URLRequest 封装了一次 HTTP 请求的所有信息:URL、HTTP 方法(GET/POST等)、请求头、请求体等。
URLResponse / HTTPURLResponse 服务器的响应信息,包括 HTTP 状态码(200成功,404未找到,500服务器错误等)、响应头等。
// 最简单的 GET 请求
func fetchJSON() async throws -> Data {
    let url = URL(string: "https://api.example.com/data")!

    // data(from:) 返回 (Data, URLResponse) 元组
    let (data, response) = try await URLSession.shared.data(from: url)

    // 检查 HTTP 状态码
    guard let httpResponse = response as? HTTPURLResponse,
          httpResponse.statusCode == 200 else {
        throw NetworkError.invalidResponse
    }
    return data
}

// POST 请求(需要构建 URLRequest)
func postData(body: Encodable) async throws -> Data {
    var request = URLRequest(url: URL(string: "https://api.example.com/create")!)
    request.httpMethod = "POST"
    request.setValue("application/json", forHTTPHeaderField: "Content-Type")
    request.setValue("Bearer my-token", forHTTPHeaderField: "Authorization")
    request.httpBody = try JSONEncoder().encode(body)

    let (data, _) = try await URLSession.shared.data(for: request)
    return data
}

7.5 Codable:JSON 编解码

Codable 是 Swift 提供的类型安全 JSON 序列化/反序列化协议,让 JSON 与 Swift 数据模型之间的转换变得极其简单。它等价于同时遵循 Encodable(编码为JSON)和 Decodable(从JSON解码)。

JSONDecoder 将 JSON Data 解码为 Swift 结构体/类的工具。
JSONEncoder 将 Swift 结构体/类编码为 JSON Data 的工具。
CodingKeys 当 JSON 字段名(通常是下划线命名如 user_name)与 Swift 属性名(驼峰命名如 userName)不一致时,用 CodingKeys 枚举进行映射。
// 定义与 JSON 结构对应的 Swift 模型
// 对应 JSON: {"id": 1, "name": "北京", "temp": 23.5, "weather_desc": "晴天"}
struct WeatherData: Codable {
    let id: Int
    let name: String
    let temp: Double
    let weatherDesc: String  // Swift 驼峰命名

    // CodingKeys 映射 JSON 字段名到 Swift 属性名
    enum CodingKeys: String, CodingKey {
        case id, name, temp
        case weatherDesc = "weather_desc"  // JSON用下划线,Swift用驼峰
    }
}

// 解码 JSON → Swift 对象
let jsonString = """
{
  "id": 1,
  "name": "北京",
  "temp": 23.5,
  "weather_desc": "晴天"
}
"""
let data = jsonString.data(using: .utf8)!
let weather = try JSONDecoder().decode(WeatherData.self, from: data)
print(weather.name)         // "北京"
print(weather.weatherDesc)  // "晴天"

// 自动处理下划线到驼峰的转换(无需手写 CodingKeys)
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase  // user_name → userName

// 编码 Swift 对象 → JSON
let encoder = JSONEncoder()
encoder.outputFormatting = .prettyPrinted  // 格式化输出,便于调试
let encoded = try encoder.encode(weather)
print(String(data: encoded, encoding: .utf8)!)

处理嵌套 JSON

// 对应 JSON:
// {"city": "上海", "main": {"temp": 28.0, "humidity": 65}}
struct CityWeather: Codable {
    let city: String
    let main: MainWeather  // 嵌套对象也必须遵循 Codable
}

struct MainWeather: Codable {
    let temp: Double
    let humidity: Int
}

7.6 错误处理:do-try-catch

网络请求充满了不确定性——服务器可能宕机、网络可能断连、JSON 格式可能变化。Swift 的错误处理机制让我们能优雅地处理这些异常情况。

throws 函数声明中加 throws,表示该函数可能抛出错误,调用时必须处理。
try 调用可能抛出错误的函数时,在前面加 try,表示"我知道这里可能出错"。
do-catch do 块中执行可能出错的代码,catch 块捕获并处理错误。
try? 将错误转换为 nil——出错时返回 nil,不报错。适合"出错也无所谓"的场景。
try! 强制执行,出错时崩溃。仅在确定不会出错时使用,生产代码中应避免。
// 自定义错误枚举(比系统错误更具可读性)
enum NetworkError: Error, LocalizedError {
    case invalidURL                    // URL 格式错误
    case invalidResponse               // 服务器响应异常
    case httpError(statusCode: Int)   // HTTP 错误码(关联值)
    case decodingError                 // JSON 解析失败
    case noInternet                    // 无网络连接

    // errorDescription 用于向用户展示可读错误信息
    var errorDescription: String? {
        switch self {
        case .invalidURL:       return "链接格式有误"
        case .invalidResponse:  return "服务器响应异常"
        case .httpError(let code): return "HTTP 错误:\(code)"
        case .decodingError:    return "数据格式解析失败"
        case .noInternet:       return "请检查网络连接"
        }
    }
}

// do-try-catch 完整示例
func loadWeather() async {
    do {
        let weather = try await fetchWeather(city: "北京")
        print("温度:\(weather.temp)°C")

    } catch NetworkError.noInternet {
        // 精确匹配特定错误类型,分别处理
        print("请检查 WiFi 或移动数据")

    } catch NetworkError.httpError(let code) where code == 404 {
        // where 子句进一步过滤
        print("城市未找到")

    } catch {
        // 通用错误兜底,error 是系统自动绑定的错误变量
        print("未知错误:\(error.localizedDescription)")
    }
}

// try? 适合"失败即为空"的场景
let data = try? JSONEncoder().encode(someModel)  // 失败时 data 为 nil

7.7 加载状态的三态管理

网络请求有三种可能的状态:加载中(显示进度指示器)、成功(显示数据)、失败(显示错误提示)。合理管理这三种状态是网络编程的核心。

// 使用枚举建模加载状态(最优雅的方式)
enum LoadingState<T> {
    case idle       // 初始状态,尚未开始加载
    case loading    // 正在加载
    case success(T) // 加载成功,携带数据(泛型)
    case failure(Error) // 加载失败,携带错误信息
}

// 在 ViewModel 中使用
@Observable
class WeatherViewModel {
    var state: LoadingState<WeatherData> = .idle

    func fetchWeather() async {
        state = .loading  // 1. 开始加载,通知视图显示进度条
        do {
            let data = try await WeatherService().fetch()
            state = .success(data)  // 2. 成功,通知视图更新数据
        } catch {
            state = .failure(error) // 3. 失败,通知视图显示错误
        }
    }
}

// 视图根据状态显示不同内容
struct WeatherView: View {
    var viewModel = WeatherViewModel()

    var body: some View {
        switch viewModel.state {
        case .idle:
            Text("点击刷新天气")
        case .loading:
            ProgressView("加载中...")  // 系统自带加载动画
        case .success(let weather):
            WeatherCardView(weather: weather)
        case .failure(let error):
            ErrorView(message: error.localizedDescription)
        }
    }
}

7.8 .task {} 修饰符

.task { } 是 SwiftUI 提供的最优雅的异步任务启动方式——视图出现时自动启动任务,视图消失时自动取消任务,无需手动管理生命周期。

💡
.task vs .onAppear

.onAppear 是同步回调,需要手动创建 Task {} 才能调用异步函数。.task { } 内部直接支持 await,且在视图销毁时会自动取消任务,更安全、更简洁,推荐优先使用

struct ArticleListView: View {
    @State private var articles: [Article] = []
    @State private var isLoading = false

    var body: some View {
        List(articles) { article in
            Text(article.title)
        }
        .overlay {
            if isLoading { ProgressView() }
        }
        // 视图出现时自动执行,视图消失时自动取消
        .task {
            isLoading = true
            defer { isLoading = false }  // defer: 无论如何都在最后执行
            do {
                articles = try await ArticleService().fetchAll()
            } catch { }
        }
        // 带参数的 .task:当参数变化时重新执行
        .task(id: selectedCategory) {
            articles = try await ArticleService().fetch(category: selectedCategory)
        }
    }
}

7.9 并发与任务组(withTaskGroup)

有时需要同时发起多个独立的网络请求,然后等待所有结果。用 withTaskGroup 可以并发执行多个任务,比顺序执行快得多。

// 顺序请求:总耗时 = 请求1时间 + 请求2时间 + 请求3时间
func loadSequentially() async throws {
    let weather = try await fetchWeather()   // 等1秒
    let news    = try await fetchNews()      // 再等2秒
    let stocks  = try await fetchStocks()    // 再等1秒,共4秒
}

// async let 并发请求:总耗时 ≈ 最慢的那个请求的时间
func loadConcurrently() async throws {
    async let weather = fetchWeather()  // 立即开始,不等待
    async let news    = fetchNews()     // 立即开始,不等待
    async let stocks  = fetchStocks()   // 立即开始,不等待
    // 在这里同时等待所有结果,总耗时 ≈ 2秒(并发执行)
    let (w, n, s) = try await (weather, news, stocks)
}

// withTaskGroup:处理动态数量的并发任务
func fetchMultipleCities(_ cities: [String]) async throws -> [WeatherData] {
    try await withThrowingTaskGroup(of: WeatherData.self) { group in
        for city in cities {
            group.addTask {
                try await fetchWeather(for: city)  // 每个城市并发请求
            }
        }
        var results: [WeatherData] = []
        for try await result in group {
            results.append(result)  // 收集所有结果
        }
        return results
    }
}

7.10 实战示例:天气 App 完整实现

将本章所有知识点整合,构建一个完整的天气查询应用,包含加载状态、错误处理和真实 API 调用。

ℹ️
示例说明

本示例使用 OpenWeatherMap API(需注册免费账号获取 API Key)。数据模型与实际 API 响应保持一致。

第一步:定义数据模型

// Models/Weather.swift
// 对应 OpenWeatherMap API 的响应结构
struct WeatherResponse: Codable {
    let name: String           // 城市名
    let main: MainInfo
    let weather: [WeatherInfo]
    let wind: WindInfo

    // 便捷计算属性
    var temperature: String {
        "\(Int(main.temp))°C"
    }
    var description: String {
        weather.first?.description ?? ""
    }
}

struct MainInfo: Codable {
    let temp: Double          // 温度(摄氏度)
    let humidity: Int         // 湿度(%)
    let feelsLike: Double     // 体感温度

    enum CodingKeys: String, CodingKey {
        case temp, humidity
        case feelsLike = "feels_like"  // JSON 是 feels_like
    }
}

struct WeatherInfo: Codable {
    let description: String   // 天气描述
    let icon: String           // 天气图标代码
}

struct WindInfo: Codable {
    let speed: Double          // 风速 m/s
}

第二步:网络服务层

// Services/WeatherService.swift
struct WeatherService {
    private let apiKey = "YOUR_API_KEY"
    private let baseURL = "https://api.openweathermap.org/data/2.5/weather"

    func fetchWeather(city: String) async throws -> WeatherResponse {
        // 1. 构建 URL(URLComponents 自动处理特殊字符编码)
        var components = URLComponents(string: baseURL)!
        components.queryItems = [
            URLQueryItem(name: "q", value: city),
            URLQueryItem(name: "appid", value: apiKey),
            URLQueryItem(name: "units", value: "metric"),   // 摄氏度
            URLQueryItem(name: "lang", value: "zh_cn")    // 中文描述
        ]
        guard let url = components.url else {
            throw NetworkError.invalidURL
        }

        // 2. 发起网络请求
        let (data, response) = try await URLSession.shared.data(from: url)

        // 3. 验证响应状态码
        guard let httpResponse = response as? HTTPURLResponse else {
            throw NetworkError.invalidResponse
        }
        guard httpResponse.statusCode == 200 else {
            throw NetworkError.httpError(statusCode: httpResponse.statusCode)
        }

        // 4. 解码 JSON
        do {
            let decoder = JSONDecoder()
            return try decoder.decode(WeatherResponse.self, from: data)
        } catch {
            throw NetworkError.decodingError
        }
    }
}

第三步:ViewModel

// ViewModels/WeatherViewModel.swift
@Observable
class WeatherViewModel {
    var weather: WeatherResponse? = nil
    var isLoading = false
    var errorMessage: String? = nil
    var cityName = "Beijing"

    private let service = WeatherService()

    func fetchWeather() async {
        isLoading = true
        errorMessage = nil
        defer { isLoading = false }  // 无论成功失败,都重置加载状态

        do {
            weather = try await service.fetchWeather(city: cityName)
        } catch let error as NetworkError {
            errorMessage = error.errorDescription
        } catch {
            errorMessage = "未知错误,请稍后重试"
        }
    }
}

第四步:SwiftUI 视图

// Views/WeatherView.swift
struct WeatherView: View {
    @State private var viewModel = WeatherViewModel()

    var body: some View {
        NavigationStack {
            VStack(spacing: 20) {

                // 搜索栏
                HStack {
                    TextField("输入城市名(英文)", text: $viewModel.cityName)
                        .textFieldStyle(.roundedBorder)
                    Button("搜索") {
                        Task { await viewModel.fetchWeather() }
                    }
                    .buttonStyle(.borderedProminent)
                }
                .padding(.horizontal)

                // 三态内容区
                if viewModel.isLoading {
                    ProgressView("正在获取天气数据...")
                        .frame(maxWidth: .infinity, maxHeight: .infinity)

                } else if let error = viewModel.errorMessage {
                    VStack(spacing: 12) {
                        Image(systemName: "wifi.exclamationmark")
                            .font(.system(size: 48))
                            .foregroundStyle(.red)
                        Text(error).foregroundStyle(.secondary)
                        Button("重试") {
                            Task { await viewModel.fetchWeather() }
                        }
                    }
                    .frame(maxWidth: .infinity, maxHeight: .infinity)

                } else if let weather = viewModel.weather {
                    WeatherCardView(weather: weather)

                } else {
                    ContentUnavailableView("搜索城市天气",
                        systemImage: "cloud.sun",
                        description: Text("输入城市名称查看实时天气"))
                }

                Spacer()
            }
            .navigationTitle("天气")
            .task {
                // 视图出现时自动加载默认城市天气
                await viewModel.fetchWeather()
            }
        }
    }
}

// 天气卡片子视图
struct WeatherCardView: View {
    let weather: WeatherResponse

    var body: some View {
        VStack(spacing: 16) {
            Text(weather.name)
                .font(.title)
                .bold()
            Text(weather.temperature)
                .font(.system(size: 72, weight: .thin))
            Text(weather.description.capitalized)
                .font(.title3)
                .foregroundStyle(.secondary)

            HStack(spacing: 32) {
                Label("\(weather.main.humidity)%", systemImage: "humidity")
                Label("\(Int(weather.wind.speed)) m/s", systemImage: "wind")
                Label("体感 \(Int(weather.main.feelsLike))°", systemImage: "thermometer")
            }
            .font(.subheadline)
            .foregroundStyle(.secondary)
        }
        .padding()
        .frame(maxWidth: .infinity)
        .background(.blue.opacity(0.1), in: .rect(cornerRadius: 16))
        .padding(.horizontal)
    }
}
本章小结

网络编程的核心思路:async/await 解决了回调地狱,URLSession 负责实际网络通信,Codable 处理 JSON 映射,do-try-catch + 自定义 Error 处理异常,三态管理(loading/success/failure)确保 UI 始终有正确的反馈。下一章我们将为应用加入生动的动画。