Combine Publishers.Zip 容错处理指南

Combine Publishers.Zip 容错处理指南 #

概述 #

在 iOS 开发中,我们经常需要并行调用多个 API 并等待所有结果返回。Publishers.Zip 是 Combine 框架提供的强大工具,但它有一个"全有或全无"的特性:任何一个 Publisher 失败,整个操作都会失败。本文介绍如何通过 replaceError 实现容错处理。

Publishers.Zip 基础 #

什么是 Publishers.Zip #

Publishers.Zip 将多个 Publisher 的输出组合成一个元组,当所有 Publisher 都发出值时才会触发。

// 基础用法:同时等待两个网络请求
let publisher1 = URLSession.shared.dataTaskPublisher(for: url1)
let publisher2 = URLSession.shared.dataTaskPublisher(for: url2)

Publishers.Zip(publisher1, publisher2)
    .sink(
        receiveCompletion: { completion in
            // 处理完成或错误
        },
        receiveValue: { (response1, response2) in
            // 两个请求都成功时处理数据
        }
    )

Zip 的变体 #

Combine 提供了 Zip2 到 Zip4 的变体,支持 2-4 个 Publisher 的组合:

  • Publishers.Zip (Zip2): 组合 2 个 Publisher
  • Publishers.Zip3: 组合 3 个 Publisher
  • Publishers.Zip4: 组合 4 个 Publisher

问题:脆弱的并行 #

默认行为的问题 #

// ❌ 问题代码:任何一个失败,全部失败
let api1 = loadUserProfile()      // 可能失败
let api2 = loadUserPosts()        // 可能失败
let api3 = loadUserFriends()      // 可能失败

Publishers.Zip3(api1, api2, api3)
    .sink(
        receiveCompletion: { completion in
            if case .failure(let error) = completion {
                // 任何一个API失败都会到这里
                // 用户看到错误页面,即使其他数据可用
            }
        },
        receiveValue: { (profile, posts, friends) in
            // 只有全部成功才会执行
        }
    )

这种"全有或全无"的设计在某些场景下过于严格,降低了用户体验。

解决方案:容错处理 #

核心技术:replaceError #

replaceError(with:) 操作符可以将错误替换为默认值,让 Publisher 继续工作:

publisher
    .replaceError(with: defaultValue)  // 错误时返回默认值
    .eraseToAnyPublisher()             // 类型擦除,简化类型签名

完整容错示例 #

// ✅ 容错版本:部分失败不影响整体
func loadDataWithFaultTolerance() {
    // 1. 定义每个 Publisher 并添加容错
    let profilePublisher = loadUserProfile()
        .replaceError(with: nil)  // 失败返回 nil
        .eraseToAnyPublisher()
        as AnyPublisher<UserProfile?, Never>  // Never 表示不会失败
    
    let postsPublisher = loadUserPosts()
        .replaceError(with: [])   // 失败返回空数组
        .eraseToAnyPublisher()
        as AnyPublisher<[Post], Never>
    
    let friendsPublisher = loadUserFriends()
        .replaceError(with: nil)
        .eraseToAnyPublisher()
        as AnyPublisher<[Friend]?, Never>
    
    // 2. 使用 Zip 组合,现在不会因单个失败而崩溃
    Publishers.Zip3(profilePublisher, postsPublisher, friendsPublisher)
        .sink { completion in
            // 由于使用了 replaceError,这里只会收到 .finished
            print("All requests completed")
        } receiveValue: { (profile, posts, friends) in
            // 3. 处理可选值,展示可用数据
            if let profile = profile {
                updateProfileUI(profile)
            } else {
                showProfilePlaceholder()
            }
            
            if !posts.isEmpty {
                updatePostsUI(posts)
            }
            
            if let friends = friends {
                updateFriendsUI(friends)
            }
        }
        .store(in: &cancellables)
}

eraseToAnyPublisher 的作用 #

为什么需要类型擦除 #

Combine 的类型系统非常严格,链式操作会产生复杂的嵌套类型:

// 没有类型擦除时的类型签名(简化版)
let publisher: Publishers.Map<
    Publishers.ReplaceError<
        Publishers.Map<URLSession.DataTaskPublisher, UserProfile>,
        UserProfile?
    >,
    UserProfile?
> 

// 使用 eraseToAnyPublisher 后
let publisher: AnyPublisher<UserProfile?, Never>  // 清晰简洁

使用场景 #

// 1. 作为函数返回值
func fetchData() -> AnyPublisher<Data, Never> {
    return URLSession.shared
        .dataTaskPublisher(for: url)
        .map(\.data)
        .replaceError(with: Data())
        .eraseToAnyPublisher()  // 简化返回类型
}

