Flow的sample(t)详解

它到底做什么?

  • 语义 :按固定周期 t 取样 上游,在每个周期边界发出"最近一次"值;周期内的其它值被跳过。

  • 特点 :最多 每隔 t 发 1 次 ;若某个周期里没有新值 ,该周期不发上游完成时会把"最后一个尚未发出的值"补发(flush)

时序示例(t = 300ms,上游每 100ms 发 1 次):

less 复制代码
time(ms): 0 100 200 300 400 500 600 700 800 900 1000(end)
upstream:   a   b   c   d   e   f   g   h   i   j   (complete)
sampled :            [c]           [f]           [i]        [j] (flush on complete)
结果:c, f, i, j

API 形态

kotlin 复制代码
fun <T> Flow<T>.sample(timeoutMillis: Long): Flow<T>
fun <T> Flow<T>.sample(timeout: Duration): Flow<T>

// "外部采样器"版本:sampler 发一次,就取一次"最近值"
fun <T> Flow<T>.sample(sampler: Flow<*>): Flow<T>
  • 周期版:从订阅时刻 开始计时,每隔 t 取最新值。

  • 采样器版:可用"帧时钟""节流器"等自定义节拍来采样(比如屏幕刷新)。

放置位置(很关键)

把 sample() 放在昂贵/频繁的操作前面,让后续链路只在"周期边界"处理一次:

scss 复制代码
source
  .sample(100)            // ↓ 降频
  .map { heavyCompute(it) }
  .collect { render(it) }

反之若把重活放在 sample 之前(如 map { heavyCompute() }.sample(...)),重活仍会"每个输入都做",起不到省工效果。

与其它操作符怎么选?

诉求 选择 要点
固定周期取样,周期内只要最后一个 sample(t) 没新值不发;完成时会 flush
静默 t 后发一次"尾值" debounce(t) 抗抖:有持续输入就一直等
永远只保"最新",不按时间 conflate() 背压合并,无固定节拍
新值来就取消旧处理 mapLatest / collectLatest 可中断的重计算/渲染
不丢任何值,只解耦 buffer(n, SUSPEND) 满了会挂起上游

粗略心智模型:sample(t) ≈ "按时钟 + 取最新一次" ;debounce(t) ≈ "静默窗口 + 取尾值" ;conflate() ≈ "不限时,只保最新"

常见范式

1) 高频进度/位置 → 降频渲染

scss 复制代码
downloader.progressFlow()        // 0..100 高频
  .sample(50)                    // 每 50ms 刷新一次
  .collectLatest(progressBar::setProgress)

2) 传感器数据 → 60Hz 采样

scss 复制代码
val frames = ticker(delayMillis = 16).receiveAsFlow() // 约 60 FPS
sensorFlow.sample(frames)                              // sampler 版
  .map { smooth(it) }
  .collect(::draw)

3) Compose:状态帧率对齐

scss 复制代码
LaunchedEffect(Unit) {
  snapshotFlow { vm.uiState.value }
    .sample(16.milliseconds)     // 对齐动画帧,避免过度重组
    .collect { state -> render(state) }
}

4) 列表滚动埋点(降频)

scss 复制代码
listScrollDelta()                // 高频像素增量
  .sample(100)                   // 100ms 取一次最新位移
  .onEach { logScroll(it) }
  .launchIn(scope)

时间边界与补发(flush)

  • 首次发射 :不会"立即"发,要等第一个周期边界(除非恰好上游在那一刻发出了值)。

  • 无新值不重复 :上次采样后若没有新值到来,下一个 tick 不会重复发旧值

  • 完成补发 :上游 complete() 时,若缓存里有一个"最新但尚未发过"的值,会立即补发一次(常用于不丢最后一帧)。

验证小例:

scss 复制代码
runTest {
  val out = mutableListOf<Int>()
  (1..10).asFlow().onEach { delay(100) }
    .sample(300)
    .toList(out)
  // 期望:[3, 6, 9, 10]  ------ 300/600/900 边界 + 完成时 flush
}

与flowOn / 并发

  • flowOn(Dispatchers.IO) 在上下文切换处自带缓冲边界;sample 只是在取样点决定是否把"最近值"往下游送。

  • 若希望减少上游工作量,把重活放到 mapLatest { ... } 里,并置于 sample 之后(或直接在 mapLatest 之前做 debounce/sample,依据业务取舍)。

易坑与建议

  • 不是限速上游 :sample 只减少下游频次,不会让上游"慢下来"。要限速生产可在源头做节流或背压(buffer/conflate)。

  • 放错顺序:把 sample 放在重计算之后 → 省不了工。

  • 事件必达场景禁用:周期内的事件会被丢弃;需要"每条必处理"时用可靠队列/buffer(SUSPEND)。

  • 首发延迟:如果希望"第一下立刻来,其后再按周期",通常在 onStart { emit(initial) } 或额外逻辑里处理;sample 本身不会"leading emit"。

  • CPU 密集下游的取消:若与 collectLatest 搭配,下游需要有挂起点或显式 yield()/isActive 检查,取消才及时。

小抄:该用谁?

  • 搜索框降噪:debounce(300) + distinctUntilChanged() + flatMapLatest(...)

  • 进度/位置动画:sample(16~50) + collectLatest { draw() }

  • 只关心最新状态:conflate()(或 buffer(1, DROP_OLDEST))

  • 可中断的重计算:mapLatest / flatMapLatest

一句话总结

sample(t) = 固定周期取样、每周期发一次"最近值",无新值不发,完成时补发尾值。 把它放在昂贵操作前,用于高频信号的帧率对齐/降频渲染 ;和 debounce/conflate/*Latest 结合,能在"流量大但只需代表性结果"的场景里保持既稳又省

相关推荐
在未来等你2 小时前
Elasticsearch面试精讲 Day 18:内存管理与JVM调优
大数据·分布式·elasticsearch·搜索引擎·面试
南北是北北3 小时前
Kotlin中debounce(t)详解
面试
南北是北北3 小时前
详解Flow的collectLatest { ... }
面试
南北是北北3 小时前
Flow中的buffer详解
面试
小高0073 小时前
🤔函数柯里化:化繁为简的艺术与实践
前端·javascript·面试
Sherry0073 小时前
【译】掌握 Flexbox 的终极指南:从烤肉串到鸡尾酒香肠的布局哲学
css·面试·flexbox
绝无仅有4 小时前
大厂Redis高级面试题与答案
后端·面试·github
绝无仅有4 小时前
面试问题之导致 SQL 查询慢的原因及优化建议
后端·面试·github
在未来等你5 小时前
Kafka面试精讲 Day 18:磁盘IO与网络优化
大数据·分布式·面试·kafka·消息队列