错误处理是 Combine 构建健壮数据流的核心环节。Combine 框架通过强类型系统将错误纳入发布者的契约中:每个 Publisher 不仅声明 Output 类型,还必须声明 Failure 类型(符合 Error 协议)。这种编译时约束让你在链条的任何位置都能预测潜在的失败,并通过一系列专用操作符进行捕获、转换、重试和降级。本章将系统梳理 Combine 的错误模型、内置操作符、恢复策略以及最佳实践,助你应对各种异步场景下的意外状况。
1. Combine 的错误模型
Combine 使用泛型错误类型,每个发布者都有明确的 Failure 类型:
swift
// 可能产生错误的发布者,Failure 为一个具体的错误类型
struct SomePublisher: Publisher {
typealias Output = Data
typealias Failure = URLError
}
// 永远不会产生错误的发布者,Failure 为 Never
struct SafePublisher: Publisher {
typealias Output = String
typealias Failure = Never
}
Never 是一个特殊枚举,无任何 case,表示该发布者绝对不会 以失败终止。几乎所有 UI 绑定的链最终都需要将 Failure 转为 Never,否则类型不匹配。
你可以通过遵守 Error 协议,创建适用于自身领域的错误枚举,并使其遵循 LocalizedError 来提供用户可读的描述:
swift
enum NetworkError: Error {
case invalidURL
case noData
case decodingError(Error)
case serverError(statusCode: Int)
}
extension NetworkError: LocalizedError {
var errorDescription: String? {
switch self {
case .invalidURL: return "无效的 URL"
case .noData: return "未收到数据"
case .decodingError: return "数据解析失败"
case .serverError(let code): return "服务器错误,状态码:\(code)"
}
}
}
2. 可能抛出错误的 try 系列操作符
许多常用操作符都有对应的 try 版本,允许闭包抛出错误。一旦抛出错误,整个链立即终止并传播该错误。
2.1 tryMap
tryMap 在转换过程中可以抛出错误:
swift
let strings = ["1", "2", "invalid", "4"].publisher
strings
.tryMap { str -> Int in
guard let number = Int(str) else {
throw NetworkError.noData // 自定义错误
}
return number
}
.sink(receiveCompletion: { completion in
if case .failure(let error) = completion {
print("转换失败: \(error.localizedDescription)")
}
}, receiveValue: { number in
print("转换成功: \(number)")
})
// 输出:1, 2, 转换失败...
2.2 tryCompactMap
类似 compactMap,但允许抛出错误。适合过滤 nil 的同时处理非法输入:
swift
let inputs = ["1", "2", "?", "4"].publisher
inputs
.tryCompactMap { str -> Int? in
guard str != "?" else { throw NetworkError.invalidURL }
return Int(str)
}
.sink(receiveCompletion: { _ in }, receiveValue: { print($0) })
2.3 tryFilter、tryReduce 等
Combine 还为 filter、reduce、prefix(while:) 等提供了 try 版本。它们都可以在判断条件时抛出错误,让校验逻辑更集中。
swift
let numbers = [10, 20, -5, 30].publisher
numbers
.tryFilter { value in
guard value > 0 else { throw NetworkError.serverError(statusCode: 422) }
return value % 2 == 0
}
.sink(receiveCompletion: { _ in },
receiveValue: { print($0) })
3. 错误处理操作符
Combine 提供了一系列操作符来拦截、替换或重新发布错误。
3.1 catch
catch 是最常用的恢复操作符。当上游失败时,它返回一个新的 Publisher 继续供给值。通常用于降级到默认数据或备用接口。
swift
let subject = PassthroughSubject<String, Error>()
subject
.catch { error -> AnyPublisher<String, Never> in
print("捕获错误: \(error.localizedDescription)")
return Just("默认值").eraseToAnyPublisher() // 将失败转为永不出错
}
.sink { value in
print("收到: \(value)")
}
.store(in: &cancellables)
subject.send("正常")
subject.send(completion: .failure(NetworkError.noData))
// 输出: 收到: 正常, 捕获错误..., 收到: 默认值
catch 返回的新 Publisher 的 Failure 类型可以与原链不同(例如降级为 Never),从而恢复整个链的稳定性。
3.2 tryCatch
tryCatch 是 catch 的抛出错误版本。你可以在处理错误时根据错误种类决定是恢复还是继续抛出。
swift
subject
.tryCatch { error -> AnyPublisher<String, Error> in
if let networkError = error as? NetworkError {
return Just("网络恢复值").setFailureType(to: Error.self).eraseToAnyPublisher()
}
throw error // 其他错误继续传播
}
.sink(receiveCompletion: { _ in }, receiveValue: { print($0) })
3.3 replaceError
replaceError 直接将错误替换为一个固定的默认值,并将 Failure 转为 Never。
swift
subject
.replaceError(with: "因错误被替换")
.sink { value in print("收到: \(value)") }
.store(in: &cancellables)
3.4 mapError
错误类型不匹配时,使用 mapError 进行转换。例如将底层 URLError 转换为业务错误枚举。
swift
let urlPublisher: AnyPublisher<Data, URLError> = ...
urlPublisher
.mapError { error -> NetworkError in
return .networkError(error) // 包装成业务错误
}
.sink(receiveCompletion: { completion in
if case .failure(let networkError) = completion {
// 处理统一的 NetworkError
}
}, receiveValue: { data in
// 使用 data
})
3.5 assertNoFailure
仅用于调试,当链发生错误时触发运行时崩溃,帮助开发者尽早发现逻辑漏洞。切忌在发布版本中使用。
swift
let safePublisher = Just("OK")
safePublisher
.assertNoFailure() // 安全,因为 Failure 是 Never
.sink { print($0) }
4. 重试机制
瞬时故障(如网络抖动)适合自动重试。Combine 提供 retry 操作符,并支持自定义延迟和退避策略。
4.1 基础重试:retry(n)
retry 接受一个次数,在失败时重新订阅上游。
swift
var attempt = 0
let flaky = Future<String, Error> { promise in
attempt += 1
if attempt < 3 {
promise(.failure(NetworkError.serverError(statusCode: 503)))
} else {
promise(.success("最终成功"))
}
}
flaky
.retry(3) // 最多重试3次
.sink(receiveCompletion: { print("结束: \($0)") },
receiveValue: { print("收到: \($0)") })
4.2 带延迟的重试
每次重试之间插入固定延迟,可通过 catch + delay 实现:
swift
func delayedRetry(_ publisher: AnyPublisher<String, Error>, retries: Int, delay: TimeInterval) -> AnyPublisher<String, Error> {
publisher
.catch { error -> AnyPublisher<String, Error> in
guard retries > 0 else { return Fail(error: error).eraseToAnyPublisher() }
return Just(())
.delay(for: .seconds(delay), scheduler: DispatchQueue.global())
.flatMap { _ in delayedRetry(publisher, retries: retries - 1, delay: delay) }
.eraseToAnyPublisher()
}
.eraseToAnyPublisher()
}
4.3 指数退避策略
逐次翻倍等待时间,避免服务端雪崩。实现方式类似,只需在递归中递增 delay:
swift
func exponentialBackoffRetry(_ publisher: AnyPublisher<String, Error>, remainingRetries: Int, delay: TimeInterval) -> AnyPublisher<String, Error> {
publisher
.catch { error -> AnyPublisher<String, Error> in
guard remainingRetries > 0 else { return Fail(error: error).eraseToAnyPublisher() }
return Just(())
.delay(for: .seconds(delay), scheduler: DispatchQueue.global())
.flatMap { _ in exponentialBackoffRetry(publisher, remainingRetries: remainingRetries - 1, delay: delay * 2) }
.eraseToAnyPublisher()
}
.eraseToAnyPublisher()
}
5. 将错误转换为 Never
许多 SwiftUI 绑定点要求发布者的 Failure == Never。通过以下方式可将错误流"封死":
- replaceError(with:):直接用默认值替换,适合显示占位文本。
- catch + Just :返回一个永不出错的
Just。 - assertNoFailure:仅在开发期使用,强制崩溃。
swift
let publisher: AnyPublisher<Int, Error> = ...
// 转为 Never,用 0 替代
publisher
.replaceError(with: 0)
.assign(to: \.count, on: self)
.store(in: &cancellables)
6. 实战:网络请求错误处理
下面是一个完整的 ViewModel,展示了如何在网络层统一处理错误,并组合重试、缓存降级和加载状态。
swift
class DataViewModel: ObservableObject {
@Published var items: [String] = []
@Published var isLoading = false
@Published var errorMessage: String?
private var bag = Set<AnyCancellable>()
func load() {
isLoading = true
errorMessage = nil
URLSession.shared.dataTaskPublisher(for: URL(string: "https://api.example.com/items")!)
.map(\.data)
.decode(type: [String].self, decoder: JSONDecoder())
.mapError { error -> NetworkError in
if let urlError = error as? URLError {
return .networkError(urlError)
} else if error is DecodingError {
return .decodingError(error)
} else {
return .noData
}
}
.retry(2) // 自动重试2次
.catch { [weak self] error -> AnyPublisher<[String], Never> in
// 尝试加载本地缓存
if let cached = self?.loadFromCache() {
return Just(cached).eraseToAnyPublisher()
}
self?.errorMessage = error.localizedDescription
return Just([]).eraseToAnyPublisher()
}
.receive(on: DispatchQueue.main)
.sink { [weak self] items in
self?.isLoading = false
self?.items = items
}
.store(in: &bag)
}
private func loadFromCache() -> [String]? {
// 从本地持久化存储读取
return nil
}
}
该示例展示了以下最佳实践:
- 将底层错误映射为统一的业务错误枚举
NetworkError。 - 使用
retry应对瞬时网络故障。 - 在
catch中尝试本地缓存,确保即使网络彻底不可用,用户也能看到旧数据。 - 所有 UI 更新回到主线程。
7. 最佳实践总结
- 定义清晰的错误枚举 :遵循
LocalizedError,提供用户友好的错误信息。 - 在合适的层级处理错误:数据层抛出原始错误,ViewModel 层转换为界面可展示的提示。
- 始终提供降级方案:网络失败时展示缓存、默认占位或友好提示。
- 适量重试:仅对瞬态错误重试,配合延迟和指数退避,避免服务端压力。
- 使用 mapError 保持类型一致:让业务层使用统一的错误类型,简化后续处理。
- 将 UI 绑定链的错误转为 Never :通过
replaceError或catch终结错误流。 - 调试时善用 assertNoFailure,Release 移除。
- 永远存储订阅 :重试产生的新订阅也需要放入
Set<AnyCancellable>。
8. 总结
Combine 提供了一整套完整的错误处理工具集:从 try 系列操作符让闭包能够抛出错误,到 catch、replaceError、retry 等操作符实现恢复与重试,再到类型安全的 mapError 和 Never 终结。掌握这些手段后,你就能构建出即使面对网络波动、数据异常、服务端错误也依然稳固的响应式数据流。健壮的应用,正是从严谨的错误处理开始的。