第46章 异步、网络与生命周期:数据从哪来

task 修饰符的生命周期、三态建模、async let 并发、URLSession 请求、取消与去抖

第四十六章:异步、网络与生命周期:数据从哪来

真实的应用里,界面上大部分数据都不是"你造的",而是"你去要来的"。要数据这件事有三个绕不开的性质:会慢、会失败、可能在你不要它的时候还在跑。 这一章就围绕这三点,把 SwiftUI 里处理异步数据的标准做法立起来。

46.1 .task 不是 onAppear 的异步版

新手最常见的写法是在 onAppear 里起一个 Task { }。它能跑,但你会丢掉三样东西:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
import SwiftUI

struct NaiveView: View {
    @State private var text = "加载中"

    var body: some View {
        Text(text)
            .onAppear {
                // ❌ 这个任务跟视图没有隶属关系:
                // 视图消失后它还在跑;视图再次出现又会起一个新的
                Task {
                    text = await fetchTitle()
                }
            }
    }

    func fetchTitle() async -> String { "标题" }
}

换成 .task:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
import SwiftUI

struct TaskView: View {
    @State private var text = "加载中"

    var body: some View {
        Text(text)
            .task {
                // ✅ 视图消失时,这个任务会被自动取消
                text = await fetchTitle()
            }
    }

    func fetchTitle() async -> String { "标题" }
}

// 界面效果:进入页面时显示"加载中",请求回来后变成"标题"

两者的差别可以列成一张表:

onAppear + Task { }.task { }
视图消失时任务继续跑(泄漏风险)自动取消
视图再次出现每次都起新任务每次都重新起任务(但旧的已取消)
属于哪个隔离域需要自己标注 @MainActor自动继承视图的隔离域(body 所在的 MainActor)
能不能依赖某个值不能,得自己写 onChange能,用 .task(id:)

一句话结论:只要是在视图生命周期里发起的异步工作,就用 .task。 onAppear 留给纯粹同步的副作用(比如埋点)。

46.2 .task(id:):值一变,旧任务自动取消,新任务自动开始

这是 .task 相比"onAppear + onChange + 手动取消"最省事的地方:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import SwiftUI

struct DebouncedSearch: View {
    @State private var query = ""
    @State private var results: [String] = []

    var body: some View {
        List(results, id: \.self) { Text($0) }
            .searchable(text: $query, prompt: "搜索")
            // id 一变化,上一次任务就被取消,然后立刻开始新的
            .task(id: query) {
                do {
                    try await Task.sleep(for: .milliseconds(300))   // 输入停顿 300ms 才真的请求
                } catch {
                    return                                          // 被新输入打断,安静退出
                }
                results = query.isEmpty ? [] : ["\(query) 的结果 A", "\(query) 的结果 B"]
            }
    }
}

// 界面效果:快速连打几个字母时不会每个字母都请求;
// 停手 300 毫秒后才出现「xx 的结果 A / B」两条

这段代码里有一个非常值得单独拎出来的模式:用 Task.sleep 当"去抖",用 catch { return } 当"被打断就放弃"。 三行代码就实现了完整的去抖,不需要引入计时器、不需要额外的状态变量。

.task(id:) 的 id 必须是 Equatable。常见的几个用法:

id 用效果
query搜索去抖、联动请求
selectedID(选中的记录)切换详情时自动重新加载
isPresented弹窗打开时才加载内容

46.3 三态建模:加载中 / 成功 / 失败

界面加载数据时,永远只有三种(加上"还没开始"是四种)状态。把它们建模成一个枚举,switch 一次写完,界面就不可能漏掉某一种。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
import SwiftUI

enum LoadState<Value> {
    case idle
    case loading
    case loaded(Value)
    case failed(String)
}

struct FeedView: View {
    @State private var state: LoadState<[String]> = .idle

    var body: some View {
        Group {
            switch state {
            case .idle, .loading:
                ProgressView("加载中…")
                    .frame(maxWidth: .infinity, maxHeight: .infinity)

            case .loaded(let items) where items.isEmpty:
                ContentUnavailableView("还没有内容", systemImage: "tray")

            case .loaded(let items):
                List(items, id: \.self) { Text($0) }

            case .failed(let message):
                ContentUnavailableView {
                    Label("加载失败", systemImage: "wifi.exclamationmark")
                } description: {
                    Text(message)
                } actions: {
                    Button("重试") { Task { await load() } }
                }
            }
        }
        .task { await load() }
    }

