返回博客

Compose UI 的状态与重组——跨平台声明式 UI 的工程心法

声明式 UI 的核心承诺是「UI = f(state)」,但这个等式成立的前提是你把状态管理做对。我在用 Compose 和 Compose Multiplatform 搭跨平台界面的过程中,逐渐意识到:大多数「Compose 写起来怎么这么难调」的问题,根源不在 UI 层,而在状态的形状和流向设计得不好。

单一状态对象:让 UI 只有一个「真相来源」

最容易踩的坑是把屏幕状态拆成一堆零散的 mutableStateOf——isLoadingerroritems 各占一个变量,然后在协程里试图同步更新它们,最终发现某个中间态总会触发意外的重组或短暂的 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 里不是风格问题,是语义问题。paddingclickable 之前,点击热区会更大(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 才能真正做到「只是形状的映射」。