В 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 ) } }
Создайте пользовательский
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
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() } }
Создайте элемент
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 , которая считывает 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 } } } }