mcpField
C
choosing-derivedstateof

choosing-derivedstateof

github.com/skydoves开发520131 次安装19 周前更新
代码仓库

关于 choosing-derivedstateof

面向 Claude 的技能,指导何时在 Compose 中使用 `derivedStateOf`,避免多余订阅和捕获过期值。还介绍用 `snapshotFlow` 处理一次性副作用,以及检查重组情况。

主要功能

  1. 01比较输入与输出更新频率
  2. 02为捕获值设置 `remember` 键
  3. 03用 `snapshotFlow` 处理副作用
  4. 04检查重组次数

适用场景

  1. 01判断滚动派生布尔值是否需要过滤
  2. 02修复组合后不再更新的派生值
  3. 03在派生值变化时触发一次性效果

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/skydoves/compose-performance-skills --skill choosing-derivedstateof

安装后在 Claude Code 中输入/choosing-derivedstateof确认已加载。

详细介绍

选择 derivedStateOf —— 将高频输入筛选为低频输出

derivedStateOf 会生成一个 State,只有在派生结果发生变化时,其读取者才会失效,即使输入状态变化得频繁得多也是如此。当输入变化频率高于输出变化频率时使用它。用于其他情况只会带来纯粹的额外开销——增加一次快照订阅,却没有筛选效果。本技能将教 Claude 何时使用它、何时不该使用,以及如何避免常见的“捕获初始值”陷阱。

何时使用本技能

  • 开发者询问某个值是否应该用 derivedStateOf 包装。
  • 滚动位置驱动一个布尔值(firstVisibleItemIndex == 0、scrollState.value > threshold、“滚动时显示 FAB”)。
  • 高频输入(拖动增量、动画进度)会生成低频输出(布尔阈值、分桶整数)。
  • 代码中已有 derivedStateOf,但重组次数没有减少,或者该值在首次组合后就不再更新(“捕获初始值”错误)。
  • 开发者希望在派生值切换时触发一次性副作用(分析、日志记录、Snackbar)。

何时不要使用本技能

  • 状态读取发生在错误的阶段(例如 Modifier.alpha(state.value))。先用 ../deferring-state-reads/SKILL.md 修正此问题——derivedStateOf 无法解决阶段问题。
  • 诊断参数是否不稳定。这属于 ../../stability/diagnosing-compose-stability/SKILL.md。
  • 输入频率等于输出频率(例如由两个姓名状态生成 "$first $last")。这种情况下,derivedStateOf 只会带来纯粹的额外开销——直接读取或使用普通的 remember。

前置条件

  • 熟悉由状态读取驱动的重组:可重启可组合函数中的快照读取会将该可组合函数的重启作用域订阅到该状态。
  • Compose runtime ≥ 1.0(该 API 是基础功能;此处描述的行为稳定)。
  • 能够通过 Layout Inspector 或 skydoves/compose-stability-analyzer 中的 @TraceRecomposition 确认重组次数。

工作流

  • 1. 确定输入状态和 UI 实际使用的值。 记下两者的更新频率。例如:listState.firstVisibleItemIndex(每滚过一个列表项都会变化)→ Boolean(用户越过索引 0 时变化一次)。

  • 2. 比较频率。 应用以下规则:

    • 输入更新频繁得多,而输出更新较少 → derivedStateOf 是合适的工具。
    • 输入和输出的更新频率大致相同 → derivedStateOf 只会平白增加一次快照订阅。直接读取,或使用普通的 remember(input1, input2)。
  • 3. 将派生逻辑包装在 remember { derivedStateOf { … } } 中。 直接使用 derivedStateOf { } 会导致它在每次组合时都重新创建,从而使缓存失效。remember 是必需的。

  • 4. 如果 lambda 捕获了非状态变量(函数参数、局部变量、属性),请将它们作为 remember 键传入。 捕获的非状态变量只会在首次组合时读取一次,之后便会一直固定不变——派生逻辑会悄无声息地使用过期值。修正方法是 remember(threshold) { derivedStateOf { … > threshold } }。

  • 5. 对派生值的一次性副作用(日志记录、分析、Snackbar),优先在 LaunchedEffect 中使用 snapshotFlow { … }.collect { … }。 这样可以避免将可组合函数的重启作用域订阅到派生状态;副作用会在协程中运行,不会经过组合路径。

  • 6. 在 Layout Inspector / @TraceRecomposition 中验证。 消费该值的可组合函数的重组次数 MUST 只在派生结果变化时增加,而不是每次输入变化时都增加。如果它仍然随每次输入变化而增加,则可能是 (a) 缺少 derivedStateOf,(b) 缺少 remember,或 (c) 其他因素(同级状态读取、阶段错误的修饰符)使同一作用域失效。

模式

模式:滚动到顶部的 FAB — 输入 >> 输出
// WRONG
val showFab = listState.firstVisibleItemIndex > 0
// WRONG because: this reads firstVisibleItemIndex on every recomposition; the consuming composable invalidates per scrolled item, not just when the boolean flips.
// RIGHT
val showFab by remember {
    derivedStateOf { listState.firstVisibleItemIndex > 0 }
}

firstVisibleItemIndex 会在滚过每个列表项时更新;只有用户越过索引 0 时,showFab 才会切换。remember 会让派生状态在多次组合之间保持存活;derivedStateOf 会过滤掉所有不会让布尔值切换的输入变化。

