Swift 并发 Combine 与 async/await

系统梳理 Combine 的 Publisher/Subscriber/Operator 与常用操作符、AnyCancellable 与 @Published 绑定,以及 async/await 的 Task、async let、TaskGroup、actor、MainActor 与 AsyncSequence,给出互操作桥接、选型取舍与 Swift 6 严格并发下的 Sendable 检查。

开篇

iOS 开发者现在面对的是两套并发体系:2019 年随 iOS 13 来的 Combine(响应式流),和 2021 年随 Swift 5.5 来的 async/await(结构化并发)。它们不是替代关系,而是各有主场。真正让人头疼的是:什么时候用哪个?两者怎么互相调用?Swift 6 的严格并发检查开了以后为什么一堆代码编译不过?

本文按"Combine 基础 → 操作符 → 与 SwiftUI 绑定 → async/await 基础 → AsyncSequence → 互操作 → 取消 → Swift 6 → 选型"的顺序讲,代码基于 Swift 5.9/6 与 iOS 17/18。结论先给:新的异步逻辑优先 async/await,事件流与 UI 绑定优先 Combine,两者通过 values 属性和 Future 桥接即可无缝共存。

一、Combine 三件套

Combine 的模型是"发布者—操作符—订阅者"。数据从 Publisher 流出,经过若干 Operator 变换,最终到达 Subscriber。

import Combine

final class SearchViewModel: ObservableObject {
    @Published var keyword = ""
    @Published private(set) var results: [String] = []

    private var cancellables = Set<AnyCancellable>()

    init() {
        $keyword
            .debounce(for: .milliseconds(300), scheduler: DispatchQueue.main)
            .removeDuplicates()
            .sink { [weak self] text in
                self?.results = text.isEmpty ? [] : ["结果 for \(text)"]
            }
            .store(in: &cancellables)
    }
}

三个要点:@Published 的投影 $keyword 就是一个 Publisher;.sink 返回 AnyCancellable;必须 store 起来,否则订阅在语句结束时就释放,什么都不会发生。这是 Combine 最经典的"为什么我的订阅不触发"。

订阅者的三种形态:sink(闭包,最常用)、assign(to:on:)(写入属性)、assign(to:)(写入 @Published,iOS 14+,自动管理生命周期)。

二、常用操作符

Combine 的操作符很多,但日常高频的就那几个。下表按用途分组:

操作符作用典型场景
map值变换把 Model 转成 ViewModel
filter过滤忽略空字符串
compactMap变换并丢弃 nil字符串转 URL
debounce去抖搜索输入
throttle限流滚动位置上报
removeDuplicates去重避免相同值重复刷新
combineLatest合并最新值表单多字段校验
merge合并同类型流多个数据源汇聚
flatMapLatest切换最新内层流搜索词变则取消上一次请求
catch错误降级网络失败给默认值
retry重试瞬时网络抖动

combineLatest 和 flatMapLatest 是最需要理解的两个:

// 用户名和密码都非空才允许提交
Publishers.CombineLatest($username, $password)
    .map { !$0.isEmpty && $1.count >= 8 }
    .assign(to: &$canSubmit)

// 关键词变化时切换请求,自动取消上一次
$keyword
    .debounce(for: .milliseconds(300), scheduler: DispatchQueue.main)
    .flatMapLatest { query in
        api.searchPublisher(query)          // 返回 Publisher<[Item], Error>
    }
    .replaceError(with: [])
    .assign(to: &$results)

注意 flatMapLatest 不是 Combine 内置操作符,它是社区约定(map + switchToLatest):

.map { api.searchPublisher($0) }
.switchToLatest()

switchToLatest 保证只保留最新内层流的输出,旧流被自动取消——这正是搜索防抖竞态的正解。

三、@Published 与 SwiftUI 绑定

@Published 是 ObservableObject 的一部分,在 SwiftUI 里通过 @StateObject / @ObservedObject 订阅。它的更新发生在 willSet,也就是新值写入之前就发出了通知,所以订阅者看到的是旧值——这经常导致"UI 慢一拍"的错觉。需要拿到新值时用 .receive(on:) 切回主线程并读属性,或者改用 iOS 17 的 @Observable。

class FormModel: ObservableObject {
    @Published var email = ""
    @Published var isValid = false
}

iOS 17 后,@Observable 类不依赖 Combine,SwiftUI 直接追踪属性读取。混合工程里两者可以共存,但不要在同一个类上同时用 @Observable 和 ObservableObject。

四、async/await 基础

async/await 把回调地狱变成顺序代码。核心 API:

// 单个异步调用
func loadProfile() async throws -> Profile {
    let (data, _) = try await URLSession.shared.data(from: profileURL)
    return try JSONDecoder().decode(Profile.self, from: data)
}

// 并发执行多个独立请求
func loadDashboard() async throws -> Dashboard {
    async let profile = loadProfile()
    async let feed = loadFeed()
    async let badges = loadBadges()
    return try await Dashboard(profile: profile, feed: feed, badges: badges)
}

