它到底做什么?
-
语义 :按固定周期 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 结合,能在"流量大但只需代表性结果"的场景里保持既稳又省。