在响应式编程中,scan 和 reduce 是处理“流式数据累积”的两大核心算子。它们都接收一个初始值和一个累积闭包,但在生命周期和产出时机上有着本质的区别。
1. 核心定义:过程 vs 结果
-
scan(增量累积) :每当上游发来一个新值,它都会与当前的累积值结合,并立即向下游发出中间结果。
- 比喻:像是在跑马拉松时,每跑一公里,计时器都会告诉你当前的累计总时间。
-
reduce(最终汇总) :它会默默地在内存中进行累积,直到上游发送
.finished信号时,才会将最终的结果一次性发出。- 比喻:像是考试,只有交卷的那一刻,老师才会算出你的总分。
2. 典型应用场景
A. 状态管理 (选 scan)
在 SwiftUI 架构(如 TCA 或简单的 Reducer 模式)中,scan 是管理 State 的核心。
- 场景:根据一系列 Action 连续计算最新的 UI State。
- 原因:UI 需要实时反映状态的变化,不能等到流结束(通常流永远不会结束)。
Swift
actionSubject
.scan(InitialState()) { currentState, action in
return apply(action, to: currentState)
}
.assign(to: &$state)
B. 复杂计算的中间态 (选 scan)
- 场景:计算文件下载的实时进度或平均速度。
- 原因:你需要不断更新进度条,而不是只在下载完成时弹个框。
C. 批量数据聚合 (选 reduce)
- 场景:将一个包含 1000 个数字的
Sequence转换成它们的总和;或者将多个 Data 片段拼接成一个完整的 Data。 - 原因:你只关心最终的完整对象,中间的半成品没有意义。
3. 重要注意事项与防御式设计
⚠️ 注意事项 1:reduce 的“静默”陷阱
如果上游 Publisher 永远不发 .finished(例如 Timer、NotificationCenter 或没有显式完成的 Subject),reduce 将永远不会发出任何值。
- 对策:对于无限流,如果需要中间结果,必须用
scan。
⚠️ 注意事项 2:内存压力
reduce 在完成前会一直持有所累积的对象。
- 风险:如果你正在
reduce巨大的二进制数据且流非常长,可能会导致内存占用持续飙升直到 OOM(内存溢出)。 - 对策:在处理大数据流时,考虑使用
scan配合磁盘写入,或分片处理。
⚠️ 注意事项 3:初值的副作用
scan(initialValue:nextPartialResult:) 中的 initialValue 每次有新订阅者时都会被重新拷贝。
- 建议:确保初始值是轻量级的结构体。如果初始值涉及昂贵的资源,建议配合
Deferred使用。
4. 性能与对比
| 特性 | scan | reduce |
|---|---|---|
| 产出频率 | (入一个出一个) | (入 个出一个) |
| 完成信号要求 | 不需要完成即可输出 | 必须收到完成信号才输出 |
| 下游响应 | 高频、实时 | 低频、延迟 |
| 适用架构 | 响应式状态机、实时 UI | 批处理、数据转换、数学归约 |
5. 进阶技巧:用 scan 模拟 removeDuplicates
通过 scan 记录上一次的值,你可以实现更复杂的去重逻辑,例如“只有当变化率超过 10% 时才更新”:
Swift
sensorPublisher
.scan((last: 0.0, shouldEmit: false)) { acc, newValue in
let diff = abs(newValue - acc.last)
return (newValue, diff > 0.1)
}
.filter { $0.shouldEmit }
.map { $0.last }
总结
- 如果你需要追踪变化,选择
scan。 - 如果你只需要最终结论,选择
reduce。