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

相关推荐
豆苗学前端36 分钟前
你所不知道的前端知识,html篇(更新中)
前端·javascript·面试
努力学算法的蒟蒻1 小时前
day58(1.9)——leetcode面试经典150
算法·leetcode·面试
UrbanJazzerati2 小时前
统计学的"测谎仪":一文搞懂方差、标准差与“N-1”的秘密
面试
顾林海2 小时前
Android文件系统安全与权限控制:给应用数据上把“安全锁”
android·面试·操作系统
青莲8432 小时前
Android 动画机制完整详解
android·前端·面试
No芒柠Exception3 小时前
从开发到上线的CI/CD 完整流程
后端·面试·架构
CCPC不拿奖不改名4 小时前
网络与API:从HTTP协议视角理解网络分层原理+面试习题
开发语言·网络·python·网络协议·学习·http·面试
程序员飞哥4 小时前
几年没面试,这次真的被打醒了!
java·面试
乌暮5 小时前
JavaEE初阶---《JUC 并发编程完全指南:组件用法、原理剖析与面试应答》
java·开发语言·后端·学习·面试·java-ee
CCPC不拿奖不改名5 小时前
计算机网络:电脑访问网站的完整流程详解+面试习题
开发语言·python·学习·计算机网络·面试·职场和发展