关于 compose-state-hoisting
面向 Android Compose 开发者的指导技能,帮助按需求确定 UI 状态和屏幕状态的归属层级。它区分本地状态、纯 UI 状态持有者与屏幕级业务状态,并将应用接线与界面渲染分开。
主要功能
- 01为状态选择最低所需归属层级
- 02为协同 UI 行为提取纯状态持有者
- 03分离屏幕接线与 UI 渲染
- 04在组合范围内处理 Compose 副作用
- 05仅保存可序列化的状态值
适用场景
- 01审查 Compose 状态应放置的位置
- 02重构屏幕以隔离应用依赖
- 03使预览和测试不依赖应用接线
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ chrisbanes/ skills --skill compose-state-hoisting安装后在 Claude Code 中输入/compose-state-hoisting确认已加载。
详细介绍
Compose 状态提升
核心原则
状态只提升到逻辑所需的范围。简单的 UI 元素状态保留在本地;共享的 UI 元素状态提升到最低共同可组合项所有者;当仅限 UI 的行为演变为一个概念时,提取普通状态持有者;涉及业务逻辑或应用数据时,使用屏幕状态持有者。在屏幕边界处,将状态持有者的接线与由普通状态驱动的 UI 渲染分开。
审查流程
- 列出涉及的状态、操作、应用依赖项、事件流和命令式副作用。
- 根据下面的决策指南,将每项分配给需要读取或修改它的最低层级所有者。
- 仅当协调的、仅限 UI 的行为已经形成一个概念时,才提取普通状态持有者。
- 当屏幕同时包含应用接线和布局时,保留一个小型状态持有者可组合项,并将渲染移至普通的状态驱动可组合项。
- 跨越该边界时,传递不可变 UI 状态和显式事件回调;除非业务逻辑需要其值,否则将 UI 机制保留在组合中。
- 当相关问题需要深入处理时,加载专门的副作用、测试、焦点或延迟读取技能。
- 当 UI 无需应用依赖项即可预览和测试、业务工作仍由屏幕状态持有者负责,且没有状态被提升到超出其逻辑所需的范围时,即可完成。
决策指南
| 情况 | 所有者 |
|---|---|
| 一个可组合项读取/写入简单状态 | 使用 remember / rememberSaveable 保留在本地 |
| 同级或父级可组合项需要读取/写入该状态 | 将状态和事件提升到它们的最低共同可组合项祖先 |
| 相关的 UI 元素状态和 UI 逻辑使可组合项难以阅读、预览或测试 | 提取一个在组合中记忆的普通状态持有者类 |
| 涉及仓库调用、持久化、业务规则或屏幕 UI 状态生成 | 使用屏幕级状态持有者,例如 ViewModel 或组件 |
| 屏幕可组合项收集应用状态/副作用,同时还负责大部分布局 | 保留一个小型接线可组合项,并提取一个接收不可变状态和回调的普通 UI 可组合项 |
UI 元素状态包括展开状态、底部工作表可见性、滚动位置、焦点、文本字段编辑状态、选择状态以及动画/交互状态等。屏幕 UI 状态是为显示而准备的应用数据。
如果 UI 元素状态是业务逻辑的输入,它也可能需要放在屏幕状态持有者中。例如,用于查询由仓库提供建议的文本,应与生成这些建议的状态持有者放在一起。
普通状态持有者的适用条件
当以下情况中有多项成立时,提取普通状态持有者:
- 多个相关的
remember值由相同的回调协调。 - 滚动、焦点、文本、选择或底部工作表状态需要
clear、submit、jumpToTop或openFilters等命名操作。 - 派生的 UI 标记分散在可组合项中。
- 子级可组合项接收了它们在概念上并不拥有的机制。
- 预览或测试必须驱动一长串 UI 细节才能检查一个行为。
- 辅助函数需要许多状态参数才能让可组合项保持易读。
不要仅为一个布尔值、一个文本字段或简单的显示/隐藏逻辑而提取。繁琐的仪式并不等于关注点分离。
模式
使用普通类保存 UI 元素状态和 UI 逻辑,并使用 remember...State 函数保存由组合拥有的对象:
@Stable
class ProductSearchState(
query: String,
private val listState: LazyListState,
private val focusRequester: FocusRequester,
) {
var query by mutableStateOf(query)
private set
var filtersOpen by mutableStateOf(false)
private set
val canClear: Boolean
get() = query.isNotEmpty()
fun updateQuery(value: String) {
query = value
}
fun clear() {
query = ""
focusRequester.requestFocus()
}
suspend fun jumpToTop() {
listState.animateScrollToItem(0)
}
}
@Composable
fun rememberProductSearchState(
initialQuery: String = "",
listState: LazyListState = rememberLazyListState(),
focusRequester: FocusRequester = remember { FocusRequester() },
): ProductSearchState {
return remember(listState, focusRequester) {
ProductSearchState(initialQuery, listState, focusRequester)
}
}
可组合项根据状态持有者进行渲染,并调用意图式方法。如果父级需要协调相同的 UI 行为,则将状态持有者作为参数接收,并提供默认值:
@Composable
fun ProductSearchPanel(
state: ProductSearchState = rememberProductSearchState(),
modifier: Modifier = Modifier,
) {
val scope = rememberCoroutineScope()
SearchField(
query = state.query,
onQueryChange = state::updateQuery,
onClear = state::clear,
)
JumpToTopButton(onClick = {
scope.launch { state.jumpToTop() }
})
}
组合所有权
使用 remember 创建的普通状态持有对象会遵循可组合项的生命周期。因此,它们适合用来持有 LazyListState、FocusRequester、PagerState、DrawerState 和 TextFieldState 等 Compose UI 对象。
需要帧时钟的挂起式 UI 操作(例如滚动或抽屉动画),应放在由组合管理的协程中(rememberCoroutineScope、LaunchedEffect 或其他由组合拥有的作用域)。不要将这些调用移到 viewModelScope 中。
保存状态
仅对需要在 Activity 或进程重建后保留的值使用 rememberSaveable 或自定义 Saver,例如查询字符串、所选筛选器 ID 或当前标签页键。
不要尝试直接保存 LazyListState、FocusRequester、协程作用域或回调等运行时对象。只保存重建行为所需的最少可序列化值。
将屏幕依赖接线与 UI 渲染分离
当屏幕接收 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,
)
}
@Composable
fun ProfileScreen(
state: ProfileUiState,
onNameChange: (String) -> Unit,
onSaveClick: () -> Unit,
onBackClick: () -> Unit,
modifier: Modifier = Modifier,
) {
// Layout only.
}
有意地划定边界:
| 关注点 | 状态持有可组合项 | 普通 UI 可组合项 |
|---|---|---|
| 收集应用/业务状态和一次性副作用 | 是 | 否 |
| 持有通过依赖注入获取的对象 | 是 | 否 |
| 接收不可变的 UI 状态和事件回调 | 通常会将其传递下去 | 是 |
| 拥有布局、修饰符、语义和测试标签 | 否或仅限少量 | 是 |
拥有 LazyListState 或 FocusRequester 等 Compose 运行时对象 | 否 | 是,直接拥有或放在普通 UI 状态持有对象中 |
| 接收从 UI 机制派生的业务相关值或意图 | 是 | 提供这些值,但不暴露运行时对象 |
传递最精简且实用的 UI 契约:
- 当屏幕状态紧密相关时,优先使用专用的不可变
UiState。 - 优先使用明确的事件回调,而不是将整个状态持有对象传递到组件树中。
- 将导航作为描述用户意图的回调。
- 如果直接使用领域模型会将业务规则带入渲染逻辑,则应将其映射为 UI 模型。
- 对于应在布局或绘制时读取的帧率值,按照
compose-state-deferred-reads传递提供器 lambda。
应在状态持有对象附近处理导航、Snackbar、分析或事件收集,因为在那里可以访问事件来源和命令式目标。如果副作用处理逻辑变得复杂,应提取一个小型的同级副作用处理器,而不是将状态持有对象传给 UI 可组合项。使用 compose-side-effects 了解副作用 API、键、清理和过时捕获。
不要为每个小型可组合项都创建状态持有/UI 重载。应在屏幕或具有整体性的区块边界处分离,前提是这样做能从值得预览、测试或复用的重要 UI 中移除应用依赖。
不要将这种拆分用于已经只接收普通值和回调的微小一次性可组合项、应公开插槽和修饰符的设计系统原语,或仅会转发一个原语且无法隔离应用依赖的包装器。
RED/GREEN 代理场景
对于每个场景,先省略或撤销相关规则以建立 RED 状态,然后恢复该技能并要求达到 GREEN 结果。
- 某个界面接收一个组件、收集
StateFlow、处理导航事件,并负责大部分布局。GREEN 状态下,组件、收集逻辑和副作用处理都保留在一个精简的接线 composable 中,然后提取一个纯 UI composable,接收不可变状态和回调。 - 新场景:搜索查询驱动由仓库提供的建议,而
LazyListState和FocusRequester协调 UI。GREEN 状态下,将查询和建议逻辑移到界面状态持有者,但将 Compose 运行时对象留在纯 UI 或纯 UI 状态持有者中。 - 过度应用的反例:无状态的设计系统徽标接收纯值、插槽和修饰符。GREEN 状态下,不创建状态持有者/UI 重载,也不为了结构对称而引入
ViewModel。
常见错误
| 错误 | 修正方法 |
|---|---|
| 为了“以防万一”将每个局部状态值都提升到父级 | 提升到实际读取或写入该状态的最低层级所有者 |
| 为一个布尔值提取纯状态持有者 | 将简单的私有 UI 状态保留在本地 |
| 在 Compose 状态持有者中放置仓库调用或产品规则 | 将这些逻辑移到界面状态持有者,例如 ViewModel 或组件 |
| 文本或选中项会驱动由仓库提供的界面状态,却仍保留在本地 | 将该输入及业务逻辑移到界面状态持有者 |
| 将状态持有者深层传入无关的子组件 | 传递纯值和回调,除非子组件确实需要协调该持有者的行为 |
| 将状态持有者当作整个界面的杂物堆 | 按内聚的 UI 行为拆分,例如搜索输入、底部表单协调或列表控件 |
从 viewModelScope 调用动画挂起函数 | 使用作用域限定于组合的协程 |
| 界面 composable 接收组件并渲染所有布局 | 提取一个接收状态和回调的纯 UI 重载 |
子 composable 接收 ViewModel 或组件 | 仅传递每个子组件所需的值和回调 |
| UI 渲染执行导航或收集应用事件流 | 在界面状态持有者旁处理副作用 |
| 每个小型 composable 都创建状态持有者重载 | 仅在界面或内聚区段边界处拆分 |
相关内容
compose-state-authoring— 正确编写局部remember和可变状态。compose-side-effects— 选择副作用 API 和作用域限定于组合的协程边界。compose-focus-navigation— 焦点状态、请求器以及键盘/D-pad 行为。compose-ui-testing-patterns— 无需构建完整应用依赖图,即可测试由纯状态驱动的 UI。kotlin-multiplatform-expect-actual— 保持共享 UI 纯粹,同时将平台服务置于语义边界之后。