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 中亲手实验,巩固理解。

相关推荐
90后的晨仔1 小时前
Combine 高级操作符:掌控数据流的节奏与方向
ios
90后的晨仔1 小时前
Combine 与 SwiftUI 集成:构建响应式 UI 的黄金搭档
ios
2501_916007473 小时前
Xcode支持的编程语言、主要功能及使用指南
ide·vscode·macos·ios·个人开发·xcode·敏捷流程
MonkeyKing4 小时前
iOS 深入理解 UIView 与 CALayer:关系、渲染流程与坐标系
ios
君子木4 小时前
解决ios App的webview不支持<video>标签行内播放的问题(点击播放按钮后会直接全拼播放)
ios
游戏开发爱好者84 小时前
iOS应用性能监控:Pre-Main与Main函数耗时分析及Time Profiler使用教程
android·ios·小程序·https·uni-app·iphone·webview
UXbot7 小时前
AI 原型工具对比(2026):从文字描述到完整 App 界面的 5 款主流平台评测
android·前端·ios·交互·软件构建
人月神话-Lee19 小时前
【图像处理】亮度与对比度——图像的线性变换
图像处理·人工智能·ios·ai编程·swift
AITOP1001 天前
高德联合千问开源AGenUI:让Agent UI同时跑在iOS、安卓和鸿蒙上
ui·ios·开源