卷 V · 接缝CH 20深度 20/24

derivedStateOf 与 snapshotFlow:两扇门

State 和 Flow 是两套世界,中间有两扇单向的门。走错门的代价不大,但走对了能省下惊人的东西——比如让一个每帧都在变的滚动位置,只触发 1 次重组。

derivedStateOfsnapshotFlow高频输入低频输出

三个方向,三个工具

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 + ba 或 b 变时基本每次都变纯亏,直接写

后两行是最常见的误用:derivedStateOf 当成「缓存」用了。它不是缓存——缓存是 remember(key)

⚠ 必须包在 remember 里
// ❌ 每次重组都造一个新的 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 在「过滤高频」这件事上很像。差别在于你拿到的是什么

derivedStateOfsnapshotFlow
产出一个 State,用来渲染一个 Flow,用来做事
在哪儿用composable 的 body 里LaunchedEffect
能不能挂起不能能(它在协程里)
典型场景「要不要显示这个按钮」「滚到底了就加载」「选中项变了就上报」

一句话分工:要渲染用 derivedStateOf,要做事用 snapshotFlow

◆ 这一章的核心

三扇门,各走一个方向,各解决一类问题:

  • derivedStateOfState → State。把「高频输入、低频输出」的计算挡在下游之前
  • snapshotFlowState → Flow。把 UI 的状态变化接进协程世界,好用操作符
  • collectAsStateWithLifecycleFlow → State。把数据流接进 UI,而且不在后台白跑

这三个凑齐了,Compose 和协程之间就再没有需要你手写胶水的地方。

什么时候两个都不用

一个很实际的提醒:大多数时候你两个都不需要。

// 不需要 derivedStateOf —— 输入本来就不高频
val canSubmit = name.isNotBlank() && email.isNotBlank()

// 不需要 snapshotFlow —— 直接在回调里做就行
Button(onClick = { scope.launch { submit() } })

这两个 API 的存在感和它们的实际使用频率不成比例——因为它们出现在每一份性能优化清单里。先确认自己真的有「高频」这个前提。

怎么确认?把 demo 上的滑杆拉到 20 次再看那张表:301 对 2 会变成 21 对 2。省下的绝对值取决于输入有多频繁,输入不频繁的时候,包这一层只是给代码加了一层壳。

✎ 一个容易忽略的搭配:derivedStateOf + 延迟读取

第 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」这种一次性的事,到底该放哪儿?三种做法,只有一种能同时扛住「不重复」和「不丢失」。