Flow 内部与“运算符融合(Operator Fusion)”

6 阅读6分钟

1) Flow 内部大模型(先有这个心智图)

  • 冷流:每次 collect 都从头再建一条“处理链”。

  • 链式组装:绝大多数中间运算符(map/filter/transform/onEach/scan/distinctUntilChanged…)都是 inline 的函数,返回一个新的 Flow,其 collect 实现里会 把上游的 emit 直接转发到下游的 emit,中间仅加上自己的那点逻辑。

  • 单协程、串行直通(默认):没有 buffer/flowOn/flatMap* 等“并发型运算符”时,整条链在 同一个协程同一线程逐元素 运行,上游 emit 一次就调用一串内联的操作,然后到达下游 emit。

  • 边界(Boundary) :遇到 buffer/flowOn/flatMapMerge/collectLatest/channelFlow/zip/combine/shareIn 等,会引入 额外协程 + Channel,把“直通链”切成多个段。

直观理解:能“直通”的都尽量直通;一旦需要并发/上下文切换/多路合流,就建 ChannelFlow + 协程 做解耦。


2) 什么是运算符融合(Operator Fusion)

定义:把一串同步、无并发、无上下文切换的中间运算符,在一次元素处理里连成一条内联调用链,避免为每个运算符单独创建协程/队列/对象,从而降低开销(没有额外调度、无多余分配)。

简化到源码层面的典型实现(伪代码):

inline fun <T, R> Flow<T>.map(crossinline transform: suspend (T) -> R): Flow<R> =
  unsafeFlow { downstream ->
      collect { value ->                  // 上游 emit(value)
          downstream.emit(transform(value)) // 直接算、直接发下游
      }
  }

filter、onEach、scan 等都类似:一层套一层的内联——这就是“融合”。

收益

  • 无额外协程/Channel;
  • 无多余上下文切换;
  • 极少分配(大多为已内联的 lambda + 编译期状态机)。

3) 哪些会被融合?(按“是否引入并发/上下文”来记)

完全可融合(同步串行)

  • map / mapNotNull / filter / take / drop / onEach / transform(只要不内部 launch)

  • scan / runningFold / distinctUntilChanged

  • catch / onCompletion / retry(不制造并发,包裹在同一链路上)

  • debounce(注意:实现包含时间器,但不拆分上下文时仍在同一收集协程里调度,通常与链路融合)

部分融合(逻辑在一侧融合,但会与另一侧形成边界)

  • flowOn:在 flowOn 上游 的运算符彼此融合;flowOn 自身是边界(见下)。

  • takeWhile / transformWhile:本身融合,但提前终止整条链。

几乎必定打破融合(构造边界)

  • buffer(n) / conflate() / collectLatest()

    → 构造 ChannelFlow:上游生产、下游消费分离,出现队列与独立协程。

  • flatMapMerge/Concat/Latest、mapLatest

    → 每个元素“展开”出子协程(latest 会取消老子协程),天然并发边界。

  • flowOn(context)

    → 切换到另一个 dispatcher,强边界;flowOn 上下游各自内部依旧融合。

  • zip / combine

    → 需要多源对齐/合并,内部必有协调与缓存。

  • shareIn / stateIn(热流共享)

    → 生成上游生产协程 + 共享状态/重放缓存,下游再各自收集。

口诀:不引入并发和上下文切换 → 融合;只要“跨协程/跨上下文/多路合流” → 边界。****


4) 融合后的性能画像(每个元素会发生什么)

无边界链路中,处理一个元素的路径类似:

upstream.emit(v)
 → (map λ) → (filter 判定) → (onEach 副作用) → downstream.emit(v’)
  • 无多余调度:不切线程,不入队/出队。
  • 挂起点:只有当你的 transform/emit 自身挂起时才会挂起(比如写入某个挂起 API)。纯同步运算几乎就是函数内联调用的成本。
  • SafeCollector 校验:为了上下文一致性/正确的 flowOn 语义,emit 路上有一次上下文一致性检查(线程局部开销很小,通常可忽略;flowOn 边界会换一套 SafeCollector)。

