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 个 PublisherPublishers.Zip3: 组合 3 个 PublisherPublishers.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() }
}
}
总结 #
核心要点 #
- Publishers.Zip 默认是"全有或全无"的并行操作
- replaceError(with:) 将错误转换为默认值,实现容错
- eraseToAnyPublisher() 简化类型签名,提高代码可读性
- 容错处理让应用更加健壮,提升用户体验
设计哲学 #
“完美是优秀的敌人。” - 伏尔泰
在实际应用中,展示部分数据往往比展示错误页面更有价值。通过容错设计,我们可以:
- 提供渐进式的用户体验
- 降低单点故障的影响
- 让应用在网络不稳定时仍然可用
进阶阅读 #
关键词: Swift, Combine, Publishers.Zip, replaceError, eraseToAnyPublisher, iOS开发, 容错处理, 异步编程, 错误处理