重组为何会抖动:原因概述与常见误区
做 Compose 开发的朋友,可能都有过这种体验——界面突然闪一下,或者列表滚动时莫名卡一下。明明天天敲代码、处处用了 remember,为什么重组还会像抽风一样抖?
我接手过一个项目,主页的 Feed 流明明只改了后台一个数据字段,结果整个卡片组全重绘了。当时我盯着 Profiler 的火焰图看了三遍,心里只有一个念头:这重组次数,到底是谁在捣鬼?
抖动的本质:重组不是免费午餐
先搞清楚一件事:Compose 的重组,是 从头执行 @Composable 函数体,而不是只刷新界面上的那几个像素。你以为只改了某个文本,实际上从最底部那个 Text 一直往回推导,可能把整个 Column、Row 甚至外层 Scaffold 都拉下水了。
用张图说明白原因层级:
原因一:数据源不稳定——最容易被忽略的罪魁祸首
你想想看,后台一个 StateFlow 或者 LiveData,每次网络请求回来,哪怕字段值完全一样,只要是新对象就发射一次。Compose 的 collectAsState 很老实,每次收到数据都把这个 State 更新,于是引用进来,重组就开始了。
我有个印象很深的案例:用户详情页只是刷了个点赞数,后台把整个 User 对象重新发了一遍。Compose 一看 equals 没重写,默认引用不等,好嘛,整个 UserCard 全部重绘,连头像都闪了一下。
emit / postValue 被高频调用,或者每次都创建新实例,就一定会触发重组。这不是 Compose 的问题,是数据流的锅。
原因二:重组作用域过大——State 放错位置
很多人习惯把 var text by remember { mutableStateOf("") } 直接写在最外层的 Scaffold 里。这时候这个 text 一旦变化,整个屏幕的 Composable 都得重新走一遍——包括那些根本不读 text 的静态组件。
这就是“作用域泄漏”。Compose 会跟踪每个读取 State 的 Composable 函数。如果在同一个作用域里读了这个 State,那么作用域内所有子 Composable 都会被打上“需要重组”的标记。即使那些子函数没有参数依赖。
举个例子:
@Composable
fun BigScreen() {
var searchQuery by remember { mutableStateOf("") }
TopSearchBar(query = searchQuery, onQueryChange = { searchQuery = it })
// 下面这个 UserList 并不读 searchQuery,但每次输入搜索字符,它也要重组!
UserList(users)
}
searchQuery 放到 TopSearchBar 内部去管理,或者用 derivedStateOf 把真正依赖它的子作用域独立出来。
原因三:状态提升与共享不当——ViewModel 的坑
ViewModel 里常常暴露一个 MutableList 或 MutableStateFlow。有些同学直接在 ViewModel 里写 val items by mutableStateOf(listOf(...)),然后 Fragment 那边直接引用。表面看没问题,但一旦 ViewModel 的 items 被重新赋值(哪怕内容一样),整个列表全重组成新的。
我记得有一次排查 LazyColumn 闪烁,发现 ViewModel 的 items 是个 mutableStateListOf,业务层每次刷新都调用 items.clear() 再 items.addAll()。你猜怎么着?Compose 认为整个列表的引用变了,直接触发了全部 item 重组。
StateFlow<List<T>>,配合 distinctUntilChanged() 或者自定义的 equals,确保实例不变就不发射。UI 层用 collectAsStateWithLifecycle() 更安全。
常见误区:你以为你用了 remember 就万事大吉?
很多开发者以为只要写了 remember { ... } 就不会被重组。其实 remember 只是保存了计算结果,但并不阻止它外围的 Composable 函数体被重新执行。只要外层 State 变化,remember 里面的 lambda 仍然会被重新调用,如果你的 lambda 里有昂贵的计算(比如解析 JSON、创建大对象),那就白费了。
| 常见错觉 | 真相 |
|---|---|
| remember 能阻止重组 | 不能。重组是函数重跑,remember 只决定要不要重新计算值。 |
| derivedStateOf 能自动优化 | 它只是按需计算,但读取它的 Composable 仍然会因为外层变化而重组。 |
| 用 mutableStateListOf 就不会抖动 | 如果频繁清空再填充,每次数据源变化都会引发全部重组。 |
| LazyColumn 只会重组可见 item | 只要 data 的引用变了,整个列表的数据快照都会重建,即使不可见 item 也会在后台重组。 |
再补充一个曾经让我熬夜的坑:derivedStateOf 的 keys 参数写错了。Compose 1.3 之后,derivedStateOf 需要传 policy 或 key,如果你传了一个每次都变化的 key,那它等于没缓存,比不用更糟。
// 坑:每次重组都生成新的 lambda,derivedStateOf 缓存失效
val count by remember(key1 = someLiveData.value) {
derivedStateOf { someLiveData.value }
}
// 正确做法:去掉 key,或者用稳定的 key
val count by remember { derivedStateOf { someLiveData.value } }
小结:抖动的根源其实就三件事
- 数据源发射太频繁——解决:用
distinctUntilChanged、debounce、或者自定义比较器。 - State 放得太靠上——解决:把 State 下放到最小作用域,用
compositionLocal共享时要小心。 - 状态对象引用总是变——解决:ViewModel 暴露不可变数据,用
SnapshotStateList或封装StateFlow保证幂等更新。
嗯,这三点看着简单,但真到了项目里,常常是多方面因素叠加在一起。下一轮我们聊聊怎么用 Layout Inspector 和 Compose Compiler Metrics 抓出具体是哪个节点在作祟。