    func load() async {
        state = .loading
        try? await Task.sleep(for: .milliseconds(400))
        state = .loaded(["第一条", "第二条"])
    }
}

// 界面效果:先显示居中的"加载中…",
// 约 0.4 秒后变成两行列表

为什么推荐枚举而不是"三个布尔/可选值"?对比一下就很清楚:

写法问题
var isLoading = false + var items: [X]? + var error: String?出现了"既在加载又有错误"这种非法组合,编译器不会拦你
enum LoadState四种状态互斥,switch 必须穷尽,非法组合根本写不出来

这正是第 16 章枚举与第 24 章类型系统想告诉你的同一个道理:让类型帮你排除掉不可能的情况。

46.4 并发请求:async let 与任务组

如果一个页面要三份互不相关的数据,串行请求的总耗时就是三者相加。async let 让它们同时出发:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import Foundation

struct Profile: Sendable { let name: String }
struct Orders: Sendable { let count: Int }

func fetchProfile() async throws -> Profile {
    try await Task.sleep(for: .milliseconds(100))
    print("资料请求完成")
    return Profile(name: "Mia")
}

func fetchOrders() async throws -> Orders {
    try await Task.sleep(for: .milliseconds(40))
    print("订单请求完成")
    return Orders(count: 3)
}

async let profile = fetchProfile()
async let orders = fetchOrders()
let (p, o) = try await (profile, orders)
print("\(p.name) 有 \(o.count) 个订单")
// prints: 订单请求完成
// prints: 资料请求完成
// prints: Mia 有 3 个订单

请特别看一眼输出顺序:慢的那个(100 毫秒)反而后打印,快的那个(40 毫秒)先打印。 如果两个请求是串行的,顺序必然是先"资料"后"订单"(因为代码里先写)。输出顺序反过来,正是它们同时在跑的证明。

需要动态数量的并发(比如"每个 id 都要拉一次详情")时,用任务组:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import Foundation

func fetchDetail(_ id: Int) async throws -> String {
    try await Task.sleep(for: .milliseconds(50))
    return "详情 \(id)"
}

func fetchAll(ids: [Int]) async throws -> [String] {
    try await withThrowingTaskGroup(of: String.self) { group in
        for id in ids {
            group.addTask { try await fetchDetail(id) }
        }
        var results: [String] = []
        for try await detail in group {
            results.append(detail)
        }
        return results.sorted()      // 完成顺序不确定,需要时自己排序
    }
}

let all = try await fetchAll(ids: [1, 2, 3, 4])
print(all)
// prints: ["详情 1", "详情 2", "详情 3", "详情 4"]

⚠️ 任务组的返回顺序是完成顺序,不是提交顺序。要稳定顺序就按 id 排序,或者让每个任务返回 (id, value) 再排。

46.5 网络请求:朴素但完整的一套

SwiftUI 不提供网络层,用的还是 URLSession。一个"能上生产"的请求函数需要做四件事:拼 URL、发请求、检查状态码、解码。缺一件都会在未来某天变成"界面莫名其妙没数据"。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
import Foundation

struct Article: Identifiable, Decodable, Hashable {
    let id: Int
    let title: String
}

enum FeedError: LocalizedError {
    case badStatus(Int)
    case decoding

    var errorDescription: String? {
        switch self {
        case .badStatus(let code): "服务器返回了 \(code)"
        case .decoding: "返回的数据看不懂"
        }
    }
}

func fetchArticles(matching query: String, session: URLSession = .shared) async throws -> [Article] {
    var components = URLComponents(string: "https://example.com/api/articles")!
    if !query.isEmpty {
        components.queryItems = [URLQueryItem(name: "q", value: query)]
    }
    guard let url = components.url else { throw URLError(.badURL) }

    // 用 URLRequest 才能加超时、缓存策略、请求头
    var request = URLRequest(url: url)
    request.timeoutInterval = 10
    request.setValue("application/json", forHTTPHeaderField: "Accept")

    let (data, response) = try await session.data(for: request)

    guard let http = response as? HTTPURLResponse else { throw URLError(.badServerResponse) }
    guard (200..<300).contains(http.statusCode) else { throw FeedError.badStatus(http.statusCode) }

    do {
        return try JSONDecoder().decode([Article].self, from: data)
    } catch {
        throw FeedError.decoding        // 把底层解码错误换成用户看得懂的话
    }
}

