Combine 操作符 —— 打造强大的数据处理管道

Combine 的强大之处不仅在于它提供了 Publisher 和 Subscriber,更在于它提供了丰富、可组合的操作符。你可以把操作符理解为构建数据处理管道的"乐高积木",通过将它们链式组合,就能以声明式的方式完成复杂的异步逻辑------过滤、转换、聚合、时间控制、错误恢复,一切信手拈来。

1. 操作符概述

操作符是 Combine 的灵魂。它们遵循"输入一个 Publisher,输出另一个 Publisher"的模式,让你可以在数据流动的路径上插入各种处理逻辑。根据功能,操作符可以分为以下几类:

mindmap root((Combine 操作符)) 转换操作符 map tryMap compactMap flatMap switchToLatest reduce scan 过滤操作符 filter removeDuplicates first/last dropFirst/drop(while:) prefix/prefix(while:) ignoreOutput 组合操作符 combineLatest merge zip append/prepend 时间操作符 debounce throttle delay timeout measureInterval 错误处理 catch/tryCatch replaceError retry assertNoFailure 工具操作符 print handleEvents breakpoint/breakpointOnError share/multicast 高级操作符 collect replaceNil/replaceEmpty allSatisfy/contains count/min/max

理解并熟练运用这些操作符,将使你的 Combine 代码简洁、高效且易于维护。

2. 转换操作符

map

map 是使用频率最高的操作符,它类似于 Swift 标准库的 map,将每一个上游值通过闭包转换为新的值。

swift 复制代码
let numbers = [1, 2, 3, 4, 5].publisher

numbers
    .map { $0 * 2 }
    .sink { value in
        print(value) // 2, 4, 6, 8, 10
    }

tryMap

tryMap 在转换过程中允许抛出错误,一旦抛出错误,整个序列立即以 failure 终止。

swift 复制代码
let numbers = [1, 2, -3, 4].publisher

numbers
    .tryMap { number in
        if number < 0 {
            throw NSError(domain: "math", code: 1, userInfo: nil)
        }
        return number * 2
    }
    .sink(receiveCompletion: { completion in
        if case .failure(let error) = completion {
            print("错误: \(error)")
        }
    }, receiveValue: { value in
        print(value)
    })
// 输出: 2, 4, 错误

compactMap

compactMap 会自动过滤 nil 值,相当于 Swift 标准库的 compactMap

swift 复制代码
let strings = ["1", "2", "three", "4", "five"].publisher

strings
    .compactMap { Int($0) }
    .sink { print($0) } // 1, 2, 4

flatMap

flatMap 是最常用但也最容易混淆的转换操作符之一。它可以将一个 Publisher 的每个值转换为另一个 Publisher,并将这些内部 Publisher 发出的值"展平"到一个单一的序列中。与 map 不同,flatMap 的闭包需要返回一个 Publisher。

swift 复制代码
let numbers = [1, 2, 3].publisher

numbers
    .flatMap { value in
        // 为每个输入值创建一个新的 Just Publisher
        Just("数字: \(value)")
    }
    .sink { value in
        print(value)
    }
// 输出: 数字: 1, 数字: 2, 数字: 3

flatMap 的高级控制 ------ maxPublishers

当闭包返回的 Publisher 是异步的(比如网络请求),flatMap 默认会同时订阅所有内部 Publisher,可能导致请求风暴。通过 maxPublishers 参数可以限制同时活跃的 Publisher 数量。

swift 复制代码
let subject = PassthroughSubject<Int, Never>()

subject
    .flatMap(maxPublishers: .max(2)) { value in
        Just(value)
            .delay(for: .seconds(1), scheduler: DispatchQueue.main)
    }
    .sink { value in
        print("处理完: \(value)")
    }

subject.send(1) // 立即处理
subject.send(2) // 立即处理
subject.send(3) // 等待前面两个完成后再处理

switchToLatest

switchToLatest 适用于嵌套的 Publisher 场景:当上游 Publisher 发出新值时,它会取消前一个内部 Publisher 的订阅,转而订阅最新的那个。这是实现"搜索建议"模式的理想选择。

