3. Composable 函数与声明式 UI 心智模型

Compose Multiplatform 的 UI 代码看起来像普通 Kotlin 函数,但它运行在一套完全不同的执行模型上。理解这套模型,是写出可维护、可预测界面的前提。本节围绕四个核心概念展开:可组合函数、重组、状态驱动 UI,以及副作用与纯函数的边界。

可组合函数:被 @Composable 标记的“描述”

被 @Composable 注解标记的函数称为可组合函数。它不返回 View 对象,也不直接操作控件,而是描述在给定输入下界面应该长什么样。例如:

@Composable
fun Greeting(name: String, modifier: Modifier = Modifier) {
    Text(
        text = "你好,$name",
        modifier = modifier.padding(16.dp),
        style = MaterialTheme.typography.bodyLarge
    )
}

这个函数没有 return,它的“返回值”是它向 Compose 运行时提交的 UI 描述。Compose 编译器插件会改写函数签名,注入一个隐藏的 Composer 参数,用于记录本次调用产生的节点、位置和状态依赖。因此可组合函数只能在其他可组合函数中调用,不能从普通函数里直接调用。

可组合函数应遵循几条约定:命名用大驼峰(与类一致,便于识别为 UI 单元);参数中 Modifier 通常放在第一个可选参数位置并向下传递;不要在函数体内做耗时计算或网络请求,这些应交给状态层或副作用 API。

重组:只重跑“受影响”的那部分

当可组合函数读取的状态发生变化时,Compose 会重新调用相关函数,这个过程叫重组(Recomposition)。关键点在于:重组不是整棵树重绘,而是从读取了该状态的最近可组合作用域开始,重新执行并对比新旧描述,只更新真正变化的节点。

重组可以跳过未受影响的子树。Compose 通过参数相等性判断是否需要重跑:如果传入的参数与上次相同(且类型稳定),该函数可被跳过。因此,把频繁变化的状态尽量下沉到最小的可组合函数中,能显著减少重组范围。例如把计数器状态放在 Counter 内部,而不是放在整个页面顶层,页面其他部分就不会因计数变化而重跑。

重组必须是幂等的:同一个函数在相同输入下多次执行,应产生一致的 UI 描述。不要在可组合函数体内直接修改外部变量或发起一次性操作,否则每次重组都会重复执行,导致难以排查的问题。

状态驱动 UI:用 remember 与 mutableStateOf 建立数据源

声明式 UI 的核心是“UI 是状态的函数”。在 Compose 中,可观察状态由 mutableStateOf 创建,配合 remember 在重组之间保留值:

@Composable
fun Counter() {
    var count by remember { mutableStateOf(0) }
    Column(Modifier.padding(16.dp)) {
        Text("当前计数:$count")
        Button(onClick = { count++ }) {
            Text("加一")
        }
    }
}

这里 count 是状态,Text 读取它,Button 修改它。点击按钮后,count 变化触发重组,Text 显示新值。整个过程中没有手动查找控件、没有调用 setText,界面自动与状态保持一致。

需要区分“状态”与“配置”:remember 保存的值在配置变化(如旋转屏幕)时会丢失,若需跨配置保留,应使用 rememberSaveable。此外,状态提升(State Hoisting)是常用模式:把状态从子组件移到父组件,子组件通过参数接收值、通过回调上报事件,从而让状态可被测试、可被复用。

副作用与纯函数边界

可组合函数理想上是纯函数:相同输入产生相同描述,不产生外部可观察影响。但真实应用需要启动协程、注册监听、写日志、请求权限,这些属于副作用。Compose 提供专门的副作用 API,让副作用与重组生命周期对齐:

  • LaunchedEffect(key):在进入组合时启动协程,key 变化时取消并重启,离开组合时取消。适合加载数据、订阅事件。
  • DisposableEffect(key):需要清理的资源(如注册/注销监听器)用它,在 onDispose 中释放。
  • SideEffect:每次成功重组后执行,用于把 Compose 状态同步给非 Compose 代码。
  • rememberCoroutineScope():获取与当前组合绑定的作用域,用于在回调中启动协程。

边界原则是:可组合函数负责描述 UI,副作用负责与外部世界交互。不要在函数体顶层直接调用挂起函数或修改全局变量;把这类操作放进上述 API,并给出稳定的 key,避免每次重组都重复执行。

声明式 UI 心智模型:状态 → 重组 → 描述 → 副作用边界 状态 State var count by remember { mutableStateOf(0) } 可观察 · 跨重组保留 事件 Event onClick = { count++ } 用户交互修改状态 重组 Recomposition 读取状态的最近作用域重跑 参数相等 → 跳过子树 幂等:同输入同描述 只更新变化的节点 UI 描述 Text("当前计数:$count") Button(onClick = ...) 函数不返回 View,只提交描述 副作用边界 Side Effect LaunchedEffect(key) → 启动协程,key 变化重启 DisposableEffect(key) → onDispose 释放资源 SideEffect → 每次重组后同步外部状态 rememberCoroutineScope() → 回调中启动协程 原则:可组合函数描述 UI,副作用与外部世界交互 避免在函数体顶层直接挂起或修改全局变量 状态变化 重新描述 写入 触发 需要时

把心智模型落到代码结构上

综合以上四点,一个可维护的 Compose 页面通常这样组织:状态层持有 mutableStateOf 或 StateFlow;可组合函数只读取状态并描述 UI;事件通过回调向上传递;副作用集中在 LaunchedEffect / DisposableEffect 中,并给出稳定 key。判断一段代码是否“声明式”,可以问三个问题:它是否直接修改了控件?它是否在函数体内做了与 UI 描述无关的事?它的输出是否只由输入和状态决定?如果答案分别是“否、否、是”,说明已经进入声明式思维。

调试重组时,可用 Layout Inspector 观察重组次数,或临时在可组合函数中打日志。若发现某函数反复重跑,优先检查:参数是否不稳定(如每次新建的 lambda 或 List)、状态是否放得过高、是否在函数体内创建了对象。
常见误区:把 remember 当成“缓存一切”的工具,或在 LaunchedEffect 中遗漏 key 导致数据不刷新。记住 remember 只跨重组保留值,不跨配置变化;LaunchedEffect 的 key 决定何时重启,key 选错会让副作用行为与预期不符。

掌握这套模型后,后续的布局、列表、动画与导航都会变得自然:它们本质上都是在不同场景下描述状态与 UI 的映射关系,并在需要时通过副作用与外部系统对接。