5) 边界一旦出现,会带来什么?

以 buffer(n) 为例:

  • 新增协程:上游在 A 协程生产、下游在 B 协程消费;

  • 新增队列:Channel(capacity=n);

  • 收益:抗抖/解耦(上游快、下游慢时不互相阻塞);

  • 代价:入队/出队 + 调度切换 + 可能的元素缓存(内存占用);丢失“每元素严格顺序的同线程直通”。

因此:

  • 只在确实需要解耦时加 buffer;
  • conflate 会保留最新一条,丢弃中间(适合“状态类”数据);
  • collectLatest/mapLatest/flatMapLatest 通过取消旧工作换来“只处理最新”。

6) 与flowOn的融合边界

flowOn(Dispatchers.IO) 会把它之前的运算放到 IO 线程跑、之后的运算继续在原上下文(常是 Main)。

  • flowOn 之前的同步运算符互相融合;之后的一组也融合;

  • flowOn 本身是强边界,内部用 ChannelFlow/调度器将两段链路隔离。

实战要点:

  • 把 flowOn 靠近源头(I/O 转换在 IO、UI 变换在 Main),减少跨线程往返;
  • 同理,把 buffer 放在瓶颈前,只按需放 1~2 处即可。

7) 与 RxJava“融合”的对照

  • RxJava 有**同步/异步融合(fusion)**协议(QueueFuseable),能把某些 map/filter 合并为一次拉取;
  • Kotlin Flow 的“融合”更多是编译期内联 + 单协程直通,不是在运行期协商协议位,而是通过不创建并发边界来达到“融合”的效果。
  • 共同目标相同:减少切换与中间层,尽量把一串轻运算一次做完。

8) 实战优化清单(按“最常见收益”排序)

  1. 优先保持“直通链” :能不 buffer/flowOn/flatMap* 就不要;确实需要才加(并只加一次)。
  2. 把 flowOn 靠近源头:I/O 相关运算放 flowOn(IO) 之前;UI 侧变换放之后。
  3. 把多个轻运算合并为一个 transform{} (可读性允许时):
flow.transform { v ->
    if (predicate(v)) emit(f(v))     // 相当于 filter + map 的融合版
}
  1. 用 mapLatest/collectLatest 处理“只要最新”场景,少自己写“取消上一份”的样板;

    但要知道它会打破直通(每元素子协程),请只在必要时用。

  2. 避免无意义的 flowOn/buffer 叠加:每加一次就是一个边界;能合并的合并。

  3. 状态流用 conflate/distinctUntilChanged:减少冗余运算与 UI 刷新。

  4. 组合类运算符知其代价:zip/combine 必有协调缓存;确定需要再用,避免“大杂烩链路”。

  5. 测量而非猜测:配合 DebugProbes/日志标注关键节点;观察线程与是否出现“边界”(是否出现额外协程/队列)。


9) 误解澄清

  • “多个 map 就很慢” :只要没有边界,它们会被内联融合,通常不是瓶颈;真正的开销来自跨协程/队列/线程
  • “flowOn 一定切线程两次” :不是两次,是将边界前的运算放到指定 dispatcher;之后继续在下游上下文;关键是放的位置
  • “buffer 越大越好” :不是。大缓冲 = 更大内存 + 更难掌控时序;按需要设置小容量(如 0/1/固定小数)。

10) 速查表

  • 可融合:map/filter/transform/onEach/scan/distinctUntilChanged/take/drop/catch/onCompletion/retry
  • 边界:buffer/conflate/collectLatest/flowOn/flatMap*/mapLatest/channelFlow/zip/combine/shareIn/stateIn
  • 放置:flowOn 靠近源;buffer 放瓶颈前;边界越少越快。
  • 只要最新:用 *Latest 或 conflate。
  • 合并轻运算:必要时用 transform{} 同步融合。