mcpField
C
compose-state-authoring

compose-state-authoring

github.com/chrisbanes开发1.1k994 次安装1 天前更新
代码仓库

关于 compose-state-authoring

介绍 Compose 局部界面状态管理和正确使用 `@ReadOnlyComposable` 的编码指南,帮助开发者避免状态重组时重置或无法触发重组,并说明何时可声明只读取组合状态。

主要功能

  1. 01用 `remember` 保留局部状态
  2. 02用快照状态触发更新
  3. 03选择可观察的状态集合
  4. 04避免在组合期间回写状态
  5. 05按契约使用 `@ReadOnlyComposable`

适用场景

  1. 01修复重组时重置的局部变量
  2. 02让集合变更可被观察
  3. 03根据输入生成只读快照
  4. 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 请参阅专门的技能:

核心原则

@Composable 是一个函数,每当其输入发生变化时,运行时都会重新执行它。正确编写局部状态归结为两个问题:

  1. 可变局部状态——我的 var 能否在重组后保留,并触发重组?如果不能,它会在每次重组时悄然重置,写入也不会被观察到。
  2. 这是什么类型的可组合项?——我是在修改组合(放置布局节点、分配槽位、使用 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; … } }。
  • 传递出可组合项的非 @Composable lambda 内。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 { … }——位置记忆化属于组合状态。
  • 调用 @Composable lambda(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 上添加该注解。请改为重构基类。

评论

支持 Markdown