// 2. 存储为属性
class ViewModel {
    // 不用 eraseToAnyPublisher 会导致类型过于复杂
    var dataPublisher: AnyPublisher<String, Never>
    
    init() {
        self.dataPublisher = Just("Hello")
            .delay(for: .seconds(1), scheduler: RunLoop.main)
            .eraseToAnyPublisher()
    }
}

实战案例:医疗App的数据加载 #

以下是一个真实的医疗应用场景,展示如何优雅处理多个API的并行调用:

class PatientDataLoader {
    
    func loadPatientData(patientId: String) {
        // 四个并行API调用
        let kanbanPublisher = loadKanbanData(patientId: patientId)
            .replaceError(with: nil)
            .eraseToAnyPublisher()
        
        let lisPublisher = loadLISIndicators(patientId: patientId)
            .replaceError(with: nil)
            .eraseToAnyPublisher()
        
        let summaryPublisher = loadMedicalSummary(patientId: patientId)
            .replaceError(with: nil)
            .eraseToAnyPublisher()
        
        let tasksPublisher = loadTodoTasks(patientId: patientId)
            .replaceError(with: nil)
            .eraseToAnyPublisher()
        
        Publishers.Zip4(
            kanbanPublisher,
            lisPublisher,
            summaryPublisher,
            tasksPublisher
        )
        .receive(on: DispatchQueue.main)
        .sink { _ in
            // 所有请求完成(成功或失败)
            self.hideLoadingIndicator()
        } receiveValue: { (kanban, lis, summary, tasks) in
            // 渐进式UI更新
            if let kanban = kanban {
                self.updateKanbanView(kanban)
            }
            
            if let lis = lis {
                self.updateLISView(lis)
            }
            
            if let summary = summary {
                self.updateSummaryView(summary)
            }
            
            if let tasks = tasks {
                self.updateTasksView(tasks)
            }
            
            // 即使部分数据失败,用户仍能看到可用信息
            self.refreshUI()
        }
        .store(in: &cancellables)
    }
}

最佳实践 #

1. 选择合适的默认值 #

// 根据业务场景选择默认值
.replaceError(with: nil)           // 可选类型
.replaceError(with: [])            // 空集合
.replaceError(with: defaultModel)  // 默认模型
.replaceError(with: .unknown)      // 枚举默认值

2. 记录错误但继续执行 #

publisher
    .handleEvents(receiveCompletion: { completion in
        if case .failure(let error) = completion {
            // 记录错误用于监控,但不中断流程
            Logger.log(error: error)
        }
    })
    .replaceError(with: defaultValue)
    .eraseToAnyPublisher()

3. 条件性容错 #

extension Publisher {
    func replaceErrorConditionally<T>(
        with defaultValue: T,
        when condition: @escaping (Failure) -> Bool
    ) -> AnyPublisher<Output, Never> where Output == T {
        self.catch { error -> AnyPublisher<T, Never> in
            if condition(error) {
                return Just(defaultValue).eraseToAnyPublisher()
            } else {
                // 某些错误不容错,直接失败
                return Fail(error: error).eraseToAnyPublisher()
            }
        }
        .eraseToAnyPublisher()
    }
}

性能考虑 #

并行 vs 串行 #

// ✅ 并行:使用 Zip,所有请求同时发起
Publishers.Zip3(api1, api2, api3)

// ❌ 串行:使用 flatMap 链式调用,耗时更长
api1.flatMap { _ in api2 }
    .flatMap { _ in api3 }

内存管理 #

class ViewModel {
    private var cancellables = Set<AnyCancellable>()
    
    deinit {
        // 自动取消所有订阅,防止内存泄漏
        cancellables.forEach { $0.cancel() }
    }
}

总结 #

核心要点 #

  1. Publishers.Zip 默认是"全有或全无"的并行操作
  2. replaceError(with:) 将错误转换为默认值,实现容错
  3. eraseToAnyPublisher() 简化类型签名,提高代码可读性
  4. 容错处理让应用更加健壮,提升用户体验

设计哲学 #

“完美是优秀的敌人。” - 伏尔泰

在实际应用中,展示部分数据往往比展示错误页面更有价值。通过容错设计,我们可以:

  • 提供渐进式的用户体验
  • 降低单点故障的影响
  • 让应用在网络不稳定时仍然可用

进阶阅读 #


关键词: Swift, Combine, Publishers.Zip, replaceError, eraseToAnyPublisher, iOS开发, 容错处理, 异步编程, 错误处理