Compose UI 的状态与重组——跨平台声明式 UI 的工程心法
声明式 UI 的核心承诺是「UI = f(state)」,但这个等式成立的前提是你把状态管理做对。我在用 Compose 和 Compose Multiplatform 搭跨平台界面的过程中,逐渐意识到:大多数「Compose 写起来怎么这么难调」的问题,根源不在 UI 层,而在状态的形状和流向设计得不好。
单一状态对象:让 UI 只有一个「真相来源」
最容易踩的坑是把屏幕状态拆成一堆零散的 mutableStateOf——isLoading、error、items 各占一个变量,然后在协程里试图同步更新它们,最终发现某个中间态总会触发意外的重组或短暂的 UI 闪烁。
解法是把整个屏幕状态收进一个 data class,ViewModel 用 MutableStateFlow<ScreenState> 持有它,每次变更都用 update { it.copy(...) } 原子替换:
data class ItemListState(
val items: List<Item> = emptyList(),
val isLoading: Boolean = false,
val error: String? = null,
val searchQuery: String = ""
)好处是多方面的:状态转换可测(直接断言 state.value),UI 侧只有一个收集点,中间态不会外泄给 Composable。
Composable 层做一个拆分:Screen = 有状态的容器(负责 collectAsStateWithLifecycle),Content = 无状态的展示层(接收 state + callback lambda)。这个拆分让 Content 可以直接用 @Preview 调试,不依赖 ViewModel 实例。
Event Sink:用密封接口收拢回调
屏幕复杂到一定程度后,传 5、6 个 callback lambda 的 Composable 函数签名会让人头疼。更深的问题是:每个 lambda 都是新建的对象,在重组里会造成不必要的闪烁和性能损耗。
Event Sink 模式的解法是用一个密封接口代替所有 callback:
sealed interface ItemListEvent {
data class Search(val query: String) : ItemListEvent
data class Delete(val itemId: String) : ItemListEvent
data object Refresh : ItemListEvent
}Composable 的参数从 onSearch: (String) -> Unit, onDelete: (String) -> Unit, onRefresh: () -> Unit 变成单个 onEvent: (ItemListEvent) -> Unit。ViewModel 的 onEvent(event) 用 when 分发处理——逻辑集中,测试覆盖也更容易写。
对只包含稳定类型的 data class 加上 @Immutable 注解,或对可能被 Compose 编译器误判为不稳定的类加 @Stable。这让 Compose 在父级重组时可以安全跳过这些组件,减少不必要的重组。
Modifier 顺序和 derivedStateOf:两个容易被忽视的细节
Modifier 顺序在 Compose 里不是风格问题,是语义问题。padding 在 clickable 之前,点击热区会更大(padding 先扩展了范围,然后 clickable 作用在扩展后的区域);反过来就截然不同。约定一个稳定的顺序——布局属性 → 形状 → 绘制 → 交互——有助于避免奇怪的视觉或交互 bug。
derivedStateOf 是另一个常被忽视的工具。当你需要根据某个高频变化的状态(比如滚动位置)派生出一个低频切换的布尔值(比如「是否显示返回顶部按钮」),不加任何处理的话,滚动的每次 offset 变化都会触发重组:
val showScrollToTop by remember {
derivedStateOf { listState.firstVisibleItemIndex > 5 }
}derivedStateOf 让 Compose 只在派生结果真正发生变化时才触发重组,而不是每次原始状态变化都跟着跑一遍。
跨平台:expect/actual 在 UI 层的边界
Compose Multiplatform 的跨平台能力在业务 UI 层几乎是透明的,真正需要 expect/actual 的地方往往是平台感知的系统 UI 控制——状态栏颜色、安全区 inset、键盘行为。把这些收进命名为 PlatformXxx 的 Composable 函数里,公共屏幕代码完全不感知平台差异。
反过来说,如果你发现 expect/actual 开始扩散到普通的业务组件,通常是一个信号:这个平台差异应该用数据(参数)来表达,而不是用代码分支。
一句话心法
状态塑形决定重组质量——把屏幕状态收进单一数据类,UI 才能真正做到「只是形状的映射」。