关于 compose-state-authoring
介绍 Compose 局部界面状态管理和正确使用 `@ReadOnlyComposable` 的编码指南,帮助开发者避免状态重组时重置或无法触发重组,并说明何时可声明只读取组合状态。
主要功能
- 01用 `remember` 保留局部状态
- 02用快照状态触发更新
- 03选择可观察的状态集合
- 04避免在组合期间回写状态
- 05按契约使用 `@ReadOnlyComposable`
适用场景
- 01修复重组时重置的局部变量
- 02让集合变更可被观察
- 03根据输入生成只读快照
- 04审查只读组合访问器
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ chrisbanes/ skills --skill compose-state-authoring安装后在 Claude Code 中输入/compose-state-authoring确认已加载。
详细介绍
Compose 状态编写
并非所有 remember { … } 都属于本技能。本技能涵盖局部 UI 状态(remember { mutableStateOf(…) }、mutableStateListOf / mutableStateMapOf)和 @ReadOnlyComposable。其他记忆化 API 请参阅专门的技能:
rememberCoroutineScope/rememberUpdatedState→compose-side-effects- 用于帧率读取的
rememberLazyListState/rememberScrollState→compose-state-deferred-reads - 焦点导航、焦点状态、
FocusRequester所有权、行为 →compose-focus-navigation
核心原则
@Composable 是一个函数,每当其输入发生变化时,运行时都会重新执行它。正确编写局部状态归结为两个问题:
- 可变局部状态——我的
var能否在重组后保留,并触发重组?如果不能,它会在每次重组时悄然重置,写入也不会被观察到。 - 这是什么类型的可组合项?——我是在修改组合(放置布局节点、分配槽位、使用
remember),还是只读取它?如果只是读取,@ReadOnlyComposable可以让运行时跳过工作。
这两点中任意一点处理不当,都会造成不易察觉的问题:状态消失,或优化无法生效。
何时使用本技能
编写或审查 Compose 代码时,如果遇到以下任一情况:
@Composable fun或任何可组合 lambda(Column { var x = … })内的var x = …- 函数体从不进行布局的
@Composable fun(或@Composable get()属性访问器) - 调用
Text、Box、Column、remember……的函数上标注了@ReadOnlyComposable - 可组合项的可见状态在旋转设备、主题变更或重组时莫名重置
1. 可组合项中的 var 必须由 State 支持
重组会从头重新执行可组合项。局部 var 每次都会重新初始化——上一次重组的值会丢失,而且对它的写入不会通知运行时进行重组。
// ❌ BAD — counter resets on every recomposition; clicks never update the UI
@Composable
fun Counter() {
var count = 0
Button(onClick = { count++ }) { Text("$count") }
}
// ❌ ALSO BAD — same rule applies inside composable content lambdas
@Composable
fun Wrapper() {
Row {
var count = 0 // Row's content lambda is @Composable too
// …
}
}
// ✅ GOOD — `remember` survives recomposition, `mutableStateOf` triggers it
@Composable
fun Counter() {
var count by remember { mutableStateOf(0) }
Button(onClick = { count++ }) { Text("$count") }
}
有两部分,二者都很重要:
remember { … }——在重组后保留。没有它,值会在每次重组时重新创建。mutableStateOf(…)——触发重组。没有它,运行时就观察不到修改。
对于集合,优先使用 mutableStateListOf / mutableStateMapOf(同样要用 remember 记住)。它们每次读取都会产生 Snapshot 读取,每次修改都会产生 Snapshot 写入。使用 remember { mutableStateOf(mutableListOf<X>()) } 后再调用 list.add(x),不会触发重组,因为 MutableList.add 不会通过 State setter;必须替换该值(state = state + x)。
在组合期间回写快照状态
回写是指在会触发较早阶段(或当前阶段)失效的阶段写入可观察状态。从可组合项函数体中修改 mutableState* 会回写到同一轮组合,并安排另一次组合。不要用这种方式重建派生数据:
// ❌ BAD — clear + putAll on every composition
val merged = remember { mutableStateMapOf<Key, ViewState>() }
merged.clear()
merged.putAll(parent)
merged.putAll(overlay)
// ✅ GOOD — immutable snapshot remembered from inputs
val merged = remember(parent, overlay) {
if (overlay.isEmpty()) parent else parent + overlay
}
如果当前输入对应的结果是只读的,使用 remember(keys) { … } 就足够了。跨行测量和测量阶段的修复方法,请参阅 compose-state-deferred-reads。
此规则不适用的情况
- **在
remember { … }的生成器代码块内。**它只会在键变化时运行一次,不会在每次重组时运行。在其中使用局部var没问题:val builder = remember { mutableListOf<X>().apply { var n = 0; … } }。 - 传递出可组合项的非
@Composablelambda 内。onClick = { var a = 0; … }是普通的() -> Unit。其中的局部变量就是常规 Kotlin 变量。 - **普通的(非
@Composable)辅助函数内。**只有可组合作用域会受到影响。
2. @ReadOnlyComposable 契约
@ReadOnlyComposable 声明一个可组合函数只读取组合状态——没有 Text、没有 Box、没有 remember、没有布局节点、没有位置槽位。这样,运行时就可以跳过为该调用分配组,这对快速的访问器式可组合函数(MaterialTheme.colorScheme、LocalDensity.current、设计系统令牌访问器)很重要。
该契约是双向的:
- 添加
@ReadOnlyComposable:当函数体中的每个可组合函数调用本身都是@ReadOnlyComposable时(或者完全没有可组合函数调用,例如只读取LocalFoo.current并返回值的函数)。 - 不要添加:如果你调用了任何非只读可组合函数。该优化假定你不参与组合;违反这一点会导致调用方出现错误的重组行为。
// ✅ GOOD — only reads composition locals, no layout, no remember
@Composable
@ReadOnlyComposable
fun appSpacing(): Dp = LocalDimensions.current.spacing
// ✅ GOOD — composable property getter; same rule
val accent: Color
@Composable @ReadOnlyComposable
get() = MaterialTheme.colorScheme.tertiary
// ❌ BAD — annotated read-only but lays out a Box; contract violated
@Composable
@ReadOnlyComposable
fun Header(): Int {
Box {} // ← non-read-only composable call
return 42
}
// ❌ BAD — calls a normal composable from a read-only one
@Composable
@ReadOnlyComposable
fun computed(): Int = nonReadOnlyHelper()
判断“是否应该添加”的经验法则
如果函数体包含以下任意内容,不要添加 @ReadOnlyComposable:
- 布局调用:
Box、Column、Row、LazyColumn、Text,或任何来自androidx.compose.foundation.layout或androidx.compose.material*的内容。 - 副作用调用:
LaunchedEffect、DisposableEffect、SideEffect、produceState。 remember { … }——位置记忆化属于组合状态。- 调用
@Composablelambda(content())。 - 调用非
@ReadOnlyComposable的可组合函数。
如果函数体只读取 Local*.current、调用其他 @ReadOnlyComposable 函数,或执行纯计算,就添加。
此规则不适用的情况
override fun声明。 注解属于契约的一部分;如果基类没有标注@ReadOnlyComposable,就不能将覆盖函数标注为只读。请重构基类,或接受覆盖函数需要承担创建组的开销。- 抽象声明。 没有函数体可供检查。
相关内容:副作用有专门的技能
如果可组合函数需要 LaunchedEffect、DisposableEffect、SideEffect、rememberCoroutineScope、rememberUpdatedState、snapshotFlow、Snackbar/导航处理、分析或 Flow 收集,请使用 compose-side-effects。
按问题划分关注点:导航、焦点状态、FocusRequester 的所有权、行为 → compose-focus-navigation;何时调用命令式 requestFocus(副作用时机、生命周期、键、API 选择)→ compose-side-effects。
本技能关注如何正确编写 Compose 状态。rememberUpdatedState 用于捕获副作用状态,不能普遍替代 remember { mutableStateOf(...) }。副作用有独立的生命周期和键控规则,将其放在一个专注的技能中,可以避免出现两个事实来源。
快速参考
| 症状 | 诊断 | 修复 |
|---|---|---|
@Composable fun 函数体中的 var x = … | 不支持安全重组(§1) | var x by remember { mutableStateOf(…) } |
Column { … } / Row { … } 内容 lambda 中的 var x = … | 同上——内容 lambda 也是 @Composable(§1) | 同上 |
remember { mutableStateOf(list) } 后调用 .add(x),但没有重组 | 修改绕过了 State setter | 使用 mutableStateListOf,或替换值:state = state + x |
可组合函数体中执行 stateMap.clear(); stateMap.putAll(...) | 从组合状态写回组合状态 → 组合状态 | remember(keys) { derivedSnapshot } |
没有 Text/Box/remember/副作用调用的 @Composable fun | 可以标注为 @ReadOnlyComposable(§2) | 在 @Composable 上方添加 @ReadOnlyComposable |
调用了 Box {} / Column {} / 普通可组合函数的 @ReadOnlyComposable 函数 | 违反契约(§2) | 移除 @ReadOnlyComposable |
不适用的情况
- 测试中使用
composeTestRule.setContent { … }时遵循相同规则——这些也是生产环境中的可组合项。 produceState自带在协程中运行的生产器代码块;你不需要在其中使用LaunchedEffect。derivedStateOf涉及稳定性和相等性等自身问题——不在此处讨论范围内;它的作用是防止重组,而不是创建状态。- 只读可组合声明的
override:注解由基类固定;你不能在本地添加或移除该注解。
评审时的危险信号
| 想法 | 实际情况 |
|---|---|
“这是个小型可组合项,直接用 var 没问题” | 重组随时可能发生。状态重置本就是非确定性的——而后续只会多出一条 Bug 报告。 |
“函数看起来很简单,我就加上 @ReadOnlyComposable” | 标准不是“简单”,而是“只进行只读调用”。 |
“我总是首先想到 LaunchedEffect,因为我只熟悉它” | 使用 compose-side-effects;选择哪种 effect API 取决于生命周期和键。 |
“我只要对记住的列表调用 .add() 就行” | mutableStateOf(List) 不会观察内部变更——请使用 mutableStateListOf 或替换该值。 |
“这个 override 需要 @ReadOnlyComposable 才符合它的行为” | 如果基类没有 @ReadOnlyComposable,就不能在 override 上添加该注解。请改为重构基类。 |