向后写入

Compose 会按严格的顺序执行帧,分为三个阶段:

三个正向流动阶段:1. 构成、2. 布局,3. 绘图
图 1. Compose 帧的三个阶段
  1. 组合:运行 @Composable 函数以构建和更新界面树。
  2. 布局:测量子项,然后放置子项。
  3. 绘制:发出画布绘制命令,以将像素渲染到屏幕上。

在任何阶段,只要 Compose State读取,Compose 就会自动记录该状态与相应阶段之间的依赖关系


什么情况下写入是“向后”的?

每当在后续阶段(或在下游作用域中,即在同一组合传递中按顺序稍后执行的可组合项作用域)修改状态时,就会发生向后写入,从而强制 Compose 通过安排较早的阶段或可组合项再次运行来进行重组。向后写入是指未优化的重组循环。

通过重组循环阶段进行反向写入的示例
图 2. 通过重组循环阶段进行向后写入的示例

向后写入的后果

向后写入不一定是一件坏事,也不一定会触发崩溃,但效率低下,可能会以多种方式损害应用性能:

  • 额外的帧渲染和丢帧:向后写入会强制 Compose 在连续帧中执行冗余的合成传递,从而浪费 CPU 和 GPU 资源,并可能导致卡顿。
  • 首帧正确性问题:如果您的组件需要向后写入才能确定其最终尺寸或状态,则首帧会使用无效、默认或未确定的数据(例如大小为零或偏移量不正确)进行渲染。这会导致在渲染第二个帧时出现明显的视觉跳动或布局闪烁。
  • 无限重组循环:如果状态变化会改变布局大小,而布局大小会不断将新值写回状态,您可能会面临创建无限帧循环的风险,即屏幕会不断在每个帧中重组,而永远不会稳定下来。

通过阶段向前流动

状态变化应始终在各个阶段向前流动:

阅读阶段 写入上下文 是否可接受? 原因
布局 (Modifier.offset { }) 构图 组合更新状态 → 布局在同一帧中稍后读取该状态,而无需重组。
抽奖graphicsLayer { }drawBehind { } 构图 组合更新状态 → 绘制在最终阶段读取状态。完全跳过组合和布局。
绘制 布局 布局更新状态,绘制读取状态,有效流程。
构图 驱动状态更改的事件回调(onClickonValueChange)。注意:布局回调不计为事件。 事件会改变用于驱动组合的状态。如果事件发生在帧外(不在 Composition、Layout 或 Draw 中),则这是有效的。
构图 协程 (LaunchedEffect) 可以 - 但需谨慎 异步更新状态以响应生命周期/事件。来自效果的写入可能有效,但可能表明状态分层效率低下。请尽可能避免这种情况。
展示位置(在布局中) 衡量(在布局中) 在布局中,先更新状态,然后在放置位置读取该状态是可以接受的。
衡量(在布局中) 展示位置(在布局中) 否 - 向后写入 在放置中写入状态,然后进一步读取状态,会导致重新测量循环。
构图 布局onSizeChangedLayoutModifier - 向后写入 布局使组合失效 → 重组循环。
构图 抽奖drawWithContentCanvas 否 - 向后写入 绘制使组合失效 → 重组循环。

阶段组合:向后与向前

以下示例展示了 Compose 中的向后写入以及如何解决这些问题。

向后:在组合中读取,在布局中写入

  • 发生的情况:组合读取 componentHeight 以确定要发出什么界面。在帧的后续阶段,布局阶段会测量或放置视图,并向 componentHeight 写入新值(例如,使用 onSizeChangedonGloballyPositioned 或自定义 LayoutModifier)。
  • 结果:在布局中修改 componentHeight 会使刚刚完成的合成阶段失效。请注意,onSizeChanged 会在布局测量传递完成后报告大小。如果更新后的状态值在下一次传递时趋于稳定,则重组可能会在一个额外的帧后停止;不过,如果新值继续改变大小,则会导致无限帧循环。此外,onGloballyPositioned 在布局和放置之后运行,因此其中的状态写入更容易受到连续重组和跨连续帧的重新布局循环的影响。

// ❌ BAD: Read in Composition, Written in Layout (onSizeChanged)
@Composable
fun BadAspectRatioImage(painter: Painter) {
    var calculatedHeight by remember { mutableStateOf(0.dp) }
    val density = LocalDensity.current

    // State read during COMPOSITION:
    Image(
        painter = painter,
        contentDescription = "Dynamic Image",
        modifier = Modifier
            .fillMaxWidth()
            .height(calculatedHeight)
            .onSizeChanged { size ->
                // State write during LAYOUT phase!
                // Triggers backwards write and recomposition pass
                val aspectRatio = 16f / 9f
                val widthDp = with(density) { size.width.toDp() }
                calculatedHeight = widthDp / aspectRatio
            }
    )
}