async let 是并发启动、顺序等待的语法糖:三个请求同时发出,try await 时统一收口。这比 Combine 的 combineLatest 直观得多。

TaskGroup 用于动态数量的并发:

func loadAll(ids: [Int]) async throws -> [Item] {
    try await withThrowingTaskGroup(of: Item.self) { group in
        for id in ids {
            group.addTask { try await api.fetchItem(id) }
        }
        var items: [Item] = []
        for try await item in group {
            items.append(item)
        }
        return items
    }
}

Task 是异步任务的最小单位,Task { } 从同步上下文启动异步代码。子任务与父任务构成树状结构,父任务取消时子任务自动取消——这是"结构化并发"的核心保障。

4.1 隔离域与 Task 的几种形态

async/await 的并发安全靠"隔离域"表达。@MainActor 标注的类型和方法保证在主线程执行,是 UI 相关代码的默认归属:

@MainActor
final class FeedViewModel: ObservableObject {
    @Published var posts: [Post] = []

    func refresh() async throws {
        let fetched = try await api.fetch()   // 挂起期间不阻塞主线程
        posts = fetched                        // 回到主线程赋值
    }
}

注意 await 的挂起点不阻塞线程,只是把控制权交还,所以 @MainActor 上的长耗时同步计算依然会卡界面。耗时计算要放到 nonisolated 方法、独立 actor 或 Task.detached 里。

Task 有几个形态,选择取决于要不要继承当前上下文:

Task { }                          // 继承当前 actor 与优先级
Task.detached { }                 // 不继承任何上下文,独立运行
Task(priority: .background) { }   // 指定优先级

Task.detached 在 Swift 6 下极易踩坑:它不继承 @MainActor,在里面更新 UI 会直接编译报错;它也不继承 TaskLocal 值。除非确实要脱离上下文,否则优先用 Task { }。

五、AsyncSequence 与 AsyncStream

AsyncSequence 是异步版的序列,用 for await 遍历:

for await line in url.lines {
    print(line)
}

URL.lines 是标准库自带的 AsyncSequence。自定义事件流用 AsyncStream:

func locationUpdates() -> AsyncStream<CLLocation> {
    AsyncStream { continuation in
        let manager = CLLocationManager()
        manager.startUpdatingLocation()
        continuation.onTermination = { _ in
            manager.stopUpdatingLocation()   // 消费者取消时清理
        }
        // 在 delegate 里调用 continuation.yield(location)
    }
}

continuation.onTermination 是资源清理的关键钩子,对应 Combine 里 AnyCancellable 的 cancel()。忘了它就会导致"视图销毁了但定位还在跑"。

六、Combine 与 async/await 互操作

两套体系通过几个桥接点打通:

Combine → async/await:Publisher 有 values 属性,返回 AsyncPublisher:

for await value in viewModel.$items.values {
    print("收到 \(value.count) 条")
}

一次性 Future → async:用 withCheckedThrowingContinuation 包一层:

func fetchOnce() async throws -> Data {
    try await withCheckedThrowingContinuation { continuation in
        api.fetchPublisher()
            .first()
            .sink(
                receiveCompletion: { completion in
                    if case .failure(let error) = completion {
                        continuation.resume(throwing: error)
                    }
                },
                receiveValue: { data in
                    continuation.resume(returning: data)
                }
            )
            .store(in: &cancellables)
    }
}

async/await → Combine:用 Future 包:

func searchPublisher(_ query: String) -> AnyPublisher<[Item], Error> {
    Future { promise in
        Task {
            do { promise(.success(try await api.search(query))) }
            catch { promise(.failure(error)) }
        }
    }
    .eraseToAnyPublisher()
}

关键陷阱:withCheckedThrowingContinuation 必须恰好 resume 一次。如果 Publisher 在 .first() 之前发了多个值,或者发了 completion 又发值,会触发运行时崩溃 SWIFT TASK CONTINUATION MISUSE。用 .first() 收口是最稳的写法。

七、结构化并发与取消

取消是协作式的:调用 task.cancel() 只是把 isCancelled 置为 true,任务本身要继续运行到下一个检查点。

let task = Task {
    try await Task.sleep(for: .seconds(5))
}

task.cancel()   // 睡眠立即抛出 CancellationError

标准库的异步 API(Task.sleep、URLSession、AsyncSequence 迭代)都会自动检查取消。自己写的循环要手动检查:

func process(_ items: [Item]) async throws {
    for item in items {
        try Task.checkCancellation()   // 抛 CancellationError
        await handle(item)
    }
}

SwiftUI 里 .task { } 的取消是自动的——视图消失即取消。这也是为什么它比 onAppear 更适合发请求。

Combine 侧的取消则是 AnyCancellable.cancel() 或 Set<AnyCancellable> 整体释放。两者语义不同:Combine 是"断流",async 是"协作式退出",不能指望它们行为一致。

