Данные с локальной областью видимости с помощью CompositionLocal

CompositionLocal – это инструмент для неявной передачи данных через композицию. На этой странице вы узнаете, что такое CompositionLocal, как создать собственную CompositionLocal и подходит ли она CompositionLocal для ваших целей.

Введение в CompositionLocal

Обычно в Compose данные передаются вниз по дереву интерфейса в виде параметров каждой composable-функции. Это делает зависимости композиции явными. Однако это может быть неудобно для данных, которые используются очень часто и широко, например для цветов или стилей шрифтов. Пример:

@Composable
fun MyApp() {
    // Theme information tends to be defined near the root of the application
    val colors = colors()
}

// Some composable deep in the hierarchy
@Composable
fun SomeTextLabel(labelText: String) {
    Text(
        text = labelText,
        color = colors.onPrimary // ← need to access colors here
    )
}

Чтобы не передавать цвета в качестве явного параметра зависимости большинству композиций, Compose предлагает CompositionLocal, который позволяет создавать именованные объекты с областью действия дерева, которые можно использовать как неявный способ передачи данных через дерево интерфейса.

Элементы CompositionLocal обычно имеют значение в определенном узле дерева интерфейса. Это значение может использоваться его composable-потомками без объявления CompositionLocal в качестве параметра в composable-функции.

Важные термины. В этом руководстве используются термины композиция, дерево интерфейса и иерархия интерфейса. Хотя в других руководствах эти термины могут использоваться как взаимозаменяемые, они имеют разные значения:

  • Композиция – это запись графа вызовов композитных функций.
  • Дерево интерфейса или иерархия интерфейса – это дерево LayoutNode, которое создается, обновляется и поддерживается процессом композиции.

CompositionLocal – это то, что используется в теме Material. MaterialTheme – это объект, который предоставляет три экземпляра CompositionLocal: colorScheme, typography и shapes. Вы можете получить их позже в любой дочерней части композиции. Это свойства LocalColorScheme, LocalShapes и LocalTypography, доступ к которым можно получить через атрибуты MaterialTheme, colorScheme, shapes и typography.

@Composable
fun MyApp() {
    // Provides a Theme whose values are propagated down its `content`
    MaterialTheme {
        // New values for colorScheme, typography, and shapes are available
        // in MaterialTheme's content lambda.

        // ... content here ...
    }
}

// Some composable deep in the hierarchy of MaterialTheme
@Composable
fun SomeTextLabel(labelText: String) {
    Text(
        text = labelText,
        // `primary` is obtained from MaterialTheme's
        // LocalColors CompositionLocal
        color = MaterialTheme.colorScheme.primary
    )
}

Область действия экземпляра CompositionLocal ограничена частью композиции, поэтому вы можете задавать разные значения на разных уровнях дерева. Значение current объекта CompositionLocal соответствует ближайшему значению, предоставленному родительским объектом в этой части композиции.

Чтобы задать новое значение для CompositionLocal, используйте функцию CompositionLocalProvider и ее инфиксную функцию provides, которая связывает ключ CompositionLocal с value. Лямбда-функция content объекта CompositionLocalProvider получит указанное значение при обращении к свойству current объекта CompositionLocal. Когда вы указываете новое значение, Compose перекомпонует части композиции, которые считывают CompositionLocal.

Например, LocalContentColor CompositionLocal содержит предпочтительный цвет контента, используемый для текста и иконографии, чтобы обеспечить контрастность с текущим цветом фона. В следующем примере символ CompositionLocalProvider используется для того, чтобы задать разные значения для разных частей композиции.

@Composable
fun CompositionLocalExample() {
    MaterialTheme {
        // Surface provides contentColorFor(MaterialTheme.colorScheme.surface) by default
        // This is to automatically make text and other content contrast to the background
        // correctly.
        Surface {
            Column {
                Text("Uses Surface's provided content color")
                CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.primary) {
                    Text("Primary color provided by LocalContentColor")
                    Text("This Text also uses primary as textColor")
                    CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.error) {
                        DescendantExample()
                    }
                }
            }
        }
    }
}

@Composable
fun DescendantExample() {
    // CompositionLocalProviders also work across composable functions
    Text("This Text uses the error color now")
}

Предпросмотр composable-функции CompositionLocalExample.
Рисунок 1. Предпросмотр composable-функции CompositionLocalExample.