swift 复制代码
let searchText = PassthroughSubject<String, Never>()

searchText
    .map { query in
        // 模拟网络搜索,每个搜索请求返回一个 Publisher
        Just("结果 for \(query)")
            .delay(for: .seconds(2), scheduler: DispatchQueue.main)
    }
    .switchToLatest()
    .sink { print($0) }

searchText.send("S")
searchText.send("Sw")
searchText.send("Swi")
searchText.send("Swift")
// 只有 "结果 for Swift" 会被打印,之前的请求全部被取消

reduce

reduce 是聚合操作符,它会在上游 Publisher 完成之后,将所有值通过指定的闭包聚合成一个最终值,然后发送该单一值并完成。

swift 复制代码
let numbers = [1, 2, 3, 4, 5].publisher

numbers
    .reduce(0, +) // 等价于 .reduce(0) { $0 + $1 }
    .sink { value in
        print("总和: \(value)") // 15
    }

scan

scanreduce 类似,但它会发出每一次的中间结果,而不是等到 Publisher 完成才发一个值。它非常适合计算累积值、步进状态等场景。

swift 复制代码
let numbers = [1, 2, 3, 4, 5].publisher

numbers
    .scan(0) { accumulated, element in
        accumulated + element
    }
    .sink { value in
        print(value) // 1, 3, 6, 10, 15
    }

3. 过滤操作符

filter

只允许满足条件的值通过。

swift 复制代码
(1...10).publisher
    .filter { $0 % 2 == 0 }
    .sink { print($0) } // 2, 4, 6, 8, 10

removeDuplicates

如果连续两个值相等(或满足自定义条件),第二个值会被忽略。默认依赖于 Equatable,也可以提供闭包。

swift 复制代码
let values = [1, 2, 2, 3, 3, 3, 4].publisher
values
    .removeDuplicates()
    .sink { print($0) } // 1, 2, 3, 4

// 自定义去重逻辑:按 id 去重
struct User { let id: Int; let name: String }
let users = [User(id: 1, name: "A"), User(id: 1, name: "A2"), User(id: 2, name: "B")].publisher
users
    .removeDuplicates { $0.id == $1.id }
    .sink { print($0.name) } // A, B

first / last

分别获取第一个或最后一个满足条件的值。它们在使用闭包时,会在找到后立即完成序列(first)或等到上游完成后才发送(last)。

swift 复制代码
let numbers = [1, 2, 3, 4, 5].publisher

numbers
    .first { $0 % 2 == 0 }
    .sink { print($0) } // 2

numbers
    .last { $0 % 2 == 1 }
    .sink { print($0) } // 5

dropFirst / drop(while:)

  • dropFirst(_ count:):忽略前 count 个值。
  • drop(while:):忽略满足条件的值,一旦条件不满足,后续值全部通过。
swift 复制代码
let numbers = [1, 2, 3, 4, 5].publisher

numbers.dropFirst(2)
    .sink { print($0) } // 3, 4, 5

numbers.drop { $0 < 3 }
    .sink { print($0) } // 3, 4, 5

prefix / prefix(while:)

  • prefix(_ maxLength:):只取前 maxLength 个值,之后立即完成。
  • prefix(while:):取满足条件的值,一旦条件不满足,序列完成。
swift 复制代码
numbers.prefix(3)
    .sink { print($0) } // 1, 2, 3

numbers.prefix { $0 < 3 }
    .sink { print($0) } // 1, 2

ignoreOutput

完全忽略所有值,只等待完成事件。可用于你只关心操作完成的场景。

swift 复制代码
let _ = [1,2,3].publisher
    .ignoreOutput()
    .sink(receiveCompletion: { print("完成") },
          receiveValue: { print("不会执行") })

4. 组合操作符

combineLatest

combineLatest 订阅多个 Publisher,当任意一个发出新值时,它会与其他 Publisher 的最新值组合成一个元组发出。注意:所有 Publisher 都必须至少发出过一个值之后,才会开始输出组合结果。

