开篇
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 code | async let 或 TaskGroup 里改了外部变量 | 改为收集返回值后统一赋值 |
Actor-isolated property cannot be referenced | 直接访问了 actor 内部状态 | 通过 await 调用 actor 方法 |
Swift 6 迁移建议:先用 Swift 5 语言模式 + -strict-concurrency=complete 把警告全打开,逐个消灭,再切语言模式。@preconcurrency 可以临时压掉来自旧 SDK 的警告。
九、选型取舍
两者不是二选一,而是分工:
| 维度 | Combine | async/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 链路不要为了"统一"而重写。
相关阅读
- SwiftUI 声明式界面与状态管理
—
@Published与@Observable如何驱动界面刷新 - UIKit 与 SwiftUI 混合开发 — 跨边界的异步任务与取消如何管理
- iOS 网络请求与 URLSession — async/await 在真实网络层的落地写法
小结
Combine 和 async/await 解决的是不同问题:前者擅长"流",后者擅长"一次性异步"。理解这一点,选型就不再纠结。工程上最重要的三件事是:Combine 的订阅必须 store 否则静默失效;withCheckedThrowingContinuation 必须恰好 resume 一次;取消是协作式的,自定义循环要手动 checkCancellation。Swift 6 的严格并发把"数据竞争"从运行时问题变成了编译期问题,短期会带来迁移成本,长期是净收益。迁移路径建议先开 complete 检查消灭警告,再切语言模式。把状态层交给 @Observable、异步逻辑交给 async/await、事件流留给 Combine,这套分工在 iOS 17/18 上已经足够稳定。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。