第2章 核心三件套:状态、重组与副作用

说实话,很多人学Compose上来就怼布局,写一堆Column、Row,觉得挺简单。但真到做复杂应用时,状态一乱,UI就不受控了。我见过太多这样的项目——界面看着漂亮,一交互就崩。

要想用好Compose,你得先搞明白三个核心概念:状态驱动、重组、副作用。说白了,它们就是Compose世界的运行法则。搞不懂这几样,后面做离线架构肯定踩坑。

Jetpack Compose 核心三件套关系图 状态来源 用户事件(点击/输入) 数据层(网络/数据库) 系统配置(旋转/暗黑) 定时器 / 动画帧 Compose 运行时 状态管理 mutableStateOf() 重组引擎 自动追踪变化 副作用处理 LaunchedEffect 等 UI 输出 Composable 函数树 布局 / 绘制 主题 / 动画 反馈循环:用户交互更新状态

状态管理:别让UI牵着鼻子走

Compose是响应式的。状态变了,UI自动变。你不用手动调用setText()或notifyDataSetChanged()。我个人觉得,这是Compose最爽的地方——但也是最容易翻车的地方。

什么是状态? 说白了,就是随时间变化的数据。比如列表加载中、用户输入的文字、开关的选中状态。这些数据一旦变了,UI要跟着更新。

Compose管理状态的核心工具是mutableStateOf()。它包装一个值,当值变化时,Compose会自动重新组合那些读取了这个值的Composable函数。你想想看:

@Composable
fun Counter() {
    var count by remember { mutableStateOf(0) }

    Button(onClick = { count++ }) {
        Text("点击了 $count 次")
    }
}

这里count变了,只有Text会重新显示。其他部分不受影响。嗯,这就是细粒度更新。

状态类型 定义位置 作用域 生命周期 示例
局部状态 Composable内部 单个界面 随Composable消失 remember { mutableStateOf(...) }
提升状态 父Composable 多个子界面共享 由父控制 参数传递 + (String, (String)->Unit)
ViewModel状态 ViewModel 整个界面/导航 比UI长(配置变化存活) viewModel.uiState.collectAsState()
我的个人习惯: 简单UI用remember就够了。一旦状态需要跨屏幕共享、或者需要生命周期的庇护(比如旋转屏幕不丢失),果断上ViewModel里的StateFlow。

我在项目中遇到过把网络请求结果直接扔给Composable做参数的坑,结果每次请求回来整个界面闪一下。后来我才明白,要把状态提升到ViewModel里统一管理。避免重复请求和状态混乱。

重组:Compose的“脏检查”机制

重组(Recomposition)这个词,你肯定听烂了。究竟它是什么?

重组不是全量重绘。 Compose只更新那些输入变了的部分。你想想看,如果每次状态变化都把UI所有部分重新画一遍,那性能岂不是崩塌?

Compose的聪明之处在于:它把UI组织成一颗函数树。状态变化后,只重新执行那些“读了这个状态”的Composable函数。其他节点保持不变。

举个我踩过的坑:

@Composable
fun MyScreen() {
    var text by remember { mutableStateOf("") }
    // 大列表,每次text变化都重走一遍?
    LazyColumn {
        items(100) { index ->
            Text("Item $index") // 根本不用读 text
        }
    }
    TextField(value = text, onValueChange = { text = it })
}

按说 LazyColumn 不读 text,所以它不该跟着 text 变化。对的,Compose会跳过它。我当时自己验证了一遍,才放下心来。

重组触发条件:

  • mutableStateOf 的值变化
  • StateFlow / LiveData 发射新值(通过 collectAsState() 转换)
  • Composable 的输入参数变化
  • key() 调用的值变化
我曾经血泪教训: 重组不是无限循环。但如果你在重组时又修改了状态,就会触发新的重组。这就是“无限重组”崩溃。最常见的场景:在Composable内部直接调用LaunchedEffect没有指定key,导致每次重组都重新启动协程。千万别干这事。

副作用:和外部世界打交道

Composable函数应该是一个纯函数——只基于输入输出。但实际应用中,你得发起网络请求、订阅数据库、更新状态栏。这些“外部操作”就是副作用。

Compose提供了几个特殊的Effect来处理它们:

  • LaunchedEffect:在进入组合时启动协程,退出时自动取消。适合一次性的网络请求。
  • DisposableEffect:你需要在离开时清理资源?用它。比如注册广播接收器。
  • SideEffect:每次重组都执行。适合记录日志、更新外部状态。
  • rememberCoroutineScope:在Composable外部启动协程,比如按钮点击后触发。
@Composable
fun UserProfile(userId: String) {
    var userData by remember { mutableStateOf<User?>(null) }

    // LaunchedEffect 仅在 userId 变化时重新执行
    LaunchedEffect(userId) {
        userData = repository.fetchUser(userId)
    }

    // ...
}
小提示: 什么时候用DisposableEffect而不是LaunchedEffect?当你注册监听器、打开文件、订阅Bus时需要。LaunchedEffect只能在协程容器里跑,不适合需要手动释放的API。

我在项目中使用DisposableEffect来注册网络状态监听。离开页面时取消注册,这样不会内存泄漏。

DisposableEffect(Unit) {
    val listener = NetworkCallback { isOnline ->
        // 更新状态
    }
    connectivityManager.registerDefaultNetworkCallback(listener)
    onDispose {
        connectivityManager.unregisterNetworkCallback(listener)
    }
}

最佳实践与避坑指南

千万不要: 在Composable函数内部直接写launch或async。没有Effect包装,每次重组都会新发起请求。我曾经因为这事被测试妹子追着问:“为什么我点一次按钮,后台收到十个请求?”

还有一个小技巧:用 remember 加 key 减少不必要的计算。比如:

val sortedList = remember(list, sortType) {
    list.sortedWith(getComparator(sortType))
}

只有 list 或 sortType 变了才重新排序。我之前有个列表页,每次滑动都卡,查了才发现每次重组都重新计算复杂排序。用remember加key之后,流畅多了。

总之——状态是数据,重组是机制,副作用是桥梁。三样东西配合好了,离线架构才能稳稳落地。下一部分我们就要动手搭建真正的离线框架了,这些基础一定要啃透。