swift 复制代码
let pub1 = PassthroughSubject<Int, Never>()
let pub2 = PassthroughSubject<String, Never>()

pub1.combineLatest(pub2)
    .sink { intVal, strVal in
        print("\(intVal) - \(strVal)")
    }

pub1.send(1)
pub2.send("A") // 输出 "1 - A"
pub1.send(2)   // 输出 "2 - A"
pub2.send("B") // 输出 "2 - B"

实际应用 :SwiftUI 表单联合验证。Combine 提供了 Publishers.CombineLatest3CombineLatest4 以便组合更多发布者。

swift 复制代码
Publishers.CombineLatest3($isUsernameValid, $isEmailValid, $isPasswordValid)
    .map { $0 && $1 && $2 }
    .assign(to: \.isFormValid, on: self)

merge

merge 将多个同类型 Publisher 的事件交织合并到一个序列中,不改变值,只按时间顺序传递。

swift 复制代码
let pub1 = [1, 2, 3].publisher
let pub2 = [4, 5, 6].publisher

pub1.merge(with: pub2)
    .sink { print($0) } // 1,2,3,4,5,6 (顺序可能穿插)

zip

zip 严格地将多个 Publisher 的值按索引配对:必须等所有 Publisher 都发出新值后,才将配对结果向下游发出。它常用于需要并行请求结果组合的场景。

swift 复制代码
let nums = [1, 2, 3].publisher
let letters = ["A","B","C"].publisher
nums.zip(letters)
    .sink { print("\($0) : \($1)") }
// 1 : A, 2 : B, 3 : C

append / prepend

在原始序列的末尾(append)或开头(prepend)插入其他 Publisher 的值或单个值。

swift 复制代码
[1,2,3].publisher
    .append(4, 5)
    .sink { print($0) } // 1,2,3,4,5

[4,5].publisher
    .prepend(1, 2, 3)
    .sink { print($0) } // 1,2,3,4,5

5. 时间操作符

debounce

debounce 会等待一段"静默期":只有当上游停止发出值达到指定时长后,才会将最新的值发出。常用于搜索框输入防抖。

swift 复制代码
let subject = PassthroughSubject<String, Never>()
subject
    .debounce(for: .seconds(0.5), scheduler: DispatchQueue.main)
    .sink { print("搜索: \($0)") }

subject.send("S")
subject.send("Sw")
subject.send("Swi")
subject.send("Swift")
// 0.5秒后输出 "搜索: Swift"

throttle

throttle 在固定时间窗口内,只让一个值通过(可以是第一个或最后一个)。与 debounce 不同,它保证每一个时间窗口最多发出一个值,而 debounce 是等待静默。

  • latest: true:发送窗口内最新的值。
  • latest: false:发送窗口内第一个值。
swift 复制代码
subject
    .throttle(for: .seconds(1), scheduler: DispatchQueue.main, latest: true)
    .sink { print("节流(最新): \($0)") }

debounce vs throttle 图解:

gantt dateFormat X axisFormat %s section 输入 值1 :milestone, m1, 0, 0s 值2 :milestone, m2, 1, 1s 值3 :milestone, m3, 3, 3s 值4 :milestone, m4, 5, 5s section debounce(0.5s) 等待 :done, d1, 0, 0.5s 发送1 :crit, done, 0.5, 0s 等待 :done, d2, 1, 1.5s 发送2 :crit, done, 1.5, 0s 等待 :done, d3, 3, 3.5s 发送3 :crit, done, 3.5, 0s 等待 :done, d4, 5, 5.5s 发送4 :crit, done, 5.5, 0s section throttle(1s, latest) 窗口1 :done, t1, 0, 1s 发送值2 :crit, after t1, 0s 窗口2 :done, t2, 1, 2s 发送值3 :crit, after t2, 1s 发送值4 :crit, after t2, 2s

delay

延迟所有值和完成事件的发送。

swift 复制代码
[1,2,3].publisher
    .delay(for: .seconds(2), scheduler: DispatchQueue.main)
    .sink { print($0) }

