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 结合,能在"流量大但只需代表性结果"的场景里保持既稳又省

相关推荐
Lee川14 小时前
优雅进化的JavaScript:从ES6+新特性看现代前端开发范式
javascript·面试
Lee川18 小时前
从异步迷雾到优雅流程:JavaScript异步编程与内存管理的现代化之旅
javascript·面试
晴殇i20 小时前
揭秘JavaScript中那些“不冒泡”的DOM事件
前端·javascript·面试
绝无仅有20 小时前
Redis过期删除与内存淘汰策略详解
后端·面试·架构
绝无仅有20 小时前
Redis大Key问题排查与解决方案全解析
后端·面试·架构
AAA梅狸猫21 小时前
Looper.loop() 循环机制
面试
AAA梅狸猫21 小时前
Handler基本概念
面试
Wect1 天前
浏览器缓存机制
前端·面试·浏览器
掘金安东尼1 天前
Fun with TypeScript Generics:玩转 TS 泛型
前端·javascript·面试
掘金安东尼1 天前
Next.js 企业级落地
前端·javascript·面试