四个要点:

  • session.data(for:) 而不是 data(from:):只有前者能带 URLRequest(超时、请求头、缓存策略)。
  • 一定要检查 HTTPURLResponse.statusCode。 data(for:) 在收到 404/500 时不会抛错,它只负责"网络层面通了"。不检查状态码就去解码,用户看到的是"返回的数据看不懂"这种误导性提示。
  • 解码失败要单独处理。 后端口径变了,DecodingError 的原始信息对用户毫无意义。
  • LocalizedError + errorDescription 让 error.localizedDescription 直接拿来显示,不用到处 switch。

取消与超时

.task 帮你取消,但被取消的任务不会自动停止正在执行的 URLSession 请求——它需要你配合。好消息是 URLSession 的异步方法本身就响应 Swift 并发取消:任务一取消,请求会被中断并抛出 CancellationError 或 URLError(.cancelled)。

想在界面上正确应对,只要记住一条:区分"被取消"和"真失败"。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import SwiftUI

struct CancelAware: View {
    @State private var state = "空"

    var body: some View {
        Text(state)
            .task {
                do {
                    state = try await slowThing()
                } catch is CancellationError {
                    // 被取消不是错误,界面保持原样,不要弹"加载失败"
                } catch {
                    state = "失败:\(error.localizedDescription)"
                }
            }
    }

    func slowThing() async throws -> String {
        try await Task.sleep(for: .seconds(5))
        return "结果"
    }
}

// 界面效果:页面停留 5 秒后显示"结果";
// 如果中途离开,任务被取消,再回来重新计时,且全程不显示错误

至于"超时",有两种做法。用 URLRequest.timeoutInterval 最简单(上面已经写了);要点是"整个操作必须在 X 秒内结束"(比如一次并发请求的整体预算),就用任务组自己造:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
import Foundation

/// 超时专用的错误类型,方便调用方精准捕获
struct TimeoutError: Error { }

func withTimeout<T: Sendable>(
    seconds: Double,
    operation: @escaping @Sendable () async throws -> T
) async throws -> T {
    try await withThrowingTaskGroup(of: T.self) { group in
        group.addTask { try await operation() }
        group.addTask {
            try await Task.sleep(for: .seconds(seconds))
            throw TimeoutError()
        }
        // 谁先完成就用谁,剩下的全部取消
        let result = try await group.next()!
        group.cancelAll()
        return result
    }
}

func verySlow() async throws -> String {
    try await Task.sleep(for: .seconds(3))
    return "终于好了"
}

do {
    print(try await withTimeout(seconds: 0.3) { try await verySlow() })
} catch is TimeoutError {
    print("超时:0.3 秒内没拿到结果")
    // prints: 超时:0.3 秒内没拿到结果
} catch {
    print("其它错误:\(error)")
}

用一个自定义的 TimeoutError 而不是借用 URLError(.timedOut),好处是调用方能精确区分"我等超时了"和"网络自己报错了"——后者往往需要给用户完全不同的提示。

⚠️ 注意 group.next()! 在那里是安全的(任务组里至少有一个子任务,一定会返回或抛错),但这个 withTimeout 有个取舍:被取消的那个操作不一定立刻停下,它只是失去了返回值。对 URLSession 这种尊重取消的 API 没问题,对纯 CPU 循环就要自己在循环里查 Task.isCancelled。

46.6 从网络到界面:把零件接起来

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
import SwiftUI
import Foundation

struct Article: Identifiable, Decodable, Hashable {
    let id: Int
    let title: String
}

enum LoadState<Value> {
    case idle, loading
    case loaded(Value)
    case failed(String)
}

@Observable
final class ArticleFeedModel {
    var state: LoadState<[Article]> = .idle
    private var session = URLSession.shared

    func load(query: String) async {
        if case .loaded = state {} else { state = .loading }
        do {
            let items = try await fetchArticles(matching: query, session: session)
            state = .loaded(items)
        } catch is CancellationError {
            // 取消:不动界面
        } catch {
            state = .failed(error.localizedDescription)
        }
    }