// ✅ GOOD: Measure and calculate aspect ratio height in Phase 2 (Layout) without recomposition
@Composable
fun GoodAspectRatioImage(
    painter: Painter,
    aspectRatio: Float = 16f / 9f,
    modifier: Modifier = Modifier
) {
    Layout(
        content = {
            Image(
                painter = painter,
                contentDescription = "Dynamic Image"
            )
        },
        modifier = modifier
    ) { measurables, constraints ->
        val width = constraints.maxWidth
        val height = (width / aspectRatio).toInt() // Illustrative, you can use Modifier.aspectRatio()
        val imageConstraints = constraints.copy(
            minWidth = width,
            maxWidth = width,
            minHeight = height,
            maxHeight = height
        )
        val placeable = measurables.first().measure(imageConstraints)
        layout(width, height) {
            placeable.placeRelative(0, 0)
        }
    }
}

向后:在 Composition 中读取,在 Drawing 中写入

  • 发生的情况:状态在可组合函数主体(组合阶段)中读取,但在 Modifier.drawWithContentModifier.drawBehindCanvas(绘制阶段)内发生变化。
  • 结果:绘制阶段会改变状态 → 组合失效 → 无限循环。

// ❌ BAD: Read in Composition, Written in Draw ()
@Composable
fun BadBackwardsWriteDraw() {
    var componentHeight by remember { mutableStateOf(0.dp) }
    // State read during COMPOSITION:
    Text(
        text = "Height is: $componentHeight",
        modifier = Modifier.drawBehind {
            // State write during the DRAW phase!
            // Invalidates Composition -> triggers recomposition loop!
            componentHeight = size.height.dp
        }
    )
}

向后:在组合阶段读取,在组合阶段写入(同一阶段)

  • 发生的情况:在可组合函数中读取 count,然后在读取后直接在另一个可组合项的内容 slot 中修改 count
  • 结果:快照系统会在同一组合传递中记录读取和后续写入,并立即使当前范围失效。

// ❌ BAD: Direct write in Composable body after read
@Composable
fun BadCounter() {
    var count by remember { mutableIntStateOf(0) }
    Text("Count: $count") // State read in Composition
    Button(onClick = {}) {
        count++ // State write in Composition (Backwards write!)
    }
}
// Acceptable - but error-prone as someone may add a read before the write : Direct write in Composable body before read
@Composable
fun OkCounter() {
    var count by remember { mutableIntStateOf(0) }
    Button(onClick = {}) {
        count++ // State  write in Composition
    }
    Text("Count: $count") // State read in Composition
}


防止向后写入的关键规则

  1. 如果状态在 Composition 中读取,请勿在 onGloballyPositionedonSizeChangedLayoutModifier 中写入状态,因为这会导致第一帧正确性问题。
    • 如果仅需将布局坐标或大小用于自定义绘制,请在绘制或布局阶段直接读取它们(例如,使用 Modifier.drawWithCacheModifier.layout)。
    • 对于窗口级调整大小 (WindowWidthSizeClass):将大小观测提升到窗口级别。在进行本地测量之前,组合会根据窗口大小类进行分支。
    • 保持 Composition 的统一性:使用单个自定义布局或 FlowRowLazyVerticalGrid 等组件,在第 2 阶段调整测量和放置,而无需重新组合或更改 Composition 中使用的状态。
    • 使用子组合:当子可组合项必须根据本地宽度或高度进行分支时,使用 BoxWithConstraintsSubcomposeLayout。请谨慎使用:子合成会带来性能开销,通常可以避免。
    • 作为最后的手段:允许第一帧出错,将大小存储在 onSizeChanged 中以触发第二帧重新合成。这会导致布局出现明显的跳动、卡顿,并可能出现无限循环。
  2. 在组合中首次读取状态后,请勿更改该状态
    • 虽然您可以在 SideEffect 之外的组合期间安全地写入 MutableState 对象,但请特别注意,确保您不会写入之前可能在组合中读取的状态。建议在组合中需要写入状态时使用 rememberUpdatedState。在合成期间以其他方式写入状态通常表示缺少效应或状态/可组合项设计不当。请注意,组合是乐观的,始终使用状态的最新值执行,因此您可能无法在重组中看到所有状态更改。界面更新不应作为处理一次性事件的方式,因此状态值通常不需要因重组而更新。
    • 避免更改在组合之外观察到的状态(例如 ViewModel 字段或 isVisible 标志)。影响组合的状态写入应位于事件 lambda (onClick)、协程 (LaunchedEffect) 或附带效应 (SideEffect) 中。rememberUpdatedState 是一个例外,因为它旨在改变仅在 @Composable 正文中使用的状态。
  3. 将状态读取延迟到尽可能晚的阶段
    • 在 Draw (Modifier.graphicsLayer { alpha = ... }) 或 Layout (Modifier.offset { IntOffset(...) }) 中读取状态可确保更改仅使第 2 阶段或第 3 阶段失效,从而完全跳过第 1 阶段(组合)。