Swift 5.5 引入的 Swift Concurrency 提供了一种结构化、安全且易读的异步编程方式。而 Combine 自 iOS 13 以来一直是处理持续异步事件流、UI 状态绑定的首选。两者并非对立,而是各有侧重、可以优雅共存。掌握如何在它们之间自由穿梭,能让你在项目中同时获得 Combine 的强大操作符和 async/await 的简洁线性语法。
本文将从场景对比 出发,逐一拆解 Publisher 与 AsyncSequence 的双向转换、混合编程的实际模式,并给出逐步迁移的实践指南。所有示例均基于 Swift 5.7+ 和 Combine(iOS 13+),并遵循苹果官方文档的行为规范。
1. 两种范式,各司其职
在决定何时使用 Combine、何时使用 async/await 之前,先理解它们的本质差异:
| 特性 | Combine | Swift Concurrency |
|---|---|---|
| 数据模型 | 连续的多值流 (publisher) | 通常为单一值 (异步返回) |
| 操作方式 | 丰富的操作符 (map, filter, debounce...) | 顺序的 await 调用,较少内建组合 |
| 线程模型 | 调度器 (Scheduler),显式切换 | 结构化并发,Task/actor 隔离 |
| UI 绑定 | @Published 原生驱动 SwiftUI 更新 |
需配合 @MainActor + objectWillChange |
| 错误处理 | Failure 类型约束,操作符 catch/retry |
throws / do-catch |
| 取消 | AnyCancellable 显式存储和取消 |
自动随 Task 树取消 |
| 学习曲线 | 较陡,操作符繁多 | 语法相对简单 |
实战选型法则:
- 持续的事件流 (UI 状态、Timer、通知、键盘输入)→ Combine。
- 一次性的请求/响应 (网络请求、文件读取、数据库查询)→ async/await。
- 两者共存时,用 Combine 管理实时流,用 async/await 执行具体任务,并通过
Future或values相互转换。
2. 将 Publisher 转换为 AsyncSequence
Combine 的 Publisher 从 iOS 15 / macOS 12 开始符合 AsyncSequence 协议,其 values 属性返回一个 AsyncPublisher。你可以直接使用 for try await 来迭代其值。这是从 Combine 通向 Swift Concurrency 最直接的桥梁。
2.1 基础用法
swift
let timerPublisher = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
Task {
for await date in timerPublisher.values {
print("当前时间: \(date)")
// 收到三次后退出
if counter > 3 { break }
}
}
当循环退出或 Task 取消时,异步序列会自动停止。这里无需显式 cancel() AnyCancellable。
2.2 处理错误
如果 Publisher 的 Failure 不是 Never,可以使用 try? 或 try 捕获错误:
swift
func fetchData() async throws -> Data {
let url = URL(string: "https://example.com")!
let publisher = URLSession.shared.dataTaskPublisher(for: url)
.map(\.data)
.mapError { $0 as Error }
for try await data in publisher.values {
return data // 通常期望一个值,但值流可能产生多个,这里只取第一个
}
throw URLError(.badServerResponse) // 流结束时尚未返回
}
如果希望获取 Publisher 发出的第一个值并立即结束,可以结合 first() 操作符:
swift
func fetchFirstData() async throws -> Data {
let publisher = URLSession.shared.dataTaskPublisher(for: url)
.map(\.data)
.first()
.mapError { $0 as Error }
guard let data = try await publisher.values.first(where: { _ in true }) else {
throw URLError(.badServerResponse)
}
return data
}
2.3 用 Continuation 封装为 async 函数
一些老的 Combine API 可能返回 AnyPublisher,你想在 async 环境中使用。可以通过 withCheckedThrowingContinuation 将其桥接。
swift
extension Publisher where Failure == Error {
func async() async throws -> Output {
try await withCheckedThrowingContinuation { continuation in
var cancellable: AnyCancellable?
cancellable = self
.first()
.sink(receiveCompletion: { completion in
if case .failure(let error) = completion {
continuation.resume(throwing: error)
}
}, receiveValue: { value in
continuation.resume(returning: value)
})
// 注意:cancellable 在闭包结束后并不会自动释放,但 continuation 完成后它可以被取消
}
}
}
使用示例:
swift
let user: User = try await userService.fetchUserPublisher(id: 1).async()
警告 :上述扩展仅适用于 first() 场景,若 Publisher 可能发出多个值,需谨慎处理。
3. 将 async 函数包装为 Publisher
当你需要将一个 async/await 函数嵌入 Combine 管道时,最常用的工具是 Future。
3.1 使用 Future 初始化
swift
func fetchUser(id: Int) async throws -> User {
let (data, _) = try await URLSession.shared.data(from: url)
return try JSONDecoder().decode(User.self, from: data)
}
func fetchUserPublisher(id: Int) -> AnyPublisher<User, Error> {
Future { promise in
Task {
do {
let user = try await fetchUser(id: id)
promise(.success(user))
} catch {
promise(.failure(error))
}
}
}
.eraseToAnyPublisher()
}
Future 在初始化时立即执行内部的闭包,并只发出一个值或错误,这恰好匹配 async 函数的单次完成行为。
3.2 使用 AsyncStream 构建多值流
如果需要把 async 上下文产生的连续值(例如通过回调迭代的数据)转化为 Combine 流,可以用 AsyncStream 再转为 Publisher:
swift
func locationStream() -> AsyncStream<CLLocation> {
AsyncStream { continuation in
// 模拟位置更新
let timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { _ in
continuation.yield(CLLocation(latitude: ..., longitude: ...))
}
continuation.onTermination = { @Sendable _ in
timer.invalidate()
}
}
}
func locationPublisher() -> AnyPublisher<CLLocation, Never> {
let stream = locationStream()
// 利用 Publisher 的 `init(_:)` 从 AsyncSequence 创建
// 注:Combine 提供了 Publisher 从 AsyncSequence 的初始化器(iOS 16+)
// 若版本不够,可以通过 Future + while 循环近似实现
if #available(iOS 16, *) {
return Publisher(stream).eraseToAnyPublisher()
} else {
// 降级方案:使用 Future 加 Task
return Future { promise in
Task {
for await loc in stream {
promise(.success(loc))
break // 只发送一次作为示例
}
}
}
.eraseToAnyPublisher()
}
}
4. 混合使用实践:一个 ViewModel 的双向桥梁
在实际项目中,一个 ViewModel 可能同时需要 Combine 的实时性和 Swift Concurrency 的简洁性。
swift
class DashboardViewModel: ObservableObject {
@Published var notifications: [Notification] = []
@Published var userProfile: User?
@Published var isLoading = false
@Published var errorMessage: String?
private let service: DashboardServiceProtocol
private var cancellables = Set<AnyCancellable>()
private var observeTask: Task<Void, Never>?
init(service: DashboardServiceProtocol) {
self.service = service
// 实时订阅通知流(Combine)
service.notificationStream
.receive(on: DispatchQueue.main)
.assign(to: \.notifications, on: self)
.store(in: &cancellables)
}
// 单次加载用户资料(async/await)
func loadProfile() async {
isLoading = true
errorMessage = nil
do {
userProfile = try await service.fetchUserProfile()
} catch {
errorMessage = error.localizedDescription
}
isLoading = false
}
// 手动开始观察实时更新(Combine 转 AsyncSequence)
func observeUpdates() {
observeTask = Task { [weak self] in
guard let self else { return }
for await update in service.updateStream.values {
await MainActor.run { self.process(update) }
}
}
}
func stopObserving() {
observeTask?.cancel()
}
deinit {
observeTask?.cancel()
}
}
在这里,Service 接口提供了 Combine 的 Publisher 和 async 方法并存:
swift
protocol DashboardServiceProtocol {
var notificationStream: AnyPublisher<[Notification], Never> { get }
var updateStream: AnyPublisher<Update, Never> { get }
func fetchUserProfile() async throws -> User
}
这种设计充分发挥了两大框架的优势:Combine 管理连续的 UI 状态流,async/await 处理一次性请求。
5. 迁移策略:从 Combine 到 async/await 的渐进之路
Apple 正在鼓励新代码使用 Swift Concurrency,但 Combine 并不会消失。合理的迁移策略应当是渐进式的。
5.1 迁移检查表
- 数据层 :优先将网络请求、数据库 I/O 等方法改为 async/await,然后通过
Future提供 Publisher 兼容。 - UI 层 :
@Published保持不变,它与 SwiftUI 深度绑定,用 async/await 替代并没有明显优势。 - 实时流 :如果某个流仅涉及简单的异步迭代,可逐步替换为
AsyncStream;但复杂的组合、debounce、throttle 仍建议保留 Combine。 - 遗留代码 :通过
withCheckedContinuation将 Combine 链封装为 async 函数,供新代码调用。 - 测试 :测试 async 函数比 Combine 的异步等待更直观,可利用
async/await测试。
5.2 兼容层示例
swift
class LegacyDataService {
func fetchItems() -> AnyPublisher<[Item], Error> {
// 复杂的 Combine 管道...
}
}
// 新接口适配
extension LegacyDataService {
func fetchItemsAsync() async throws -> [Item] {
try await fetchItems().async() // 使用之前定义的扩展
}
}
这样就可以在新模块中使用 try await service.fetchItemsAsync(),而旧代码仍可调用 Combine 版本。
6. 最佳实践与注意事项
- 不要过度包装:如果某个功能天然适合 Combine(如搜索防抖),请继续使用 Combine;如果是一对一的请求,直接用 async/await。
- 管理生命周期 :使用
Task时务必存储并在适当时机取消(例如deinit或.task修饰符)。Combine 的AnyCancellable存储在Set<AnyCancellable>即可。 - 小心 Future 的即时执行 :
Future在初始化时立即调用闭包,与普通的 Combine Publisher 惰性启动不同。需要延迟启动时可以将其包裹在Deferred中。 - 线程安全 :async/await 的上下文切换由系统管理,但 Combine 依然需要显式
receive(on:)确保 UI 更新在主线程。 - 测试 :使用
ImmediateScheduler同步 Combine 测试,使用await测试 async 函数。
7. 总结
Combine 与 Swift Concurrency 是苹果为 Swift 生态提供的两把异步利器。Combine 擅长处理随时间变化的复杂数据流 ,Swift Concurrency 让一次性的异步调用 如丝般顺滑。通过 values、Future、AsyncStream 等内置桥梁,两者可以在同一个项目中和平共处,甚至相得益彰。正确的架构是让它们各自负责最擅长的领域,并在边界处平滑转换。
随着 Swift 的演进,我们可能会看到更多 Combine 操作符的 async 版本,但 Combine 的核心思想和与 SwiftUI 的深度集成仍将是 iOS 开发者的必修课。掌握双向转换,你将无惧任何异步场景。