模式:纯额外开销 — 输入 ≈ 输出
// WRONG
val first by remember { mutableStateOf("Ada") }
val last by remember { mutableStateOf("Lovelace") }
val fullName by remember { derivedStateOf { "$first $last" } }
// WRONG because: first and last change at the same rate as fullName; derivedStateOf adds a snapshot subscription with zero filtering benefit.
// RIGHT — direct read, no derivedStateOf needed
val first by remember { mutableStateOf("Ada") }
val last by remember { mutableStateOf("Lovelace") }
val fullName = "$first $last"

如果担心记忆化的开销(字符串拼接很耗时),请使用 remember(first, last) { computeFullName(first, last) }——此处不适合使用 derivedStateOf,因为没有需要过滤的内容。

模式:捕获非状态变量 — 隐蔽的 Bug
// WRONG
@Composable
fun Header(threshold: Int, listState: LazyListState) {
    val isLarge by remember {
        derivedStateOf { listState.firstVisibleItemIndex > threshold }
    }
    // WRONG because: threshold is captured inside remember { ... } at first composition; later threshold changes are ignored, and the derivation reuses the stale value forever.
}
// RIGHT — threshold is a remember key, so a new derivedStateOf is created when it changes
@Composable
fun Header(threshold: Int, listState: LazyListState) {
    val isLarge by remember(threshold) {
        derivedStateOf { listState.firstVisibleItemIndex > threshold }
    }
}

规则:由 derivedStateOf lambda 捕获的任何非 State 值,都 MUST 作为外层 remember 的键。否则,派生计算会锁定首次组合时的值。

模式:一次性副作用 → snapshotFlow
// WRONG
@Composable
fun Feed(listState: LazyListState, onScrolledPastFold: () -> Unit) {
    val pastFold by remember {
        derivedStateOf { listState.firstVisibleItemIndex > 5 }
    }
    if (pastFold) onScrolledPastFold()
    // WRONG because: side effects in a composable body run on every (re)composition, can fire multiple times for the same flip, and tie the side effect to the composition lifecycle.
}
// RIGHT — fire-and-forget side effect via snapshotFlow
@Composable
fun Feed(listState: LazyListState, onScrolledPastFold: () -> Unit) {
    LaunchedEffect(listState) {
        snapshotFlow { listState.firstVisibleItemIndex > 5 }
            .distinctUntilChanged()
            .filter { it }
            .collect { onScrolledPastFold() }
    }
}

snapshotFlow 会在协程中读取快照状态,并在读取集合的组合值发生变化时发出值。LaunchedEffect 会将收集操作绑定到 listState,而 distinctUntilChanged() 可确保每次切换只发出一次值。这里不会涉及重启作用域——消费此信号的可组合项绝不会因此重新组合。

模式:将多个输入派生到一个区间
// RIGHT
val priceBucket by remember {
    derivedStateOf {
        when {
            cartTotal.value < 10_000 -> Bucket.Small
            cartTotal.value < 50_000 -> Bucket.Medium
            else -> Bucket.Large
        }
    }
}

cartTotal 可能以韩元或分为单位逐一递增;priceBucket 在整个范围内最多只切换两次。这是最典型的适用场景。

强制规则

  • MUST 将每个 derivedStateOf 都包在 remember { … } 中。裸写 derivedStateOf { } 会在每次组合时重新创建,无法提供任何过滤。
  • MUST 将捕获的非状态变量(函数参数、局部 val、属性)添加为 remember 的键。否则,派生计算会冻结首次组合时的值。
  • MUST NOT 在输入和输出频率一致时使用 derivedStateOf。这只会带来纯额外开销;请直接读取,或使用 remember(keys) { … }。
  • MUST NOT 为了“保险起见”将 collectAsState() / collectAsStateWithLifecycle() 的结果包在 derivedStateOf 中——这只会徒增一层订阅。请在上游的 flow 中使用 .distinctUntilChanged() 或 .map { } 进行过滤。
  • PREFERRED: 对于即发即弃的副作用,优先使用 snapshotFlow { … },而非 derivedStateOf。这样信号完全不会进入组合。
  • PREFERRED: 在更改前后都测量重新组合次数。derivedStateOf 的目的就是减少重新组合次数;如果次数没有下降,说明其他地方出了问题(通常是读取阶段不对——请参阅 ../deferring-state-reads/SKILL.md)。

验证

  • Layout Inspector 显示消费该派生结果的可组合项仅在派生结果变化时重新组合(例如,FAB 显示时一次,消失时一次),而不是每次输入变化时都重新组合。
  • 文件中的每个 derivedStateOf 都位于 remember { … }(或 remember(keys) { … })调用中。
  • 每个 derivedStateOf lambda 捕获的非状态变量都出现在 remember 的键中,或者该 lambda 只读取 State 对象。
  • 派生值上的副作用通过 LaunchedEffect { snapshotFlow { … }.collect { } } 执行,而不是在可组合项主体中读取派生状态。
  • 搜索 derivedStateOf { 后,不会发现任何输入和输出频率一致的情况(没有形如 "$first $last" 的用法)。

参考资料

评论

支持 Markdown