13-15.【Combine】scan / reduce 在状态累积中的应用场景及注意事项?

0 阅读1分钟

在响应式编程中,scanreduce 是处理“流式数据累积”的两大核心算子。它们都接收一个初始值和一个累积闭包,但在生命周期产出时机上有着本质的区别。


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(例如 TimerNotificationCenter 或没有显式完成的 Subject),reduce 将永远不会发出任何值

  • 对策:对于无限流,如果需要中间结果,必须用 scan

⚠️ 注意事项 2:内存压力

reduce 在完成前会一直持有所累积的对象。

  • 风险:如果你正在 reduce 巨大的二进制数据且流非常长,可能会导致内存占用持续飙升直到 OOM(内存溢出)。
  • 对策:在处理大数据流时,考虑使用 scan 配合磁盘写入,或分片处理。

⚠️ 注意事项 3:初值的副作用

scan(initialValue:nextPartialResult:) 中的 initialValue 每次有新订阅者时都会被重新拷贝。

  • 建议:确保初始值是轻量级的结构体。如果初始值涉及昂贵的资源,建议配合 Deferred 使用。

4. 性能与对比

特性scanreduce
产出频率1:11:1(入一个出一个)N:1N:1(入 NN 个出一个)
完成信号要求不需要完成即可输出必须收到完成信号才输出
下游响应高频、实时低频、延迟
适用架构响应式状态机、实时 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