timeout

如果在指定时间内上游没有发出任何值,会以 .finished 或自定义错误完成。

swift 复制代码
let subject = PassthroughSubject<String, Never>()
subject
    .timeout(.seconds(5), scheduler: DispatchQueue.main,
             customError: { .timeoutError })
    .sink(receiveCompletion: { print($0) },
          receiveValue: { print($0) })

measureInterval

测量相邻两个值发出的时间间隔,通常用于性能分析。

swift 复制代码
Timer.publish(every: 2, on: .main, in: .common).autoconnect()
    .measureInterval(using: RunLoop.main)
    .sink { print("间隔: \($0)") }

6. 错误处理操作符

catch

捕获上游错误,并返回一个"恢复用"的 Publisher,后续的值由这个新 Publisher 提供。用来实现降级策略。

swift 复制代码
let failing = Fail<String, Error>(error: NSError(domain: "test", code: 0))
failing
    .catch { _ in Just("恢复值") }
    .sink { print($0) } // "恢复值"

tryCatch

类似 catch,但其闭包允许抛出错误。如果闭包抛出错误,链路仍然会以该错误终止。

replaceError

将错误替换为一个固定的默认值,并完成流。

swift 复制代码
failing
    .replaceError(with: "默认")
    .sink { print($0) }

retry

当上游以 failure 完成时,自动重新订阅它。可以指定最大重试次数。

swift 复制代码
var attempts = 0
let flaky = Future<Int, Error> { promise in
    attempts += 1
    attempts < 3 ? promise(.failure(...)) : promise(.success(42))
}
flaky.retry(3)
    .sink(receiveCompletion: { print($0) }, receiveValue: { print($0) })

assertNoFailure

开发调试用,如果上游发送了错误,会在调试中断言崩溃。永远不要在 Release 中使用。

7. 工具操作符

print

在任意位置注入日志打印,记录所有发布者生命周期事件。

swift 复制代码
let numbers = [1,2,3].publisher
numbers
    .print("调试")
    .sink { _ in }

handleEvents

在不影响数据流的情况下,监听订阅、值、完成、取消等事件,是调试和统计上报的好帮手。

swift 复制代码
publisher
    .handleEvents(receiveSubscription: { _ in print("开始") },
                  receiveOutput: { print("值: \($0)") },
                  receiveCompletion: { print("结束") })
    .sink { _ in }

breakpoint / breakpointOnError

允许在满足条件时进入 Xcode 调试器。breakpointOnError 是遇到错误即触发的简化版。

swift 复制代码
publisher
    .breakpointOnError()
    .sink(...)

share / multicast

share() 用于将上游 Publisher 的订阅"共享"给多个下游订阅者,避免重复执行昂贵操作(如网络请求)。它返回一个 Publishers.Share,底层的 Publisher 只被订阅一次。

multicast 则提供了更大的控制权:你可以传入一个 Subject,所有订阅者订阅的是这个 Subject,然后通过 .connect() 手动启动上游。

swift 复制代码
let shared = someExpensivePublisher.share()
// 两者共享同一底层订阅
shared.sink { ... }
shared.sink { ... }

8. 高级操作符

collect

收集一定数量的值或全部值,并将它们作为一个数组发出。极为实用的批量处理操作符。

swift 复制代码
[1,2,3,4,5].publisher
    .collect()      // 输出: [1,2,3,4,5]
    .collect(2)     // 输出: [1,2], [3,4], [5]
    .sink { print($0) }

replaceNil

将可选值序列中的 nil 替换为默认值,需要指明默认值。

swift 复制代码
[1, nil, 3, nil].publisher
    .replaceNil(with: 0)
    .sink { print($0) } // Optional(1), Optional(0), Optional(3), Optional(0)

replaceEmpty

如果 Publisher 没有发送任何值就完成了,则发送一个默认值后完成。

swift 复制代码
Empty<Int, Never>()
    .replaceEmpty(with: -1)
    .sink { print($0) } // -1

allSatisfy

检查所有值是否都满足给定的条件,会在上游完成后发出一个 Bool 值。