В последнем примере экземпляры CompositionLocal использовались внутри композиций Material. Чтобы получить текущее значение CompositionLocal, используйте его свойство current. В следующем примере текущее значение Context из LocalContext CompositionLocal, которое часто используется в приложениях Android, применяется для форматирования текста:

@Composable
fun FruitText(fruitSize: Int) {
    // Get `resources` from the current value of LocalContext
    val resources = LocalContext.current.resources
    val fruitText = remember(resources, fruitSize) {
        resources.getQuantityString(R.plurals.fruit_title, fruitSize)
    }
    Text(text = fruitText)
}

Создайте свою функцию: CompositionLocal

CompositionLocal – это инструмент для передачи данных через композицию неявным образом.

Ещё один важный сигнал для использования CompositionLocal – когда параметр является сквозным и промежуточные уровни реализации не должны знать о его существовании, поскольку это ограничит полезность composable-функции. Например, запрос разрешений Android выполняется с помощью CompositionLocal. Компонуемый элемент выбора медиаконтента может добавлять новые функции для доступа к защищенному контенту на устройстве без изменения API. При этом вызывающим элементам не нужно знать о дополнительном контексте, используемом из среды.

Однако CompositionLocal не всегда является лучшим решением. Мы не рекомендуем злоупотреблять CompositionLocal, поскольку это может привести к следующим проблемам:

CompositionLocal затрудняет понимание поведения композиции. Поскольку они создают неявные зависимости, вызывающие функции, использующие их, должны убедиться, что для каждого CompositionLocal задано значение.

Кроме того, источник этой зависимости может быть неясен, поскольку она может изменяться в любой части композиции. Поэтому отладка приложения при возникновении проблемы может быть сложнее, так как вам нужно будет просматривать композицию, чтобы узнать, где было предоставлено значение current. Такие инструменты, как Поиск использований в IDE или инспектор макетов Compose, предоставляют достаточно информации, чтобы устранить эту проблему.

Решите, нужно ли использовать CompositionLocal

В некоторых случаях CompositionLocal может быть хорошим решением:

У CompositionLocal должно быть подходящее значение по умолчанию. Если значения по умолчанию нет, вы должны гарантировать, что разработчику будет крайне сложно оказаться в ситуации, когда значение для атрибута CompositionLocal не указано. Если не указать значение по умолчанию, при создании тестов или предварительном просмотре composable-функции, в которой используется параметр CompositionLocal, могут возникнуть проблемы, поскольку его всегда нужно будет указывать явным образом.

Не используйте CompositionLocal для концепций, которые не относятся к дереву или поддереву. CompositionLocal имеет смысл, если его могут использовать все дочерние элементы, а не только некоторые из них.

Если ваш вариант использования не соответствует этим требованиям, ознакомьтесь с разделом Альтернативные варианты, прежде чем создавать CompositionLocal.

Пример плохого решения – создание CompositionLocal, в котором хранится ViewModel определенного экрана, чтобы все композируемые функции на этом экране могли получить ссылку на ViewModel для выполнения какой-либо логики. Это плохая практика, поскольку не все композиции в дереве интерфейса должны знать о ViewModel. Рекомендуется передавать в компоненты только ту информацию, которая им нужна, следуя принципу состояния вниз, события вверх. Такой подход позволит вам повторно использовать компоненты и упростит их тестирование.

Создать CompositionLocal

Для создания CompositionLocal можно использовать два API:

  • compositionLocalOf: Если изменить значение, указанное при повторном создании, это приведет к тому, что только контент, который считывает значение current, станет недействительным.

  • staticCompositionLocalOf: В отличие от compositionLocalOf, чтения staticCompositionLocalOf не отслеживаются Compose. Изменение значения приводит к тому, что вся лямбда-функция content, в которой указано значение CompositionLocal, перекомпоновывается, а не только те места, где значение current считывается в композиции.

Если значение, указанное в CompositionLocal, вряд ли изменится или не изменится никогда, используйте staticCompositionLocalOf, чтобы повысить эффективность.

Например, в системе дизайна приложения может быть предусмотрено, что композиции должны быть выделены с помощью тени для компонента интерфейса. Поскольку разные уровни высоты для приложения должны распространяться по всему дереву интерфейса, мы используем CompositionLocal. Поскольку значение CompositionLocal определяется условно на основе темы системы, мы используем API compositionLocalOf:

// LocalElevations.kt file

data class Elevations(val card: Dp = 0.dp, val default: Dp = 0.dp)

// Define a CompositionLocal global object with a default
// This instance can be accessed by all composables in the app
val LocalElevations = compositionLocalOf { Elevations() }

