用途
该技能用于指导 Jetpack Compose 屏幕级 composable 的拆分:把「状态持有者接线」与「UI 渲染」分离。状态持有者 composable 负责与 ViewModel、组件、Flow、导航和副作用交互;UI composable 只接收纯不可变 UI 状态加回调,并描述布局。这样可让屏幕更易预览、测试,也更容易在 Android、Desktop、TV 和 KMP/CMP 目标间复用。
何时使用
当 Compose 屏幕出现以下情况时使用:
- 直接接收 ViewModel、component、controller、navigator、repository 或 service。
- 在同一个负责大部分 UI 布局的函数中收集应用/业务状态或副作用。
- 把整个状态持有者传给子 composable,而不是传显式状态和回调。
- 因为需要依赖注入、导航、生命周期或假服务而难以预览。
- UI 测试必须构造完整应用栈才能验证一个简单布局分支。
模式
使用一个小的公开状态持有者 composable:
@Composable
fun ProfileScreen(component: ProfileComponent, modifier: Modifier = Modifier) {
val state by component.state.collectAsStateWithLifecycle()
ProfileScreen(
state = state,
onNameChange = component::onNameChange,
onSaveClick = component::save,
onBackClick = component::back,
modifier = modifier,
)
}
然后把 UI 放在一个完全不了解状态持有者的普通 composable 中:
@Composable
fun ProfileScreen(
state: ProfileUiState,
onNameChange: (String) -> Unit,
onSaveClick: () -> Unit,
onBackClick: () -> Unit,
modifier: Modifier = Modifier,
) {
ProfileContent(
name = state.name,
isSaving = state.isSaving,
canSave = state.canSave,
onNameChange = onNameChange,
onSaveClick = onSaveClick,
onBackClick = onBackClick,
modifier = modifier,
)
}
私有 content 函数可用于拆分布局:
@Composable
private fun ProfileContent(
name: String,
isSaving: Boolean,
canSave: Boolean,
onNameChange: (String) -> Unit,
onSaveClick: () -> Unit,
onBackClick: () -> Unit,
modifier: Modifier = Modifier,
) {
// Layout only.
}
经验规则
| 关注点 | 状态持有者 composable | UI composable |
|---|---|---|
| 收集 ViewModel/组件状态 | 是 | 否 |
| 收集一次性副作用 | 是,或一个很小的同级副作用处理器 | 通常否 |
| 持有依赖注入对象 | 是 | 否 |
| 接收不可变 UI 状态 | 通常透传 | 是 |
| 接收用户事件 lambda | 接线 | 调用 |
| 拥有布局、modifier、语义、测试标签 | 否/极少 | 是 |
| 拥有 UI 本地状态,如滚动、焦点、文本输入、动画、交互 | 有时初始化 | 是 |
| 便于预览/截图 | 不一定 | 是 |
「UI composable 中不收集」规则针对的是应用/业务状态和副作用流。普通 UI composable 仍可拥有 UI 本地的框架状态:rememberScrollState、rememberLazyListState、FocusRequester、焦点状态、动画状态、TextFieldState、MutableInteractionSource.collectIsPressedAsState(),以及类似属于被渲染组件的行为。
如果这些 UI 本地状态增长为涉及多个相关字段和操作的协调行为,可参考 compose-expert(状态提升部分)判断是否应改为在组合中 remember 的普通状态持有者类。
传什么
传递最小可用的 UI 契约:
- 当屏幕有真实状态时,优先使用专门的 UiState/State 对象,而不是许多不相关的原始值。
- 优先使用显式 lambda(onRetryClick、onItemSelected),而不是传整个 component。
- 如果领域模型会迫使业务规则进入 UI,就不要把它放进 UI composable;当 UI 需要不同形状时映射为 UI 模型。
- 导航保持为回调。UI composable 表达「用户点击了返回」,而不是「导航到路由 X」。
- 帧率或 UI 本地值变化时不应强制整棵树重组:按 compose-state-deferred-reads 优先使用 provider lambda 和延迟读取。
副作用
compose-side-effects 涵盖副作用 API(LaunchedEffect、DisposableEffect、SideEffect)、key、清理和 rememberUpdatedState。
在状态持有者附近处理副作用,因为那里同时具备副作用来源和命令式目标:
@Composable
fun ProfileScreen(component: ProfileComponent, snackbarHostState: SnackbarHostState) {
val state by component.state.collectAsStateWithLifecycle()
LaunchedEffect(component) {
component.effects.collect { effect ->
when (effect) {
ProfileEffect.Saved -> snackbarHostState.showSnackbar("Saved")
}
}
}
ProfileScreen(state = state, onSaveClick = component::save)
}
如果副作用处理变复杂,应提取 ProfileEffects(component, snackbarHostState),而不是把 component 推入 UI composable。
常见错误
| 错误 | 危害 | 修复 |
|---|---|---|
| fun Screen(viewModel: MyViewModel) 包含所有布局 | 没有 Android 生命周期和 DI 时难以预览/测试 | 增加接收状态和回调的普通 UI 重载 |
| 子 composable 接收 component | 依赖沿树泄漏 | 只传子组件需要的状态/回调 |
| UI composable 发起导航 | UI 与应用路由耦合 | 暴露 onBackClick、onItemClick 等 |
| UI composable 收集应用/业务流 | 收集生命周期隐藏在布局中 | 在状态持有者附近收集并向下传值 |
| 无理由把 UI 本地状态提升到状态持有者 | 状态持有者开始拥有布局机制 | 当滚动/焦点/动画/文本框交互状态只是 UI 行为时,保留在 UI composable 中 |
| 每个微小 composable 都加状态持有者重载 | 仪式感过重 | 在屏幕/区块边界拆分,而不是每个 Row |
何时不适用
- 已经接收普通值和回调的微小一次性 composable。
- 设计系统原语,如 Button、Card 或 ListItem;它们应暴露 slot 和 modifier,而不是状态持有者。
- 状态持有者 composable 只转发一个原始值且没有增加隔离的情况。
相关
- compose-expert — Amethyst 的共享 UI 模式,包括 UI 元素状态的状态提升和普通状态持有者类。
- compose-side-effects — Compose 中的副作用 key 和清理。
- compose-state-deferred-reads — 跨边界传递的帧率/UI 本地值的延迟读取。
- kotlin-multiplatform — 共享 UI 遇到平台特定叶子时的平台服务、原生视图和 expect/interface 边界。