八、Swift 6 严格并发检查

Swift 6 默认开启严格并发检查(strict concurrency),核心概念是 Sendable:能在并发域之间安全传递的类型。

// 值类型且所有成员 Sendable,自动满足
struct User: Sendable {
    let id: Int
    let name: String
}

// 引用类型需要显式声明并保证线程安全
final class Counter: @unchecked Sendable {
    private let lock = NSLock()
    private var value = 0
}

@unchecked Sendable 是你对编译器的承诺:我保证这个类线程安全。用错了会在运行时出现数据竞争,且编译器不再帮你。

隔离域用 actor 表达,@MainActor 是特殊的全局 actor:

@MainActor
final class ViewModel: ObservableObject {
    @Published var items: [Item] = []

    func load() async {
        let fetched = await api.fetch()   // 后台执行
        items = fetched                    // 回到 MainActor 赋值
    }
}

常见编译错误的成因与解法:

报错成因解法
Capture of non-Sendable type闭包跨隔离域捕获了非 Sendable 对象让类型 Sendable,或改用 @MainActor 闭包
Call to main actor-isolated ... in a synchronous context在非主线程调用了 MainActor 方法加 await 或用 @MainActor 标注调用方
Mutation of captured var in concurrently-executing codeasync let 或 TaskGroup 里改了外部变量改为收集返回值后统一赋值
Actor-isolated property cannot be referenced直接访问了 actor 内部状态通过 await 调用 actor 方法

Swift 6 迁移建议:先用 Swift 5 语言模式 + -strict-concurrency=complete 把警告全打开,逐个消灭,再切语言模式。@preconcurrency 可以临时压掉来自旧 SDK 的警告。

九、选型取舍

两者不是二选一,而是分工:

维度Combineasync/await
模型推式事件流拉式顺序代码
多次事件天然支持需 AsyncSequence
一次性请求略啰嗦最直观
组合多源操作符丰富async let / TaskGroup
取消语义断流协作式
调试难度高(链式、堆栈深)低
SwiftUI 绑定@Published 原生需手动桥接

经验法则:

  • 一次性、有明确结果的异步操作(网络请求、文件读写、数据库查询)用 async/await。
  • 持续的事件流(文本输入、位置更新、通知、定时器)用 Combine 或 AsyncSequence。
  • UI 绑定继续用 Combine 的 @Published,或迁移到 iOS 17 的 @Observable。
  • 新项目:异步逻辑全用 async/await,事件流可以优先考虑 AsyncStream(少一个框架依赖),只在需要复杂操作符组合时才上 Combine。

一个务实的判断:如果一段 Combine 链路用了超过 5 个操作符、或者需要 flatMap 嵌套,通常用 async/await 重写会更可读。

9.1 一次真实重构

同一条"搜索 → 防抖 → 请求 → 去竞态"的链路,两种写法对比如下:

// Combine 版
$keyword
    .debounce(for: .milliseconds(300), scheduler: DispatchQueue.main)
    .removeDuplicates()
    .map { api.searchPublisher($0) }
    .switchToLatest()
    .receive(on: DispatchQueue.main)
    .sink { [weak self] items in self?.results = items }
    .store(in: &cancellables)

// async/await 版
func search(_ query: String) async -> [Item] {
    guard !query.isEmpty else { return [] }
    try? await Task.sleep(for: .milliseconds(300))
    guard !Task.isCancelled else { return [] }
    return (try? await api.search(query)) ?? []
}

// 触发处:新任务自动取消上一个
.onChange(of: keyword) { _, newValue in
    searchTask?.cancel()
    searchTask = Task { results = await search(newValue) }
}

async/await 版把"去抖"和"取消"显式写了出来,逻辑更直白,堆栈也更好追;Combine 版更声明式,但链路一长就难调试。选哪个取决于团队对两套模型的熟悉度,而不是绝对优劣。工程上的可行折中是:新代码用 async/await,已有的、跑得稳的 Combine 链路不要为了"统一"而重写。

相关阅读

小结

Combine 和 async/await 解决的是不同问题:前者擅长"流",后者擅长"一次性异步"。理解这一点,选型就不再纠结。工程上最重要的三件事是:Combine 的订阅必须 store 否则静默失效;withCheckedThrowingContinuation 必须恰好 resume 一次;取消是协作式的,自定义循环要手动 checkCancellation。Swift 6 的严格并发把"数据竞争"从运行时问题变成了编译期问题,短期会带来迁移成本,长期是净收益。迁移路径建议先开 complete 检查消灭警告,再切语言模式。把状态层交给 @Observable、异步逻辑交给 async/await、事件流留给 Combine,这套分工在 iOS 17/18 上已经足够稳定。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「iOS 开发」更多文章

  1. Swift Package Manager 与模块化拆分
  2. Core Animation 与 SwiftUI 动画
  3. iOS 安全:Keychain、生物识别与传输安全