Combine 的强大之处不仅在于它提供了 Publisher 和 Subscriber,更在于它提供了丰富、可组合的操作符。你可以把操作符理解为构建数据处理管道的"乐高积木",通过将它们链式组合,就能以声明式的方式完成复杂的异步逻辑------过滤、转换、聚合、时间控制、错误恢复,一切信手拈来。
1. 操作符概述
操作符是 Combine 的灵魂。它们遵循"输入一个 Publisher,输出另一个 Publisher"的模式,让你可以在数据流动的路径上插入各种处理逻辑。根据功能,操作符可以分为以下几类:
理解并熟练运用这些操作符,将使你的 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
scan 与 reduce 类似,但它会发出每一次的中间结果,而不是等到 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.CombineLatest3 和 CombineLatest4 以便组合更多发布者。
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 图解:
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. 工具操作符
在任意位置注入日志打印,记录所有发布者生命周期事件。
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()
}
}
数据流示意图:
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) }
同理,在涉及网络请求的链中,应把 debounce、removeDuplicates 等节流过滤操作放在 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 中亲手实验,巩固理解。