Compose 1.0 推出 Modifier.composed,可讓您從修飾符存取組合元素。舉例來說,其中一個重要用途是建立有狀態的修飾符,用於儲存本機狀態,並在 Modifier.composed 工廠中與其他修飾符共用:
// ❌ BAD: Using Modifier.composed is no longer recommended fun Modifier.pressScale( pressedScale: Float = 0.95f, onClick: () -> Unit ): Modifier = composed( inspectorInfo = debugInspectorInfo { name = "pressScale" properties["pressedScale"] = pressedScale } ) { val interactionSource = remember { MutableInteractionSource() } val isPressed by interactionSource.collectIsPressedAsState() val scale by animateFloatAsState( targetValue = if (isPressed) pressedScale else 1f, animationSpec = spring(), label = "pressScale" ) this .graphicsLayer { scaleX = scale scaleY = scale } .clickable( interactionSource = interactionSource, indication = null, onClick = onClick ) }
請使用 Modifier.Node,而非 Modifier.composed,因為 Modifier.Node 可改善修飾符中的狀態管理方式。Modifier.Node 是長期存在的有狀態物件,每個套用至 LayoutNode 的 Modifier.Element 都會建立一個,而且它會在重新組合期間存留,而不是在每次傳遞時透過組合結構重新具體化。如要進一步瞭解我們設計 Modifier.Node 的原因和方式,請參閱「深入探索 Compose 修飾符」。
本文說明如何從 Modifier.composed 遷移至 Modifier.Node。如要進一步瞭解如何一般使用這項 API,請參閱「使用 Modifier.Node 實作自訂修飾符行為」。
Modifier.Node 的效能優勢
使用 Modifier.composed 會造成幾項基本效能瓶頸:
- 狀態管理負擔:在這個範圍內管理狀態需要
remember呼叫和快照狀態物件,這會導致不必要的組合群組膨脹 slot 資料表,並增加記憶體壓力。 - 生命週期存取成本高昂:存取修飾符的生命週期需要使用
DisposableEffect等效果,這會快速增加較簡單用途所需的工作。 - 無法略過:由於傳遞至
composed的 lambda 會傳回Modifier,Compose 編譯器無法將其標示為可略過,因此每當版面配置重新組合時,系統都會強制重新執行。 - 記憶和等式中斷:由於外部擴充功能函式本身不是
@Composable,編譯器無法記憶內部 lambda,因此每次呼叫都會分配新的 lambda。由於ComposedModifier會依參照比較 lambda,因此缺少記憶化會直接破壞修飾符等式 (equals)。因此,即使參數是靜態的,Compose 仍會在每個影格將修飾符視為已變更。 - 無法智慧傳播變更:如果沒有頂層可組合的參數追蹤功能,就無法比較新舊輸入內容,進而智慧傳播變更。
整體而言,Modifier.composed API 形狀會鼓勵編寫耗用資源的程式碼,並防止 Compose 執行階段套用其他修飾符最佳化。
核心遷移步驟
以下範例顯示使用 Modifier.composed 實作的典型自訂修飾符。詳情請參閱「使用 Modifier.Node 實作自訂修飾符行為」。
fun Modifier.underline( color: Color, thickness: Dp = 2.dp, animationDurationMillis: Int = 300 ): Modifier = composed { val density = LocalDensity.current val strokePx = with(density) { thickness.toPx() } // Drives how much of the underline is drawn: 0f -> 1f val progress = remember { Animatable(0f) } LaunchedEffect(color, thickness) { progress.snapTo(0f) progress.animateTo( targetValue = 1f, animationSpec = tween(durationMillis = animationDurationMillis) ) } drawBehind { val y = size.height - strokePx / 2 drawLine( color = color, start = Offset(0f, y), end = Offset(size.width * progress.value, y), strokeWidth = strokePx ) } }
建立自訂
Modifier.Node(或DelegatingNode):private class UnderlineNode( private var color: Color, private var thickness: Dp, private var animationDurationMillis: Int ) : Modifier.Node() { fun update(color: Color, thickness: Dp, durationMillis: Int) { } }
視自訂修飾符的需求 (例如需要存取指標輸入 API 時的
PointerInputModifierNode),導入一或多個Modifier.Node的輔助 API:private class UnderlineNode( private var color: Color, private var thickness: Dp, private var animationDurationMillis: Int ) : Modifier.Node(), DrawModifierNode { private val progress = Animatable(0f) private var animationJob: Job? = null override fun onAttach() { restartAnimation() } fun update(color: Color, thickness: Dp, durationMillis: Int) { val needsRestart = this.color != color || this.thickness != thickness this.color = color this.thickness = thickness this.animationDurationMillis = durationMillis if (needsRestart) restartAnimation() } private fun restartAnimation() { animationJob?.cancel() animationJob = coroutineScope.launch { progress.snapTo(0f) progress.animateTo(1f, tween(animationDurationMillis)) } } override fun ContentDrawScope.draw() { val strokePx = thickness.toPx() val y = size.height - strokePx / 2 drawLine( color = color, start = Offset(0f, y), end = Offset(size.width * progress.value, y), strokeWidth = strokePx ) drawContent() } }
建立
ModifierNodeElement,用於建立及更新自訂節點:private class UnderlineElement( private val color: Color, private val thickness: Dp, private val animationDurationMillis: Int ) : ModifierNodeElement<UnderlineNode>() { override fun create() = UnderlineNode(color, thickness, animationDurationMillis) override fun update(node: UnderlineNode) { node.update(color, thickness, animationDurationMillis) } override fun InspectorInfo.inspectableProperties() { name = "underline" properties["color"] = color properties["thickness"] = thickness properties["animationDurationMillis"] = animationDurationMillis } override fun hashCode(): Int { var result = color.hashCode() result = 31 * result + thickness.hashCode() result = 31 * result + animationDurationMillis.hashCode() return result } override fun equals(other: Any?): Boolean { if (this === other) return true val otherElement = other as? UnderlineElement ?: return false return color == otherElement.color && thickness == otherElement.thickness && animationDurationMillis == otherElement.animationDurationMillis } }
更新修飾符工廠,指向
ModifierNodeElement:fun Modifier.underline( color: Color, thickness: Dp = 2.dp, animationDurationMillis: Int = 300 ): Modifier = this then UnderlineElement(color, thickness, animationDurationMillis)
常見的遷移食譜
下列做法說明如何將常見模式從 Modifier.composed 遷移至 Modifier.Node 或 @Composable 修飾符工廠。
存取 CompositionLocal
模式:讀取單一 CompositionLocal,例如 LocalDensity、Theme 或 LocalView。
遷移路徑:使用 @Composable 標記修飾符。使用 composed 修飾符和 @Composable 修飾符工廠存取 CompositionLocal 時,兩者之間存在語意差異。使用 @Composable 工廠時,系統會在修飾符工廠的呼叫位置解析 CompositionLocal 值。如果這不是預期行為,請使用自訂 Modifier.Node 實作,透過 CompositionLocalConsumerModifierNode 讀取 CompositionLocal。
詳情請參閱「使用可組合修飾符工廠建立自訂修飾符」。
// ❌ BAD: Using Modifier.composed to read a single CompositionLocal fun Modifier.themedContainerBorder(): Modifier = composed { Modifier.border( BorderStroke( width = 2.dp, color = LocalColorScheme.current.primaryColor, ) ) .clipToBounds() }
// ✅ GOOD: If the modifier is @Composable, it should be able to access the locals. @Composable fun Modifier.themedContainerBorder() = this then Modifier.border( BorderStroke( width = 2.dp, color = MyTheme.mainColor, ) ) .clipToBounds()
模式:讀取可能套用至後續修飾符的 CompositionLocal。
遷移路徑:建立實作 CompositionLocalConsumerModifierNode 的自訂 Modifier.Node,並結合所有修飾符的功能。
// ❌ BAD: Using Modifier.composed to read a CompositionLocal then using it in another modifier. fun Modifier.adaptiveAccessibilityPadding(basePadding: Dp): Modifier = composed { // Reading LocalThemePadding.current.small (CompositionLocal) val extraPadding = LocalThemePadding.current.small Modifier.padding(basePadding + extraPadding) }
// ✅ GOOD: A custom Modifier that combines the capabilities of both (layout and composition local reader) modifiers. fun Modifier.adaptiveAccessibilityPadding(basePadding: Dp): Modifier = this.then(AdaptivePaddingElement(basePadding)) private data class AdaptivePaddingElement( val basePadding: Dp, ) : ModifierNodeElement<AdaptivePaddingNode>() { override fun create() = AdaptivePaddingNode(basePadding) override fun update(node: AdaptivePaddingNode) { node.basePadding = basePadding } override fun InspectorInfo.inspectableProperties() { name = "adaptiveAccessibilityPadding" properties["basePadding"] = basePadding } } private class AdaptivePaddingNode( var basePadding: Dp, ) : Modifier.Node(), LayoutModifierNode, CompositionLocalConsumerModifierNode { override fun MeasureScope.measure( measurable: Measurable, constraints: Constraints, ): MeasureResult { val extraPadding = currentValueOf(LocalThemePadding).small val total = (basePadding + extraPadding).roundToPx() val horizontal = total * 2 val vertical = total * 2 val placeable = measurable.measure(constraints.offset(-horizontal, -vertical)) val width = constraints.constrainWidth(placeable.width + horizontal) val height = constraints.constrainHeight(placeable.height + vertical) return layout(width, height) { placeable.place(total, total) } } }
存取非版面配置可組合函式
模式:修飾符需要存取以 @Composable 註解的函式,並傳回物件 (例如 colorResource 或 ScrollableDefaults.flingBehavior)。
遷移路徑:使用 @Composable 為修飾符加上註解。
// ❌ BAD: Using Modifier.composed to access a composable function such as colorResource fun Modifier.niceBackground() = composed { // Reading composable function colorResource val gradientColor1 = colorResource(R.color.my_special_color) background(color = gradientColor1, shape = CircleShape) }
// ✅ GOOD: A modifier can be annotation with @Composable to reference composable functions. @Composable // Modifier can be Composable itself. private fun Modifier.niceBackground(): Modifier { val gradientColor1 = colorResource(R.color.my_special_color) return this.background(color = gradientColor1, shape = CircleShape) }
存取協同程式範圍
模式: Modifier.composed 用於執行 rememberCoroutineScope,以存取 coroutineScope 物件來啟動協同程式。
遷移路徑:使用自訂 Modifier.Node,其中包含與修飾元生命週期相關的 coroutineScope 屬性 (例如 Modifier.composed 內的 rememberCoroutineScope):
// ❌ BAD: Using Modifier.composed to get access to a coroutine scope. fun Modifier.onClickAsyncComposed(onClick: suspend () -> Unit): Modifier = composed { val scope = rememberCoroutineScope() Modifier.pointerInput(onClick) { detectTapGestures { // Needs a coroutine scope to launch suspend lambda. scope.launch { onClick() } } } }
// ✅ GOOD: A custom Modifier.Node has a scoped (modifier lifecycle) coroutineScope that can be used to launch async work. fun Modifier.onClickAsync(onClick: suspend () -> Unit): Modifier = this.then(OnClickAsyncElement(onClick)) private data class OnClickAsyncElement(val onClick: suspend () -> Unit) : ModifierNodeElement<OnClickAsyncNode>() { override fun create(): OnClickAsyncNode = OnClickAsyncNode(onClick) override fun update(node: OnClickAsyncNode) { node.update(onClick) } override fun InspectorInfo.inspectableProperties() { name = "onClickAsync" properties["onClick"] = onClick } } private class OnClickAsyncNode(private var onClick: suspend () -> Unit) : DelegatingNode(), PointerInputModifierNode { private val pointerInputNode = delegate( SuspendingPointerInputModifierNode { detectTapGestures { // Modifier.Node provides `coroutineScope` directly. coroutineScope.launch { onClick() } } } ) fun update(onClick: suspend () -> Unit) { if (this.onClick != onClick) { this.onClick = onClick pointerInputNode.resetPointerInputHandler() } } override fun onPointerEvent( pointerEvent: PointerEvent, pass: PointerEventPass, bounds: IntSize, ) { pointerInputNode.onPointerEvent(pointerEvent, pass, bounds) } override fun onCancelPointerInput() { pointerInputNode.onCancelPointerInput() } }
記住狀態
模式:在 Modifier.composed 中使用 remember,在重組期間儲存狀態。
遷移路徑: Modifier.Node 的建構方式與保存狀態相同。狀態可以保留在執行個體中,就像任何其他類別屬性一樣,但生命週期更明確:
// ❌ BAD: Using Modifier.composed to make the modifier stateful. fun Modifier.tapCountHighlightComposed(colors: List<Color>): Modifier = composed { // 1. Must use `remember` so `tapCount` isn't reset to 0 on every recomposition var tapCount by remember { mutableIntStateOf(0) } Modifier .pointerInput(colors) { detectTapGestures { tapCount++ } } .drawBehind { drawRect(colors[tapCount % colors.size]) } }
// ✅ GOOD: Modifier.Node is the recommended way of creating stateful modifiers. fun Modifier.tapCountHighlight(colors: List<Color>): Modifier = this then TapCountHighlightElement(colors) private data class TapCountHighlightElement( val colors: List<Color>, ) : ModifierNodeElement<TapCountHighlightNode>() { override fun create() = TapCountHighlightNode(colors) override fun update(node: TapCountHighlightNode) { node.updateColors(colors) } override fun InspectorInfo.inspectableProperties() { name = "tapCountHighlight" properties["colors"] = colors } } private class TapCountHighlightNode( private var colors: List<Color>, ) : DelegatingNode(), DrawModifierNode { private var tapCount = 0 // Stateful modifier, this property will survive recompositions since Modifier.Nodes are held in the modifier tree. private val pointerInputNode = delegate( SuspendingPointerInputModifierNode { detectTapGestures { tapCount++ invalidateDraw() } } ) override fun ContentDrawScope.draw() { drawRect(colors[tapCount % colors.size]) drawContent() } fun updateColors(colors: List<Color>) { this.colors = colors invalidateDraw() } }
使用特效
模式:使用效果執行與組合生命週期相關的作業 (例如,當 Modifier.composed 輸入或退出組合時)。
遷移路徑: Modifier.Node 具有明確的生命週期回呼,可用於執行相同作業。舉例來說,一般來說,LaunchedEffect 可以替換為 Modifier.Node onAttach 方法中的 coroutineScope:
// ❌ BAD: Using Modifier.composed to launch/run an effect. fun Modifier.logImpressionComposed( targetId: String, onLog: suspend (targetId: String) -> Unit, ): Modifier = composed { // LaunchedEffect is tied to Composition lifecycle LaunchedEffect(targetId) { onLog(targetId) } this }
// ✅ GOOD: Modifier.Node has lifecycle callbacks (e.g onAttach, onDetach) that can be used to emulate effects behaviors. fun Modifier.logImpression(targetId: String, onLog: suspend (targetId: String) -> Unit): Modifier = this.then(LogImpressionElement(targetId, onLog)) private data class LogImpressionElement( val targetId: String, val onLog: suspend (targetId: String) -> Unit, ) : ModifierNodeElement<LogImpressionNode>() { override fun create(): LogImpressionNode = LogImpressionNode(targetId, onLog) override fun update(node: LogImpressionNode) { node.update(targetId, onLog) } override fun InspectorInfo.inspectableProperties() { name = "logImpression" properties["targetId"] = targetId } } private class LogImpressionNode( var targetId: String, var onLog: suspend (targetId: String) -> Unit, ) : Modifier.Node() { private var job: Job? = null override fun onAttach() { super.onAttach() runEffect() // Uses onAttach to track modifier lifecycle. } fun update(targetId: String, onLog: suspend (targetId: String) -> Unit) { // Re-run the effect if the key (`targetId`) changed if (this.targetId != targetId) { runEffect() } this.targetId = targetId this.onLog = onLog } private fun runEffect() { job?.cancel() job = coroutineScope.launch { onLog(targetId) } } }
保留動畫狀態
圖案:使用 animate*AsState。Modifier.composed
遷移路徑: animate*AsState 可細分為自訂修飾符,用於監控生命週期回呼並保留 Animatable 狀態:
// ❌ BAD: Using Modifier.composed to save an animation state. fun Modifier.fadeInOnHoverComposed(isHovered: Boolean): Modifier = composed { val alpha by animateFloatAsState( targetValue = if (isHovered) 1f else 0.4f, animationSpec = tween(durationMillis = 300), label = "alphaAnimation", ) Modifier.graphicsLayer { this.alpha = alpha } }
// ✅ GOOD: Animation state can be saved in Modifier.Node like other types of stateful implementations. fun Modifier.fadeInOnHover(isHovered: Boolean): Modifier = this.then(FadeInOnHoverElement(isHovered)) private data class FadeInOnHoverElement(val isHovered: Boolean) : ModifierNodeElement<FadeInOnHoverNode>() { override fun create(): FadeInOnHoverNode = FadeInOnHoverNode(isHovered) override fun update(node: FadeInOnHoverNode) { node.update(isHovered) } override fun InspectorInfo.inspectableProperties() { name = "fadeInOnHover" properties["isHovered"] = isHovered } } private class FadeInOnHoverNode(var isHovered: Boolean) : Modifier.Node(), LayoutModifierNode { // 1. Persistent Animatable field on the Node instance private val alphaAnimatable = Animatable(if (isHovered) 1f else 0.4f) override fun onAttach() { super.onAttach() startAnimation(isHovered) } // 2. Trigger animation imperatively when `isHovered` argument changes fun update(isHovered: Boolean) { if (this.isHovered != isHovered) { this.isHovered = isHovered if (isAttached) { startAnimation(isHovered) } } } private fun startAnimation(hovered: Boolean) { val targetAlpha = if (hovered) 1f else 0.4f // Use Node's built-in coroutineScope coroutineScope.launch { alphaAnimatable.animateTo( targetValue = targetAlpha, animationSpec = tween(durationMillis = 300), ) } } override fun MeasureScope.measure( measurable: Measurable, constraints: Constraints, ): MeasureResult { val placeable = measurable.measure(constraints) return layout(placeable.width, placeable.height) { // Read current animation value during layout placement layer placeable.placeWithLayer(0, 0) { alpha = alphaAnimatable.value } } } }