Как указать значения для CompositionLocal

Composable-функция CompositionLocalProvider связывает значения с экземплярами CompositionLocal для заданной иерархии. Чтобы задать новое значение для CompositionLocal, используйте инфиксную функцию provides, которая связывает ключ CompositionLocal с value следующим образом:

// MyActivity.kt file

class MyActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        setContent {
            // Calculate elevations based on the system theme
            val elevations = if (isSystemInDarkTheme()) {
                Elevations(card = 1.dp, default = 1.dp)
            } else {
                Elevations(card = 0.dp, default = 0.dp)
            }

            // Bind elevation as the value for LocalElevations
            CompositionLocalProvider(LocalElevations provides elevations) {
                // ... Content goes here ...
                // This part of Composition will see the `elevations` instance
                // when accessing LocalElevations.current
            }
        }
    }
}

Работа с CompositionLocal

CompositionLocal.current возвращает значение, предоставленное ближайшим тегом CompositionLocalProvider, который передает значение тегу CompositionLocal:

@Composable
fun SomeComposable() {
    // Access the globally defined LocalElevations variable to get the
    // current Elevations in this part of the Composition
    MyCard(elevation = LocalElevations.current.card) {
        // Content
    }
}

Альтернативные варианты

В некоторых случаях CompositionLocal может быть избыточным решением. Если ваш вариант использования не соответствует критериям, указанным в разделе Как решить, использовать ли CompositionLocal, возможно, вам подойдет другое решение.

Передача явных параметров

Явное указание зависимостей функции, аннотированной @Composable, – это хорошая практика. Мы рекомендуем передавать в компоненты только необходимые им данные. Чтобы упростить разделение и повторное использование composable-функций, каждая из них должна содержать как можно меньше информации.

@Composable
fun MyComposable(myViewModel: MyViewModel = viewModel()) {
    // ...
    MyDescendant(myViewModel.data)
}

// Don't pass the whole object! Just what the descendant needs.
// Also, don't  pass the ViewModel as an implicit dependency using
// a CompositionLocal.
@Composable
fun MyDescendant(myViewModel: MyViewModel) { /* ... */ }

// Pass only what the descendant needs
@Composable
fun MyDescendant(data: DataToDisplay) {
    // Display data
}

Инверсия управления

Ещё один способ избежать передачи ненужных зависимостей в composable-функцию – использовать инверсию управления. Вместо того чтобы дочерний элемент принимал зависимость для выполнения какой-либо логики, это делает родительский элемент.

В примере ниже показано, как дочерний элемент может инициировать запрос на загрузку данных:

@Composable
fun MyComposable(myViewModel: MyViewModel = viewModel()) {
    // ...
    MyDescendant(myViewModel)
}

@Composable
fun MyDescendant(myViewModel: MyViewModel) {
    Button(onClick = { myViewModel.loadData() }) {
        Text("Load data")
    }
}

В зависимости от ситуации MyDescendant может нести большую ответственность. Кроме того, передача MyViewModel в качестве зависимости делает MyDescendant менее пригодным для повторного использования, поскольку теперь они связаны друг с другом. Рассмотрим альтернативный вариант, в котором зависимость не передается потомку и используются принципы инверсии управления, согласно которым предок отвечает за выполнение логики:

@Composable
fun MyComposable(myViewModel: MyViewModel = viewModel()) {
    // ...
    ReusableLoadDataButton(
        onLoadClick = {
            myViewModel.loadData()
        }
    )
}

@Composable
fun ReusableLoadDataButton(onLoadClick: () -> Unit) {
    Button(onClick = onLoadClick) {
        Text("Load data")
    }
}

В некоторых случаях такой подход может быть более удобным, поскольку отделяет дочерний элемент от его непосредственных предков. Компонуемые функции-предки, как правило, становятся более сложными, чтобы сделать компонуемые функции более низкого уровня более гибкими.

Аналогичным образом можно использовать лямбда-функции для контента @Composable, чтобы получить те же преимущества:

@Composable
fun MyComposable(myViewModel: MyViewModel = viewModel()) {
    // ...
    ReusablePartOfTheScreen(
        content = {
            Button(
                onClick = {
                    myViewModel.loadData()
                }
            ) {
                Text("Confirm")
            }
        }
    )
}

@Composable
fun ReusablePartOfTheScreen(content: @Composable () -> Unit) {
    Column {
        // ...
        content()
    }
}