Combine 与 Swift Concurrency:响应式与并发的完美协奏

Swift 5.5 引入的 Swift Concurrency 提供了一种结构化、安全且易读的异步编程方式。而 Combine 自 iOS 13 以来一直是处理持续异步事件流、UI 状态绑定的首选。两者并非对立,而是各有侧重、可以优雅共存。掌握如何在它们之间自由穿梭,能让你在项目中同时获得 Combine 的强大操作符和 async/await 的简洁线性语法。

本文将从场景对比 出发,逐一拆解 PublisherAsyncSequence 的双向转换、混合编程的实际模式,并给出逐步迁移的实践指南。所有示例均基于 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 执行具体任务,并通过 Futurevalues 相互转换。

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. 最佳实践与注意事项

  1. 不要过度包装:如果某个功能天然适合 Combine(如搜索防抖),请继续使用 Combine;如果是一对一的请求,直接用 async/await。
  2. 管理生命周期 :使用 Task 时务必存储并在适当时机取消(例如 deinit.task 修饰符)。Combine 的 AnyCancellable 存储在 Set<AnyCancellable> 即可。
  3. 小心 Future 的即时执行Future 在初始化时立即调用闭包,与普通的 Combine Publisher 惰性启动不同。需要延迟启动时可以将其包裹在 Deferred 中。
  4. 线程安全 :async/await 的上下文切换由系统管理,但 Combine 依然需要显式 receive(on:) 确保 UI 更新在主线程。
  5. 测试 :使用 ImmediateScheduler 同步 Combine 测试,使用 await 测试 async 函数。

7. 总结

Combine 与 Swift Concurrency 是苹果为 Swift 生态提供的两把异步利器。Combine 擅长处理随时间变化的复杂数据流 ,Swift Concurrency 让一次性的异步调用 如丝般顺滑。通过 valuesFutureAsyncStream 等内置桥梁,两者可以在同一个项目中和平共处,甚至相得益彰。正确的架构是让它们各自负责最擅长的领域,并在边界处平滑转换。

随着 Swift 的演进,我们可能会看到更多 Combine 操作符的 async 版本,但 Combine 的核心思想和与 SwiftUI 的深度集成仍将是 iOS 开发者的必修课。掌握双向转换,你将无惧任何异步场景。

相关推荐
90后的晨仔2 小时前
Combine 自定义 Subject:构建专属的响应式事件源
ios
90后的晨仔2 小时前
Combine 架构模式:构建响应式应用的蓝图
ios
90后的晨仔2 小时前
Combine 高级实践:多线程调度、调试与测试
ios
人月神话Lee4 小时前
【图像处理】饱和度——颜色的浓淡与灰度化
ios·ai编程·图像识别
王飞飞不会飞5 小时前
iOS卡顿查找和定位-ProFile
ios·性能优化
敲代码的鱼5 小时前
NFC读卡能力 支持安卓/iOS/鸿蒙 UTS插件
android·ios·uni-app
sweet丶9 小时前
iOS应用启动过程深度分析与优化实践
ios
largecode12 小时前
企业名称能在来电显示吗?号码显示公司名服务打通多终端展示
android·xml·ios·iphone·xcode·webview·phonegap
MonkeyKing12 小时前
iOS Core Animation 渲染架构详解:Render Server 与 Commit Transaction
ios