第3章:后台状态变更导致重组的原理——Snapshot系统简介

咱们直接聊核心问题——为啥后台改个状态,UI就抖得像抽风?

很多人刚接触Compose时,以为它跟View系统一样,手动更新视图就行。但Compose不是这样的。它背后有个叫 Snapshot 的快照系统,这个系统决定了重组怎么触发、什么时候触发。不理解它,你排查抖动就是瞎子摸象。

3.1 什么是Snapshot系统?

说白了,Snapshot就是一种 事务性状态管理。它有点像数据库的快照隔离级别:每个线程在某一时刻看到的状态是一致的,不会读到一半被别的线程改了。Compose用它来跟踪所有可观察的状态(比如 mutableStateOf),并决定什么时候需要重组。

我最初在项目中遇到一个诡异问题:从后台线程改了 MutableState 的值,UI确实刷新了,但有时候刷新频率不正常,甚至出现空指针。后来才意识到,后台线程写入时没有走Snapshot机制,导致Compose的观察者没收到正确通知。

核心要点: Compose的每个重组周期都基于一个“快照”。读操作注册依赖,写操作触发失效,然后调度重组。

3.2 Snapshot的工作原理

整个流程可以拆成三步:读、写、提交。我画张图帮你理清楚。

Snapshot系统工作流程 State对象 写线程(后台) 当前快照 新快照 Compose观察者 修改值 标记失效 创建新快照 通知变更 丢弃旧快照 关键:写操作必须在Snapshot事务内执行,否则Compose观察者不会收到失效通知

3.3 读操作:依赖跟踪

当Compose执行重组时,它会 在当前快照中读取 所有被观察的状态。读的时候,Snapshot会自动把这个状态标记为“依赖项”。

@Composable
fun MyBadge(count: Int, label: String) {
    val size by remember { mutableStateOf(16) }  // ← 这里读取了size
    Text("$label: $count", fontSize = size.sp)   // ← count、label不是State,不会跟踪
}

嗯,重点来了:只有通过 by 或 .value 取出来的 MutableState 才会注册依赖。普通变量改了一万遍,Compose也不理你。

3.4 写操作:失效与重组调度

一旦某个 MutableState 的值被修改(通过 .value = 或 by 委托),Snapshot系统就会做两件事:

  1. 标记当前快照失效 —— 表示这个状态已经脏了。
  2. 创建新快照 —— 新快照包含最新值。
  3. 通知关联的Composable —— 触发下一帧重组。

这个流程听起来简单,但你想想看——如果写操作发生在后台线程,而Compose默认只在主线程上做重组,那就出问题了。我在项目中遇到过这么个场景:WebSocket推送数据后直接改了 mutableStateOf,结果UI闪了一下后卡住了,因为Snapshot的写事务没在主线程提交。

我的经验: 如果非要在后台线程改State,请使用 snapshot.withSnapshot 包裹,或者用 withContext(Dispatchers.Main) 切回主线程。

3.5 为什么后台状态变更会导致“抖动”?

抖动本质上是 不必要的、未同步的重组。

比如:后台线程连续修改同一个状态10次,每次修改都触发一次完整的重组管线。而且因为修改不在一个快照事务里,Compose可能会读到中间值,导致UI在被锁定前反复刷新。这就是为什么你看到界面跳来跳去,甚至闪烁。

我给大家列个对比表:

场景 修改位置 是否在Snapshot事务内 重组次数 UI表现
点击按钮修改 主线程 是(Compose自动管理) 1次(合并后) 流畅
后台线程直接改 后台线程 否 多次(可能超过10次) 抖动、闪烁
后台线程 + snapshot.withSnapshot 后台线程 是 1次(事务合并) 流畅

3.6 避坑指南:我曾经犯过的错

记得有个项目用Room + Paging 3,数据从后台线程流到UI。我习惯直接在 collect 里赋值给 mutableStateOf,结果列表滚动时经常跳回顶部。后来排查发现:

// 错误示范 —— 直接后台改状态
viewModelScope.launch(Dispatchers.IO) {
    dataFlow.collect { newData ->
        _uiState.value = newData   // 后台线程修改,触发无序重组
    }
}

// 正确做法 —— 切到主线程
viewModelScope.launch {
    dataFlow.collect { newData ->
        withContext(Dispatchers.Main) {
            _uiState.value = newData
        }
    }
}

或者更优雅的方式,直接依赖StateFlow的自动收集:

val uiState by viewModel.uiState.collectAsState()

这样Snapshot由Compose内部管理,根本不用你操心线程问题。

注意: 别把 mutableStateOf 暴露给非Compose线程直接写。即使你用了锁或原子类,只要不经过Snapshot事务,Compose的观察者就可能错过失效通知,导致UI与状态不同步。

3.7 小结:理解快照才能治本

好,这一章咱们把Snapshot的基本原理理清了:

  • 读操作 注册依赖
  • 写操作 失效旧快照,创建新快照
  • 后台修改 如果不走事务,会导致多次重组、UI抖动