Migracja z Modifier.composed do Modifier.Node

Modifier.composed wprowadzono w Compose 1.0, aby umożliwić dostęp do elementów kompozycji z modyfikatorów. Jednym z głównych przypadków użycia jest tworzenie modyfikatora stanu, który zapamiętuje stan lokalny i udostępnia go innym modyfikatorom w fabryce 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
        )
}

Używaj Modifier.Node zamiast Modifier.composed, ponieważ Modifier.Node poprawia sposób zarządzania stanem w modyfikatorach. Modifier.Node to długotrwały obiekt stanowy, który jest tworzony raz na Modifier.Element zastosowany do LayoutNode i przetrwa rekompozycję, zamiast być ponownie tworzony podczas każdego przejścia. Więcej informacji o tym, dlaczego i jak zaprojektowaliśmy Modifier.Node, znajdziesz w artykule Szczegółowe informacje o modyfikatorach Compose.

Z tego dokumentu dowiesz się, jak przeprowadzić migrację z Modifier.composed naModifier.Node. Więcej informacji o ogólnym korzystaniu z tego interfejsu API znajdziesz w artykule Implementowanie niestandardowego działania modyfikatora za pomocą Modifier.Node.

Korzyści związane z wydajnością Modifier.Node

Użycie Modifier.composed powoduje kilka podstawowych wąskich gardeł wydajności:

  • Obciążenie związane z zarządzaniem stanem: zarządzanie stanem w tym zakresie wymaga wywołań remember i obiektów stanu migawki, co powoduje powiększenie tabeli slotów o niepotrzebne grupy kompozycji i zwiększa obciążenie pamięci.
  • Dostęp do cyklu życia jest kosztowny: dostęp do cyklu życia modyfikatora wymaga użycia efektów takich jak DisposableEffect, co szybko zwiększa nakład pracy w przypadku prostszych zastosowań.
  • Brak możliwości pominięcia: ponieważ lambda przekazana do funkcji composed zwraca wartość Modifier, kompilator Compose nie może oznaczyć jej jako możliwej do pominięcia, co wymusza ponowne wykonanie za każdym razem, gdy układ jest ponownie komponowany.
  • Nieprawidłowe zapamiętywanie i równość: ponieważ zewnętrzna funkcja rozszerzająca nie jest @Composable, kompilator nie może zapamiętać wewnętrznej lambdy, co powoduje przydzielanie nowych lambd przy każdym wywołaniu. Brak zapamiętywania bezpośrednio narusza równość modyfikatorów (equals), ponieważ ComposedModifier porównuje lambdy przez odwołanie. W związku z tym Compose traktuje modyfikator jako zmieniony w każdej klatce, nawet jeśli parametry są statyczne.
  • Brak inteligentnego propagowania zmian: bez śledzenia parametrów kompozycyjnych najwyższego poziomu nie ma możliwości porównania nowych danych wejściowych z poprzednimi w celu inteligentnego propagowania zmian.

Ogólnie rzecz biorąc, kształt interfejsu API Modifier.composed zachęca do pisania kodu o wysokich kosztach obliczeniowych i uniemożliwia środowisku wykonawczemu Compose stosowanie dodatkowych optymalizacji modyfikatorów.

Główne etapy migracji

Poniższy przykład pokazuje typowy modyfikator niestandardowy zaimplementowany za pomocą parametru Modifier.composed. Więcej informacji znajdziesz w artykule Implementowanie niestandardowego działania modyfikatora za pomocą 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
        )
    }
}

  1. Utwórz niestandardowy Modifier.Node (lub 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) {
        }
    }

  2. Zaimplementuj co najmniej 1 interfejs API pomocniczy z Modifier.Node, w zależności od potrzeb niestandardowego modyfikatora (np. PointerInputModifierNode, jeśli potrzebuje on dostępu do interfejsów API wejścia wskaźnika):

    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()
        }
    }

  3. Utwórz ModifierNodeElement, który tworzy i aktualizuje węzeł niestandardowy:

    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
        }
    }

  4. Zaktualizuj fabrykę modyfikatorów, aby wskazywała na ModifierNodeElement:

    fun Modifier.underline(
        color: Color,
        thickness: Dp = 2.dp,
        animationDurationMillis: Int = 300
    ): Modifier = this then UnderlineElement(color, thickness, animationDurationMillis)

