重组为何会抖动:原因概述与常见误区

做 Compose 开发的朋友,可能都有过这种体验——界面突然闪一下,或者列表滚动时莫名卡一下。明明天天敲代码、处处用了 remember,为什么重组还会像抽风一样抖?

我接手过一个项目,主页的 Feed 流明明只改了后台一个数据字段,结果整个卡片组全重绘了。当时我盯着 Profiler 的火焰图看了三遍,心里只有一个念头:这重组次数,到底是谁在捣鬼?

抖动的本质:重组不是免费午餐

先搞清楚一件事:Compose 的重组,是 从头执行 @Composable 函数体,而不是只刷新界面上的那几个像素。你以为只改了某个文本,实际上从最底部那个 Text 一直往回推导,可能把整个 Column、Row 甚至外层 Scaffold 都拉下水了。

用张图说明白原因层级:

重组抖动原因概览 重组抖动(过度/意外重组) 数据源不稳定 / 频繁变化 重组作用域过大 状态提升 / 共享不当 StateFlow 事件重复 LiveData 乱传数据 State 放在顶层 Composable Lambda 捕获外部状态 ViewModel 暴露了 Mutable 对象 derivedStateOf 用错 key 💡 三条主线彼此可能叠加:比如一个顶层 State 变化,触发过大作用域层层重绘,看起来就像“抖动”。 实际排查时,往往需要从这三个方向同时入手。

原因一:数据源不稳定——最容易被忽略的罪魁祸首

你想想看,后台一个 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 重组。

推荐姿势: ViewModel 里用 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 } }

小结:抖动的根源其实就三件事

  1. 数据源发射太频繁——解决:用 distinctUntilChanged、debounce、或者自定义比较器。
  2. State 放得太靠上——解决:把 State 下放到最小作用域,用 compositionLocal 共享时要小心。
  3. 状态对象引用总是变——解决:ViewModel 暴露不可变数据,用 SnapshotStateList 或封装 StateFlow 保证幂等更新。

嗯,这三点看着简单,但真到了项目里,常常是多方面因素叠加在一起。下一轮我们聊聊怎么用 Layout Inspector 和 Compose Compiler Metrics 抓出具体是哪个节点在作祟。