Переход с Modifier.composed на Modifier.Node

В 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 — это долгоживущий объект с состоянием, создаваемый один раз для каждого Modifier.Element , применяемого к LayoutNode , и он сохраняется после рекомпозиции, а не материализуется заново при каждом проходе композиции. Для получения дополнительной информации о том, почему и как мы разработали Modifier.Node , см. подробный анализ Compose Modifiers .

В этом документе описывается, как перейти от Modifier.composed к Modifier.Node . Для получения дополнительной информации об общем использовании этого API см. раздел «Реализация пользовательского поведения модификатора с помощью Modifier.Node» .

Преимущества Modifier.Node в плане производительности

Использование Modifier.composed приводит к возникновению нескольких фундаментальных проблем с производительностью:

  • Накладные расходы на управление состоянием: Управление состоянием в таком масштабе требует вызовов функции remember и создания снимков состояния, что приводит к раздуванию таблицы слотов ненужными группами композиции и увеличению нагрузки на память.
  • Дорогостоящий доступ к жизненному циклу: Для доступа к жизненному циклу модификатора требуется использование таких эффектов, как DisposableEffect , что быстро увеличивает трудозатраты даже в простых случаях.
  • Отсутствие возможности пропуска: поскольку лямбда-функция, передаваемая в composed , возвращает Modifier , компилятор Compose не может пометить её как допускающую пропуск, что заставляет её повторно выполняться при каждой перекомпозиции макета.
  • Нарушение мемоизации и сравнения на равенство: Поскольку внешняя функция расширения сама по себе не является аннотацией @Composable , компилятор не может мемоизировать внутреннюю лямбда-функцию, что приводит к выделению новых лямбда-функций при каждом вызове. Отсутствие мемоизации напрямую нарушает сравнение на равенство с помощью модификатора ` equals , поскольку ComposedModifier сравнивает лямбда-функции по ссылке. Следовательно, `Compose` рассматривает модификатор как измененный в каждом кадре, даже если параметры статические.
  • Отсутствие интеллектуального распространения изменений: без отслеживания параметров верхнего уровня, допускающих компонуемость, невозможно сравнивать новые входные данные с предыдущими для обеспечения интеллектуального распространения изменений.

В целом, структура API Modifier.composed поощряет написание ресурсоемкого кода и препятствует применению дополнительных оптимизаций модификаторов средой выполнения 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
        )
    }
}

  1. Создайте пользовательский 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) {
        }
    }

  2. Реализуйте один или несколько вспомогательных API Modifier.Node в зависимости от потребностей вашего пользовательского модификатора (например, PointerInputModifierNode если ему необходим доступ к 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()
        }
    }

  3. Создайте элемент 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
        }
    }

  4. Обновите фабрику модификаторов, указав в ней путь к 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 , которая считывает CompositionLocal с помощью CompositionLocalConsumerModifierNode .

Для получения дополнительной информации см. раздел «Создание пользовательского модификатора с помощью компонуемой фабрики модификаторов» .

// ❌ 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 , который может быть применен к последующему модификатору.

Путь миграции: Создайте пользовательский Modifier.Node , реализующий интерфейс CompositionLocalConsumerModifierNode и объединяющий возможности всех модификаторов.

// ❌ 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 , привязанное к жизненному циклу модификатора (например, rememberCoroutineScope внутри 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()
    }
}

Запомните штат

Шаблон: Использование remember в Modifier.composed для сохранения состояния при перекомпоновке.

Путь миграции: 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 обычно можно заменить использованием coroutineScope внутри метода 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) }
    }
}

Удерживайте состояние анимации

Шаблон: Modifier.composed using animate*AsState .

Путь миграции: 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 }
        }
    }
}