swift 复制代码
[1,2,3,4,5].publisher
    .allSatisfy { $0 < 10 }
    .sink { print($0) } // true

contains

检查是否包含某个特定值,同样需要上游完成。

swift 复制代码
[1,2,3].publisher
    .contains(2)
    .sink { print($0) } // true

count

统计上游发送值的总数,上游完成后发出。

swift 复制代码
[1,2,3,4,5].publisher
    .count()
    .sink { print($0) } // 5

min / max

找出最小或最大值,要求 Output 类型遵循 Comparable,或者提供自定义比较器。同样需要上游完成。

swift 复制代码
[3, 1, 4, 1, 5, 9].publisher
    .min()
    .sink { print($0) } // 1

9. 操作符链实战:优雅的搜索体验

下面展示如何利用操作符链构建一个完整的搜索功能,包含防抖、去重、网络请求、加载状态和错误处理。

swift 复制代码
import Combine

class SearchViewModel: ObservableObject {
    @Published var query = ""
    @Published var results: [String] = []
    @Published var isLoading = false

    private var bag = Set<AnyCancellable>()

    init() {
        $query
            .debounce(for: .seconds(0.5), scheduler: DispatchQueue.main)
            .removeDuplicates()
            .filter { !$0.isEmpty }
            .flatMap { [weak self] query -> AnyPublisher<[String], Never> in
                self?.isLoading = true
                return self?.performSearch(query) ?? Just([]).eraseToAnyPublisher()
            }
            .receive(on: DispatchQueue.main)
            .sink { [weak self] results in
                self?.results = results
                self?.isLoading = false
            }
            .store(in: &bag)
    }

    private func performSearch(_ query: String) -> AnyPublisher<[String], Never> {
        // 模拟网络请求
        return Just(["\(query) 结果1", "\(query) 结果2"])
            .delay(for: .seconds(1), scheduler: DispatchQueue.global())
            .catch { _ in Just([]) }
            .eraseToAnyPublisher()
    }
}

数据流示意图:

graph LR A[用户输入] -->|@Published query| B[debounce 0.5s] B --> C[removeDuplicates] C --> D[filter 非空] D --> E[flatMap 网络请求] E --> F[receive on Main] F --> G[更新 results] E -.->|加载开始| H[isLoading = true] G -.->|加载结束| I[isLoading = false]

10. 操作符最佳实践

1. 组合顺序至关重要

操作符链按照声明的顺序依次执行,上游的输出作为下游的输入。因此,先过滤后转换能减少不必要的计算量。

swift 复制代码
// ✅ 推荐:先过滤,再转换
[1, 2, 3, 4, 5].publisher
    .filter { $0 % 2 == 0 }      // 只允许偶数通过
    .map { $0 * $0 }             // 对通过的值进行平方运算
    .sink { print($0) }          // 输出 4, 16

// ❌ 不推荐:先转换后过滤,会浪费计算资源
[1, 2, 3, 4, 5].publisher
    .map { $0 * $0 }             // 对所有值进行平方
    .filter { $0 % 2 == 0 }      // 再过滤偶数
    .sink { print($0) }

同理,在涉及网络请求的链中,应把 debounceremoveDuplicates 等节流过滤操作放在 flatMap 之前,避免发出多余的请求。

2. 类型擦除

复杂的操作符链会生成嵌套的泛型类型,如 Publishers.FlatMap<Publishers.Map<PassthroughSubject<String, Never>, Just<String, Never>>, ...>。将链的返回类型声明为这种具体类型是极为脆弱的。使用 eraseToAnyPublisher() 可以将类型模糊化为 AnyPublisher<Output, Failure>,隐藏实现细节。

swift 复制代码
// 复杂链,返回类型极长
func search(query: String) -> AnyPublisher<[String], Error> {
    return URLSession.shared.dataTaskPublisher(for: url)
        .map(\.data)
        .decode(type: [String].self, decoder: JSONDecoder())
        .catch { _ in Just([]).setFailureType(to: Error.self) }
        .eraseToAnyPublisher()  // 包装为简洁的 AnyPublisher
}