Typowe przepisy migracji

Poniższe przepisy pokazują, jak migrować typowe wzorce z Modifier.composed do fabryk modyfikatorów Modifier.Node lub @Composable.

Uzyskiwanie dostępu do elementu CompositionLocal

Wzorzec: odczytanie pojedynczego znaku CompositionLocal, np. LocalDensity, Theme lub LocalView.

Ścieżka migracji: oznacz modyfikator symbolem @Composable. Istnieje różnica semantyczna między użyciem modyfikatora composed a modyfikatora @Composable do uzyskania dostępu do CompositionLocal. W przypadku modyfikatora @Composable wartości CompositionLocal są rozwiązywane w miejscu wywołania modyfikatora. Jeśli nie jest to oczekiwane działanie, użyj niestandardowej implementacji Modifier.Node, która odczytuje CompositionLocal za pomocą CompositionLocalConsumerModifierNode.

Więcej informacji znajdziesz w artykule Tworzenie niestandardowego modyfikatora za pomocą fabryki modyfikatorów kompozycyjnych.

// ❌ 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()

Wzorzec: odczytywanie CompositionLocal, które może być zastosowane do kolejnego modyfikatora.

Ścieżka migracji: utwórz niestandardowy Modifier.Node, który implementuje CompositionLocalConsumerModifierNode i łączy wszystkie możliwości modyfikatorów.

// ❌ 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)
        }
    }
}

Dostęp do funkcji typu „composable”, która nie jest elementem układu

Wzorzec: modyfikator musi mieć dostęp do funkcji, która jest oznaczona adnotacją @Composable i zwraca obiekt (np. colorResource lub ScrollableDefaults.flingBehavior).

Ścieżka migracji: dodaj do modyfikatora adnotację @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)
}

Dostęp do zakresu korutyny

Wzorzec: Modifier.composed służy do wykonywania rememberCoroutineScope w celu uzyskania dostępu do obiektu coroutineScope na potrzeby uruchamiania korutyn.

Ścieżka migracji: użyj niestandardowego Modifier.Node, który ma właściwość coroutineScope powiązaną z cyklem życia modyfikatora (np. rememberCoroutineScope w Modifier.composed):

// ❌ 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()
    }
}

Zapamiętywanie stanu

Wzorzec: używanie remember w Modifier.composed do zapisywania stanu między ponownymi kompozycjami.

Ścieżka migracji: Modifier.Node został zaprojektowany tak, aby przechowywać stan w ten sam sposób. Stan może być przechowywany w instancji, tak jak każda inna właściwość klasy, z bardziej przejrzystym cyklem życia:

// ❌ 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()
    }
}

Używanie efektu

Wzorzec: używanie efektów do wykonywania operacji powiązanych z cyklem życia kompozycji (np. gdy Modifier.composed wchodzi do kompozycji lub z niej wychodzi).

Ścieżka migracji: Modifier.Node ma jasne wywołania zwrotne cyklu życia, których można używać do wykonywania tych samych operacji. Na przykład LaunchedEffect można zwykle zastąpić użyciem coroutineScope w metodzie Modifier.Node onAttach:

// ❌ 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) }
    }
}

Wstrzymywanie stanu animacji

Wzorzec: Modifier.composed za pomocą animate*AsState.

Ścieżka migracji: animate*AsState można ją podzielić na niestandardowy modyfikator, który monitoruje wywołania zwrotne cyklu życia i przechowuje Animatable stan:

// ❌ 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 }
        }
    }
}