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 na przykład utworzenie modyfikatora stanowego, 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
        )
}

Zamiast Modifier.composed używaj Modifier.Node, ponieważ Modifier.Node ulepsza sposób zarządzania stanem w modyfikatorach. Modifier.Node to trwały obiekt stanowy, który jest tworzony raz na Modifier.Element zastosowany do LayoutNode. Przetrwa on rekompozycję, zamiast być ponownie materializowany przez kompozycję przy każdym przejściu. Więcej informacji o tym, dlaczego i jak zaprojektowaliśmy Modifier.Node, znajdziesz w artykule Szczegółowe omówienie modyfikatorów Compose.

Ten dokument opisuje, jak przeprowadzić migrację z Modifier.composed do Modifier.Node. Więcej informacji o ogólnym korzystaniu z tego interfejsu API znajdziesz w artykule Implementowanie niestandardowego zachowania modyfikatora za pomocą Modifier.Node.

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

Korzystanie z Modifier.composed powoduje kilka podstawowych problemów z wydajnością:

  • Narzut związany z zarządzaniem stanem: zarządzanie stanem w tym zakresie wymaga wywołań remember i obiektów stanu migawki, co powoduje przepełnienie tabeli slotów niepotrzebnymi grupami kompozycji i zwiększa obciążenie pamięci.
  • Kosztowny dostęp do cyklu życia: dostęp do cyklu życia modyfikatora wymaga użycia efektów takich jak DisposableEffect, co szybko zwiększa nakład pracy wymagany w przypadku prostszych zastosowań.
  • Brak możliwości pominięcia: ponieważ lambda przekazana do composed zwraca Modifier, kompilator Compose nie może oznaczyć jej jako możliwej do pominięcia, co wymusza jej ponowne wykonanie przy każdej rekompozycji układu.
  • Zepsuta memoizacja i równość: ponieważ zewnętrzna funkcja rozszerzenia sama nie jest funkcją @Composable, kompilator nie może zapamiętać wewnętrznej funkcji lambda, co powoduje tworzenie nowych funkcji lambda przy każdym wywołaniu. Brak zapamiętywania bezpośrednio narusza równość modyfikatorów (equals), ponieważ ComposedModifier porównuje lambdy według odwołania. W rezultacie Compose traktuje modyfikator jako zmieniony w każdej klatce, nawet jeśli parametry są statyczne.
  • Brak inteligentnego rozpowszechniania zmian: bez śledzenia parametrów funkcji kompozycyjnej najwyższego poziomu nie ma możliwości porównania nowych danych wejściowych z poprzednimi w celu inteligentnego rozpowszechniania zmian.

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

Podstawowe kroki migracji

Poniższy przykład pokazuje typowy modyfikator niestandardowy zaimplementowany za pomocą Modifier.composed. Więcej informacji znajdziesz w artykule Implementowanie niestandardowego zachowania 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 pomocniczy interfejs API Modifier.Node w zależności od tego, czego potrzebuje Twój niestandardowy modyfikator (np. PointerInputModifierNode, jeśli potrzebuje dostępu do interfejsów API danych wejściowych ze 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 plik 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 dotyczące migracji

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

Uzyskiwanie dostępu do CompositionLocal

Wzorzec: odczyt pojedynczego elementu CompositionLocal, takiego jak LocalDensity, Theme lub LocalView.

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

Więcej informacji znajdziesz w artykule Tworzenie modyfikatora niestandardowego za pomocą fabryki modyfikatorów funkcji 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 elementu CompositionLocal, który może zostać zastosowany do kolejnego modyfikatora.

Ścieżka migracji: utwórz niestandardowy element Modifier.Node, który implementuje interfejs 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” niebędącej układem

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

Uzyskiwanie dostępu do zakresu współprogramu

Wzorzec: Modifier.composed służy do wykonywania rememberCoroutineScope w celu uzyskania dostępu do obiektu coroutineScope i uruchamiania współprogramów.

Ścieżka migracji: użyj niestandardowego elementu Modifier.Node, który ma właściwość coroutineScope powiązaną z cyklem życia modyfikatora (np. rememberCoroutineScope wewnątrz 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 rekompozycjami.

Ścieżka migracji: usługa Modifier.Node została utworzona w taki sposób, 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 ją opuszcza).

Ś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 element LaunchedEffect można zwykle zastąpić elementem 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) }
    }
}

Utrzymywanie stanu animacji

Wzorzec: Modifier.composed przy użyciu animate*AsState.

Ścieżka migracji: animate*AsState można podzielić na niestandardowy modyfikator, który monitoruje wywołania zwrotne cyklu życia i przechowuje stan 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 }
        }
    }
}