3. 错误处理的位置

错误会导致整个发布者链终止。对于可能失败的异步操作,在内部 Publisher 中处理错误,可以防止错误冒泡到外层链导致整个订阅关闭。

swift 复制代码
// ❌ 错误冒泡:searchFailed 发出的错误会终结整个 $query 的订阅
$query
    .flatMap { query in
        searchFailed(query)       // 一旦失败,外部订阅取消
    }
    .sink(receiveCompletion: { ... }, receiveValue: { ... })

// ✅ 内部处理:catch 放在 flatMap 返回的 Publisher 内部
$query
    .flatMap { query in
        searchFailed(query)
            .catch { _ in Just([]) }  // 替换为空数组,保证外部流不会中断
    }
    .sink { results in
        // 永远不会收到错误 completion
    }

4. 内存管理

所有订阅必须被 AnyCancellable 持有,否则订阅会立即被取消。典型做法是使用 Set<AnyCancellable> 统一管理。

swift 复制代码
class ViewModel {
    private var cancellables = Set<AnyCancellable>()

    func bind() {
        publisher
            .sink { [weak self] value in
                self?.process(value)
            }
            .store(in: &cancellables)  // 存储到 Set 中
    }
    // 当 ViewModel 被释放时,cancellables 自动取消所有订阅
}

注意assign(to:on:) 返回的 AnyCancellable 也需要存储,否则 KVO 绑定会直接失效。

5. 可读性

为复杂的操作符链合理换行并添加注释,能够极大提升可维护性。

swift 复制代码
searchSubject
    .debounce(for: .milliseconds(300), scheduler: DispatchQueue.main)  // 防抖
    .removeDuplicates()                                                // 去重
    .filter { !$0.isEmpty }                                            // 忽略空字符串
    .flatMap { query in                                                // 执行搜索
        APIService.search(query)
            .retry(2)                                                  // 失败自动重试2次
            .catch { _ in Just([]) }                                   // 失败降级空数组
    }
    .receive(on: DispatchQueue.main)                                   // 回到主线程
    .sink { [weak self] results in
        self?.updateUI(with: results)
    }
    .store(in: &cancellables)

通过清晰的换行、缩进和注释,链的结构一目了然,新人接手时也不会感到困惑。

11. 总结

Combine 操作符是构建响应式数据流的基石。本章覆盖了从基础转换、过滤到高级组合、时间控制、错误恢复及调试工具的数十个操作符。掌握它们的关键在于理解每个操作符的输入输出时机以及副作用,并能在实践中根据需求灵活组合。建议经常查阅 Apple 官方文档,并在 Playground 中亲手实验,巩固理解。

相关推荐
黑化旺仔5 小时前
【OC】KVO
macos·ios·objective-c·cocoa
HouWan7 小时前
SwiftUI 小技巧:如何读取 SwiftUI Font 的详细信息
ios·swiftui·apple
李游Leo8 小时前
HarmonyOS 7 实战开发 05:把页面做到可上线状态
ios·harmonyos
终端安全笔记13 小时前
描述文件、企业证书、MDM 不是三个名字,是三层
android·ios·智能手机
传奇开心果编程13 小时前
【SwiftUI入门练中学】第1课 从零开始
学习·ui·ios·swiftui·swift
丿Wayne19 小时前
iOS Token 刷新实战(Actor 避免并发重复刷新)
ios·swift·token·actor·swift并发
终端安全笔记2 天前
iOS 27 给了租赁一个新工具,但它只认受监督的设备
android·网络·安全·ios
大熊猫侯佩2 天前
独立 App 首发完成 iPhone Duo 展开态适配
ios·swiftui·swift
光影少年2 天前
setImmediate 和 setTimeout(0) 的区别
android·前端·react.js·ios·前端框架
2501_916008892 天前
SSE 和 gRPC 流式接口如何抓包,调试 SSE 与 gRPC 流式接口
网络协议·计算机网络·网络安全·ios·adb·https·udp