    private func fetchArticles(matching query: String, session: URLSession) async throws -> [Article] {
        guard var components = URLComponents(string: "https://example.com/api/articles") else {
            throw URLError(.badURL)
        }
        components.queryItems = query.isEmpty ? nil : [URLQueryItem(name: "q", value: query)]
        let (data, response) = try await session.data(from: components.url!)
        guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
            throw URLError(.badServerResponse)
        }
        return try JSONDecoder().decode([Article].self, from: data)
    }
}

struct ArticleFeedView: View {
    @State private var model = ArticleFeedModel()
    @State private var query = ""

    var body: some View {
        NavigationStack {
            Group {
                switch model.state {
                case .idle, .loading: ProgressView()
                case .loaded(let items): List(items) { Text($0.title) }
                case .failed(let message): Text(message).foregroundStyle(.red)
                }
            }
            .navigationTitle("文章")
            .searchable(text: $query)
            .task(id: query) {
                do { try await Task.sleep(for: .milliseconds(300)) } catch { return }
                await model.load(query: query)
            }
        }
    }
}

// 界面效果:标题栏下是搜索框;输入关键词停顿后列表刷新;
// 网络失败时显示一行红字说明

这个结构值得当成模板背下来:

  • 模型持有 LoadState,负责请求与错误翻译;
  • 视图只 switch 状态,一行网络代码都不写;
  • .task(id: query) 负责触发与取消,去抖也在这一层。

三者职责不重叠,所以任何一处要改(换缓存策略、改去抖时长、加"空结果"界面)都不会牵动另外两处。

46.7 AsyncImage:三行显示远程图片

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import SwiftUI

struct RemoteAvatar: View {
    let url: URL?

    var body: some View {
        AsyncImage(url: url) { phase in
            switch phase {
            case .empty:
                ProgressView()
            case .success(let image):
                image.resizable().scaledToFill()
            case .failure:
                Image(systemName: "person.crop.circle.badge.exclamationmark")
                    .foregroundStyle(.secondary)
            @unknown default:
                EmptyView()
            }
        }
        .frame(width: 64, height: 64)
        .clipShape(Circle())
    }
}

// 界面效果:一个圆形头像位;加载中显示转圈,
// 成功后显示图片,失败显示带感叹号的人像图标

AsyncImage 的优缺点都很鲜明:它不缓存(每次出现都会重新下载),也不能配请求头。需求简单时用它省事;生产项目里通常自己写一个带缓存的小组件,把 URLSession 换成带 URLCache 的配置即可。

46.8 本章小结

概念一句话
.task视图生命周期内的异步工作,视图消失自动取消
.task(id:)依赖值变化时自动取消旧的、启动新的
去抖技巧.task(id:) + Task.sleep + catch { return }
三态建模enum LoadState 让非法组合无法表达
async let固定数量的并发请求,输出顺序能证明并发
任务组动态数量的并发;结果按完成顺序返回
URLRequest加超时、请求头、缓存策略;data(for:) 才有它
状态码检查data(for:) 对 4xx/5xx 不抛错,必须自己判断
取消catch is CancellationError 要和真失败分开处理
超时timeoutInterval 管单次请求;整体预算用任务组自己实现
AsyncImage最省事的远程图片,但无缓存、不能配请求头

46.9 本章易错点速查

容易踩的地方正确认识
用 onAppear { Task { ... } }任务与视图无隶属,会泄漏;用 .task
在 .task 里直接改 @State 却担心线程.task 继承 MainActor 隔离,直接改是安全的
忘记处理取消用户快速切页会看到"加载失败"闪一下;先 catch is CancellationError
用 data(for:) 却以为 404 会抛错不会;必须检查 HTTPURLResponse.statusCode
把解码错误直接显示给用户DecodingError 的信息没有用户价值,要翻译
用三个可选/布尔表示加载状态非法组合可表达;换 enum LoadState
串行 await 多个请求互不依赖就用 async let 或任务组并发
以为任务组按提交顺序返回按完成顺序;要稳定顺序就自己排
把整个页面塞进 AsyncImage它不缓存;头像之类的小图才适合
在每个 .task 里都写一遍去抖抽成一个小工具函数或模型方法

46.10 下章预告

到这里,一个功能完整、数据真实、动画得体的 App 已经能写出来了。最后一章处理"能不能交付"的问题:#Preview 怎么写才有用、SwiftUI 代码怎么测、可访问性为什么不是可选项,以及打包上架前值得过一遍的清单。

最后修改 September 19, 2026: 更新 (3489033b1)