Compose 的 derivedStateOf,什么时候真的需要它
Compose 的 derivedStateOf,什么时候真的需要它
去年把项目里的 Compose BOM 从 2023.06 升级到 2023.10(对应 Compose UI 1.5.4)之后,我顺手清理了一批性能相关的样板代码。其中有一类改动特别值得拿出来单说:我删掉了大约三分之二用到 derivedStateOf 的地方。不是因为 API 过时了,而是回看这些代码时发现,大部分场景根本触发不了它存在的意义,纯粹是跟着早期文档照猫画虎写多了。这篇文章想把 derivedStateOf 的真实行为、适用边界和版本差异摊开讲一遍,避免更多人把它当成一种“防御性编程”的护身符。
从一个 LazyColumn 的返顶按钮说起
最典型的 derivedStateOf 教学案例,几乎都离不开滚动监听。场景很简单:一个 LazyColumn 里有一个返回顶部按钮,当用户滚动超过第一项时再显示。
最朴素的写法是直接读取 LazyListState:
val listState = rememberLazyListState()
val showButton = listState.firstVisibleItemIndex > 0
AnimatedVisibility(visible = showButton) {
FloatingActionButton(onClick = { /* scroll to top */ }) { }
}问题出在哪?firstVisibleItemIndex 是一个 MutableState,它的值会随着滚动逐项递增。如果你把这个 Boolean 直接解构在 Composition 里,每次 firstVisibleItemIndex 变化都会触发读取它的 Composable 重组。在 1000 项的列表里从顶部滑到底部,这个 AnimatedVisibility 所在的节点会重组几十次甚至上百次,其中绝大多数都是 true -> true 的无意义刷新。
这时候 derivedStateOf 登场:
val showButton by remember {
derivedStateOf { listState.firstVisibleItemIndex > 0 }
}它的核心机制不是缓存计算结果,而是做差异通知。derivedStateOf 内部会追踪 Snapshot State 的依赖关系,每次依赖项变化时重新执行 lambda,但只有当返回值发生 equals 变化时,才会向下游发送状态变更通知。换句话说,从 true 到 true 的跃迁被内部消化了,AnimatedVisibility 感知不到任何变化,重组次数直接掉到两次(进入阈值一次,离开阈值一次)。
这个例子本身没问题,但麻烦的是很多人从这里推导出了一个过度泛化的结论:“只要状态是从其他状态算出来的,就应该包一层 derivedStateOf。” 我项目里就见过给 String 拼接包 derivedStateOf,给简单数学运算包 derivedStateOf,甚至给 ViewModel 已经去重过的 Flow 转 State 再包一层。这些写法在 1.5.x 的 runtime 里不会报错,但它们既省不下一次重组,又白白多分配了一个 DerivedState 对象。
derivedStateOf 在 Snapshot 系统里的真实位置
要判断用不用,得先搞清楚它到底介入了什么。Compose 的 Snapshot 系统里,derivedStateOf 返回的不是普通 State,而是一个内部实现了 DerivedState 接口的对象。它的读取路径比普通 MutableState 多了一层依赖收集和脏检查。
当你在 lambda 里读取其他 Snapshot State(比如 MutableState、StateFlow 的 .value)时,derivedStateOf 会把自己注册为这些状态的观察者。后续任何依赖项的写入都会标记这个 derived state 为“可能脏了”。等到下一次读取(或重组前的状态一致性检查),lambda 被重新执行,结果和旧值比对,不同才发通知。
这个流程意味着两件事。第一,计算本身没有被跳过。如果 lambda 里的逻辑很重,每次依赖变化你仍然要承担计算开销,只是可能省下了下游的重组开销。第二,它引入了额外的对象和订阅成本。在 Compose Runtime 1.5.x 的实现里,一个 derivedStateOf 会持有一个 DerivedSnapshotState 实例,内部还有用于依赖追踪的 SnapshotMutableState 和一些原子引用。对于高频变化的简单数值,这一层包装可能比直接重组还贵。
所以 derivedStateOf 解决的问题本质是状态变更频率与派生结果变更频率的不匹配。如果两者频率差不多,中间插一层就是负优化。
两类典型误用
我在代码审查里高频看到两类误用,都值得拆开看。
第一类:低频状态 + 简单计算
比如一个分页列表,当前页码 currentPage 是个 MutableState,你用它算一个标题字符串:
val title by remember {
derivedStateOf { "第 ${currentPage.value} 页" }
}页码切换一次才变一次,而且消费这个 title 的 Composable 本来也只会在页码变时重组。derivedStateOf 在这里没有任何拦截价值,反而让一次简单的字符串模板插值多绕了一圈 Snapshot 的依赖收集。
第二类:已经做了去重的上游状态
假设 ViewModel 里有一个 StateFlow<List<Item>>,已经用 distinctUntilChanged() 保护过了。你在 Composable 里 collect 成 State:
val items by viewModel.items.collectAsState()如果接下来你要算一个 isEmpty:
val isEmpty by remember { derivedStateOf { items.isEmpty() } }这里的 items 作为 Snapshot State 只在 Flow 发射新值时才变。derivedStateOf 确实能工作,但它是多余的。items.isEmpty() 本身的开销和 derivedStateOf 的内部开销比起来,后者更大。直接 val isEmpty = items.isEmpty() 写在 Composable 里就行,因为 items 没变时根本不会触发重组。
真正需要拦截的是那种依赖项疯狂变化、但派生结果极少变化的场景。滚动监听之所以成为经典案例,正是因为 firstVisibleItemIndex 每滚过一项都变,而“是否大于零”这个布尔结果只变两次。
真正需要它的边界条件
除了滚动阈值判断,还有几种场景我确实会主动使用 derivedStateOf。
列表过滤的可见性标记
当一个列表数据源高频变化(比如每 16ms 接收一次传感器数据更新列表内部状态),但过滤条件极少变化时:
val visibleItems by remember {
derivedStateOf {
allItems.filter { it.sensorValue > threshold.value && it.isActive }
}
}这里的 allItems 可能持续收到同一个列表实例的微更新(比如某个元素内部属性变了),导致整个列表对象引用没变但内容变了。如果没有 derivedStateOf,消费 visibleItems 的 LazyColumn 可能会因为 allItems 的引用没变但内部变化而陷入不确定的重组行为;更常见的是,如果你直接 remember(allItems) { allItems.filter { ... } },那么只要 allItems 引用没变就不会重新过滤,这反而是错的。derivedStateOf 在这里的语义是“持续观察内容变化,但只在过滤结果集变化时通知我”。
多状态组合且下游消费复杂
当多个 MutableState 共同决定一个派生值,而这个派生值被多个深层 Composable 读取时:
val canSubmit by remember {
derivedStateOf {
title.value.isNotBlank() && content.value.length > 10 && !isUploading.value
}
}title、content、isUploading 可能各自高频变化(比如用户输入时 every keystroke),但 canSubmit 只在边界附近翻转。如果不包 derivedStateOf,任何一个输入变化都会导致读取 canSubmit 的按钮重组。虽然 Compose 的重组粒度很细,但如果这个按钮嵌套在一个庞大的 Card 里且没有做好状态隔离,ripple effect 可能让整片 UI 跟着闪。
必须注意的一个陷阱:不要把 derivedStateOf 和 remember(key) 混为一谈
我见过有人这么写:
val filtered by remember(searchQuery.value) {
derivedStateOf { items.filter { it.contains(searchQuery.value) } }
}这完全搞反了。remember(searchQuery.value) 已经在 query 变化时重新创建整个 block,内部的 derivedStateOf 每次都是一个新实例,依赖追踪被切断。正确的写法是 remember 不依赖 query,让 derivedStateOf 内部自己去追踪 searchQuery:
val searchQuery = remember { mutableStateOf("") }
val filtered by remember {
derivedStateOf { items.filter { it.contains(searchQuery.value) } }
}版本差异:1.3 到 1.5 的行为变化
Compose UI 1.3.x 到 1.5.x 之间,derivedStateOf 的实现有过一次值得关注的内部调整。虽然官方 release note 里没有大篇幅宣传,但在 Runtime 1.5.0 的源码里,DerivedState 的依赖追踪从基于全局 Snapshot 的线性扫描,改成了更细粒度的版本戳比对。带来的实际影响是:高频低差异场景下的性能损耗降低了。
在 1.3.x 时代,我见过一个极端案例:用一个 derivedStateOf 追踪 LazyListState.firstVisibleItemScrollOffset 来驱动一个视差效果。因为 firstVisibleItemScrollOffset 每帧都变,derivedStateOf 的 lambda 每帧都被迫执行,内部比对后通知下游。虽然逻辑上“只有 offset 跨过某个阈值才通知”,但 1.3.x 的 runtime 在依赖项极高频变化时,内部维护依赖关系的成本会被放大,Systrace 里能看到 DerivedState 相关的 SnapshotStateObserver 调用占了不少 CPU 时间。
到了 1.5.x,同样的代码在 Pixel 6 上跑,对应 trace 里的耗时大概降了 40% 左右。我没有用 Macrobenchmark 做严格的实验室对比,但在反复滑动列表的 profile 数据里,这个优化是可见的。
不过版本升级也带来了另一个隐藏变化:derivedStateOf 在 1.4.0 之后对 structural equality 的依赖更严格了。如果你的 lambda 返回一个自定义数据类,但没有正确实现 equals,可能会出现“值已经变了但没通知重组”的静默 bug。我在升级 BOM 时踩过一次坑:一个数据类用了默认的 equals(基于引用),derivedStateOf 里返回新构造的实例,因为引用不同所以每次都通知,这和预期相反;但另一个场景里我故意复用了缓存实例,derivedStateOf 以为没变化,导致 UI 没刷新。后来干脆在 lambda 里显式拆解成基本类型比较,才避开这个坑。
数据说话:重组次数的观测方法
空谈概念没意义,真正判断一个场景需不需要 derivedStateOf,应该看重组次数。我常用的手段不是猜,而是加一行 debug 工具。
Compose Runtime 1.5.x 里可以用 Recomposer.current 配合自定义的 CompositionLocal 做日志,但更轻量的办法是直接利用 Android Studio 的 Layout Inspector 里的 “Recomposition Counts” 功能,或者直接在 Composable 里打印: