derivedStateOf 与 snapshotFlow:两扇门
State 和 Flow 是两套世界,中间有两扇单向的门。走错门的代价不大,但走对了能省下惊人的东西——比如让一个每帧都在变的滚动位置,只触发 1 次重组。
三个方向,三个工具
State → State derivedStateOf { } 这一章
State → Flow snapshotFlow { } 这一章
Flow → State collectAsStateWithLifecycle() 第 19 章
没有 Flow → Flow 那一格,因为那是操作符的事。也没有反过来的门——这三扇门都是单向的,这不是限制,是好事:数据流向单一,出问题只有一个地方可查。
derivedStateOf:一个会自己判断「值得不值得通知」的格子
场景:列表滚过一定距离之后,显示「回到顶部」按钮。
// ① 直接读
val showButton = listState.firstVisibleItemIndex > 3
// ② 包一层
val showButton by remember {
derivedStateOf { listState.firstVisibleItemIndex > 3 }
}
两种写法逻辑完全一样,结果差多少?
滚动 300 次(大约 5 秒的滚动):
写法 BackToTop 重组次数 derivedStateOf 重算 真正通知下游 ① 直接读 scroll 301 — — ② derivedStateOf 2 301 1
301 对 2。而中间那一列是这一章最重要的一个数字。
它省的不是「算」,是「下游的重跑」
看清楚:derivedStateOf 里的那个表达式照样算了 301 次。它一点计算都没省。
它做的是另一件事:算完之后,把结果和上次的结果比一下。
依赖变了
→ 重新算一遍 (301 次都算了)
→ 结果和上次相等吗?
相等 → 什么都不做,下游一无所知 (300 次)
不等 → 通知下游,触发重组 (1 次:从 false 变 true 那一刻)
所以它的适用形状只有一种,而且非常好认:
输入变得很频繁,输出很少变。
| 表达式 | 输入频率 | 输出频率 | 值得吗 |
|---|---|---|---|
scrollOffset > 600 | 每帧 | 整场滚动变一两次 | 非常值 |
text.isNotBlank() | 每次打字 | 一次(从空到非空) | 值 |
items.count { it.done } == items.size | 每次勾选 | 很少 | 值 |
user.name.uppercase() | user 变时 | user 变时(一样频繁) | 纯亏,用 remember(user) |
a + b | a 或 b 变时 | 基本每次都变 | 纯亏,直接写 |
后两行是最常见的误用:把 derivedStateOf 当成「缓存」用了。它不是缓存——缓存是 remember(key)。
// ❌ 每次重组都造一个新的 derivedStateOf,前面记的东西全废了
val show by derivedStateOf { listState.firstVisibleItemIndex > 3 }
// ✅
val show by remember { derivedStateOf { listState.firstVisibleItemIndex > 3 } }
这个错误很难自己发现,因为功能是对的——界面显示正常,只是那 301 次重组一次都没省下来。
Android Studio 有一个 lint(UnrememberedDerivedState)会警告它,但前提是你打开了 Compose 的 lint 检查。
snapshotFlow:从 State 走到 Flow
另一扇门,方向相反:把 Compose 的 State 变成一个 Flow,这样你就能用上操作符和协程。
LaunchedEffect(listState) {
snapshotFlow { listState.firstVisibleItemIndex }
.distinctUntilChanged()
.filter { it > items.size - 5 }
.collect { loadNextPage() } // 滚到底部就加载下一页
}
snapshotFlow { } 的行为:
- 块里读到的 state 变了 → 重新求值 → emit
- 自带
conflate:下游慢的时候,中间的值会被跳过 - 自带
distinctUntilChanged:相等的值不会重复发
后两条让它和 derivedStateOf 在「过滤高频」这件事上很像。差别在于你拿到的是什么:
derivedStateOf | snapshotFlow | |
|---|---|---|
| 产出 | 一个 State,用来渲染 | 一个 Flow,用来做事 |
| 在哪儿用 | composable 的 body 里 | LaunchedEffect 里 |
| 能不能挂起 | 不能 | 能(它在协程里) |
| 典型场景 | 「要不要显示这个按钮」 | 「滚到底了就加载」「选中项变了就上报」 |
一句话分工:要渲染用 derivedStateOf,要做事用 snapshotFlow。
三扇门,各走一个方向,各解决一类问题:
- derivedStateOf:State → State。把「高频输入、低频输出」的计算挡在下游之前
- snapshotFlow:State → Flow。把 UI 的状态变化接进协程世界,好用操作符
- collectAsStateWithLifecycle:Flow → State。把数据流接进 UI,而且不在后台白跑
这三个凑齐了,Compose 和协程之间就再没有需要你手写胶水的地方。
什么时候两个都不用
一个很实际的提醒:大多数时候你两个都不需要。
// 不需要 derivedStateOf —— 输入本来就不高频
val canSubmit = name.isNotBlank() && email.isNotBlank()
// 不需要 snapshotFlow —— 直接在回调里做就行
Button(onClick = { scope.launch { submit() } })
这两个 API 的存在感和它们的实际使用频率不成比例——因为它们出现在每一份性能优化清单里。先确认自己真的有「高频」这个前提。
怎么确认?把 demo 上的滑杆拉到 20 次再看那张表:301 对 2 会变成 21 对 2。省下的绝对值取决于输入有多频繁,输入不频繁的时候,包这一层只是给代码加了一层壳。
第 6 章那条「传 lambda 不传值」和这一章可以叠加:
// 两层保护叠在一起
val showButton by remember {
derivedStateOf { listState.firstVisibleItemIndex > 3 }
}
BackToTopButton(visible = { showButton }) // ← 还是传 lambda
derivedStateOf 把 301 次减到 1 次通知;传 lambda 让那 1 次通知只影响 BackToTopButton 内部,不惊动它的父级。
两条优化管的是不同的东西:一个管「通知几次」,一个管「通知谁」。
卷 V 到此结束
这一卷讲的全是「接缝」——两套机制交接的地方。回头看,四章的共同点很明显:
- 第 17 章:Compose 的 组合生命周期 ↔ 协程的 Job 生命周期(key 就是它们的对接口)
- 第 18 章:Compose 的 「不保证跑几次」 ↔ 副作用的 「必须刚好一次」
- 第 19 章:Compose 的 Composition ↔ Android 的 Lifecycle(两者不是一回事)
- 第 20 章:Compose 的 State ↔ 协程的 Flow
线上事故绝大多数出在这四条缝上,而不是出在任何一套机制内部。原因也很明白:每一套机制单独看都很自洽,只有在交接的地方,两边对「什么时候算结束」的理解才会不一致。
这一章的一句话
derivedStateOf 省的不是「算」,是「下游的重跑」——它照样算 301 次,但只通知 1 次。它只在「输入高频、输出低频」时值得,而且必须包在 remember 里。要渲染用它,要做事用 snapshotFlow。
最后一卷:把前面二十章全部换成明天就能改的东西。第一个问题是所有 Android 开发者都遇到过的——「弹一个 Snackbar」这种一次性的事,到底该放哪儿?三种做法,只有一种能同时扛住「不重复」和「不丢失」。