Combine 错误处理与恢复:构建健壮的应用防线

错误处理是 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 还为 filterreduceprefix(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

tryCatchcatch 的抛出错误版本。你可以在处理错误时根据错误种类决定是恢复还是继续抛出。

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 :通过 replaceErrorcatch 终结错误流。
  • 调试时善用 assertNoFailure,Release 移除
  • 永远存储订阅 :重试产生的新订阅也需要放入 Set<AnyCancellable>

8. 总结

Combine 提供了一整套完整的错误处理工具集:从 try 系列操作符让闭包能够抛出错误,到 catchreplaceErrorretry 等操作符实现恢复与重试,再到类型安全的 mapErrorNever 终结。掌握这些手段后,你就能构建出即使面对网络波动、数据异常、服务端错误也依然稳固的响应式数据流。健壮的应用,正是从严谨的错误处理开始的。

相关推荐
90后的晨仔1 小时前
Combine 多线程与调度器:掌控数据流的执行线程
ios
冰凌时空2 小时前
iOS 架构模式全景图:MVC / MVVM / VIPER / Clean Architecture 选型指南
ios·openai·ai编程
冰凌时空2 小时前
Swift 类型系统入门:从 Int、String 到自定义类型
前端·ios·ai编程
pop_xiaoli16 小时前
【iOS】autoreleasePool
ios·objective-c·cocoa
秋雨梧桐叶落莳18 小时前
iOS——ZARA仿写项目
学习·macos·ios·objective-c·cocoa
人月神话Lee18 小时前
【图像处理】二值化与阈值——从灰度到黑白的决策
ios·ai编程·图像识别
美狐美颜SDK开放平台21 小时前
美颜SDK接入流程详解:Android、iOS、鸿蒙兼容方案解析
android·人工智能·ios·华为·harmonyos·美颜sdk·视频美颜sdk
90后的晨仔1 天前
Combine 操作符 —— 打造强大的数据处理管道
ios
90后的晨仔1 天前
Combine 高级操作符:掌控数据流的节奏与方向
ios