关于 compose-state-holder-ui-split
面向 Compose 开发者的状态持有者与 UI 分离技能,将状态收集和副作用留在状态持有层,让 UI 仅依赖不可变状态与回调,便于预览和测试。
主要功能
- 01分离状态接线与界面布局
- 02在状态持有层收集业务状态
- 03通过状态对象和回调传递 UI 契约
- 04在状态持有层处理副作用
- 05区分 UI 本地状态与业务状态
适用场景
- 01拆分依赖 ViewModel 的屏幕布局
- 02让 UI 测试无需构造完整应用依赖
- 03将导航事件改为显式回调
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ chrisbanes/ skills --skill compose-state-holder-ui-split安装后在 Claude Code 中输入/compose-state-holder-ui-split确认已加载。
详细介绍
Compose:状态持有者与 UI 分离
核心原则
将状态持有者的接线与 UI 渲染分离。状态持有者可组合项与 ViewModel、组件、流、导航和副作用交互。UI 可组合项接收纯粹的不可变 UI 状态和回调,并描述布局。
这样一来,屏幕更易于预览、测试,也更容易在 Android、Desktop、TV 和 KMP/CMP 目标平台之间复用。
何时使用此技能
当 Compose 屏幕符合以下情况时,使用此技能:
- 直接接收 ViewModel、组件、控制器、导航器、存储库或服务。
- 在布局大部分 UI 的同一个函数中收集应用/业务状态或副作用。
- 将整个状态持有者传给子可组合项,而不是显式传递状态和回调。
- 因为需要依赖注入、导航、生命周期或伪服务而难以预览。
- UI 测试必须构建完整的应用栈,才能验证一个简单的布局分支。
模式
使用一个小型公开状态持有者可组合项:
@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
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,
)
}
私有内容函数可以拆分布局:
@Composable
private fun ProfileContent(
name: String,
isSaving: Boolean,
canSave: Boolean,
onNameChange: (String) -> Unit,
onSaveClick: () -> Unit,
onBackClick: () -> Unit,
modifier: Modifier = Modifier,
) {
// Layout only.
}
经验法则
| 关注点 | 状态持有者可组合项 | UI 可组合项 |
|---|---|---|
| 收集 ViewModel/组件状态 | 是 | 否 |
| 收集单次触发的副作用 | 是,或由一个精简的同级副作用处理器负责 | 通常否 |
| 持有通过依赖注入获取的对象 | 是 | 否 |
| 接收不可变 UI 状态 | 通常会透传 | 是 |
| 接收用于用户事件的 lambda | 负责接线 | 调用它们 |
| 负责布局、修饰符、语义、测试标签 | 否/尽量少 | 是 |
| 持有滚动、焦点、文本输入、动画、交互等 UI 局部状态 | 有时会为其提供初始值 | 是 |
| 适合预览/截图 | 不一定 | 是 |
“UI 可组合项中不收集状态”这条规则针对的是应用/业务状态和副作用流。普通 UI 可组合项仍可以持有 UI 局部框架状态:rememberScrollState、rememberLazyListState、FocusRequester、焦点状态、动画状态、TextFieldState、MutableInteractionSource.collectIsPressedAsState(),以及其他属于所渲染控件的类似行为。
如果这种 UI 局部状态发展成由多个相关字段和操作构成的协同行为,请使用 compose-state-hoisting 判断是否应将其改为在组合中记住的普通状态持有者类。
传递什么
传递尽可能精简且实用的 UI 契约:
- 如果屏幕确实有状态,优先使用专门的
UiState/State对象,而不是许多互不相关的基本类型。 - 优先使用显式 lambda(
onRetryClick、onItemSelected),而不是传递整个组件。 - 如果领域模型会迫使业务规则进入 UI 可组合项,就不要在其中使用领域模型。若 UI 需要不同的数据结构,则映射为 UI 模型。
- 将导航作为回调处理。UI 可组合项表达的是“用户点击了返回”,而不是“导航到路由 X”。
- 对于变化时不应导致整棵树重新组合的帧率或 UI 局部值:优先使用提供器 lambda,并按
compose-state-deferred-reads延迟读取。
副作用
compose-side-effects 介绍了副作用 API(LaunchedEffect、DisposableEffect、SideEffect)、键、清理和 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),而不是将组件传入 UI 可组合项。
常见错误
| 错误 | 问题所在 | 修复方法 |
|---|---|---|
fun Screen(viewModel: MyViewModel) 包含所有布局 | 没有 Android 生命周期和依赖注入时,难以预览和测试 | 添加一个接收 state 和回调的纯 UI 重载 |
子组合函数接收 component | 依赖会沿组件树泄漏 | 只传递子组件所需的状态和回调 |
| UI 组合函数启动导航 | UI 会与应用路由耦合 | 暴露 onBackClick、onItemClick 等回调 |
| UI 组合函数收集应用或业务流程 | 收集生命周期隐藏在布局中 | 在状态持有者附近收集值,并向下传递 |
| 没有理由就将 UI 局部状态提升到状态持有者中 | 状态持有者开始负责布局机制 | 如果滚动、焦点、动画和文本字段交互状态仅属于 UI 行为,就将其保留在 UI 组合函数中 |
| 每个细小的组合函数都有一个状态持有者重载 | 仪式感太强 | 在屏幕或区域边界拆分,而不是为每个 Row 拆分 |
不适用的情况
- 已经接收纯值和回调的微型一次性组合函数。
Button、Card或ListItem等设计系统基础组件;它们应暴露插槽和修饰符,而不是状态持有者。- 状态持有者组合函数只会转发一个基础组件,无法提供隔离的情况。
相关内容
compose-ui-testing-patterns— 在不加载完整应用依赖图的情况下,测试由纯状态驱动的 UI 组合函数。compose-state-hoisting— 决定 UI 元素状态和 UI 逻辑应放在哪里,包括纯状态持有者类。kotlin-multiplatform-expect-actual— 共享 UI 涉及平台特定底层组件时,了解平台服务、原生视图以及 expect/interface 边界。