Побочные эффекты в Compose

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

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

Примеры использования состояний и эффектов

Как описано в документации Thinking in Compose, composable-функции не должны иметь побочных эффектов. Если вам нужно изменить состояние приложения (как описано в документации по управлению состоянием), используйте Effect API, чтобы побочные эффекты выполнялись предсказуемым образом.

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

LaunchedEffect: запуск функций приостановки в области действия composable-функции.

Чтобы выполнять работу в течение жизненного цикла composable-функции и иметь возможность вызывать функции приостановки, используйте composable-функцию LaunchedEffect. Когда LaunchedEffect входит в Composition, он запускает сопрограмму с блоком кода, переданным в качестве параметра. Сопрограмма будет отменена, если LaunchedEffect покинет композицию. Если LaunchedEffect рекомпонуется с другими ключами (см. раздел Перезапуск эффектов ниже), существующая сопрограмма будет отменена, а новая функция приостановки будет запущена в новой сопрограмме.

Например, вот анимация, которая пульсирует значением альфа с настраиваемой задержкой:

// Allow the pulse rate to be configured, so it can be sped up if the user is running
// out of time
var pulseRateMs by remember { mutableLongStateOf(3000L) }
val alpha = remember { Animatable(1f) }
LaunchedEffect(pulseRateMs) { // Restart the effect when the pulse rate changes
    while (isActive) {
        delay(pulseRateMs) // Pulse the alpha every pulseRateMs to alert the user
        alpha.animateTo(0f)
        alpha.animateTo(1f)
    }
}

В коде выше анимация использует функцию приостановки delay, чтобы подождать заданное количество времени. Затем он последовательно анимирует альфа-канал до нуля и обратно, используя animateTo. Это будет повторяться на протяжении всего жизненного цикла composable-функции.

rememberCoroutineScope: получение области видимости, учитывающей композицию, для запуска сопрограммы вне composable-функции.

Поскольку LaunchedEffect – это composable-функция, ее можно использовать только внутри других composable-функций. Чтобы запустить сопрограмму вне composable-функции, но в области видимости, которая автоматически отменяется при выходе из композиции, используйте rememberCoroutineScope. Также используйте rememberCoroutineScope, когда вам нужно вручную управлять жизненным циклом одной или нескольких сопрограмм, например отменять анимацию при возникновении пользовательского события.

rememberCoroutineScope – это composable-функция, которая возвращает CoroutineScope, привязанный к точке композиции, в которой он вызывается. Область видимости будет отменена, когда вызов покинет Composition.

Продолжая предыдущий пример, вы можете использовать этот код, чтобы показать Snackbar, когда пользователь нажимает на Button:

@Composable
fun MoviesScreen(snackbarHostState: SnackbarHostState) {

    // Creates a CoroutineScope bound to the MoviesScreen's lifecycle
    val scope = rememberCoroutineScope()

    Scaffold(
        snackbarHost = {
            SnackbarHost(hostState = snackbarHostState)
        }
    ) { contentPadding ->
        Column(Modifier.padding(contentPadding)) {
            Button(
                onClick = {
                    // Create a new coroutine in the event handler to show a snackbar
                    scope.launch {
                        snackbarHostState.showSnackbar("Something happened!")
                    }
                }
            ) {
                Text("Press me")
            }
        }
    }
}

rememberUpdatedState: ссылка на значение в эффекте, который не должен перезапускаться при изменении значения.

LaunchedEffect перезапускается, когда меняется один из ключевых параметров. Однако в некоторых ситуациях вам может потребоваться зафиксировать значение в эффекте, при изменении которого вы не хотите, чтобы эффект перезапускался. Для этого необходимо использовать rememberUpdatedState, чтобы создать ссылку на это значение, которое можно зафиксировать и обновить. Этот подход полезен для эффектов, содержащих длительные операции, воссоздание и перезапуск которых может быть дорогостоящим или запретительным.

Например, в вашем приложении есть элемент LandingScreen, который исчезает через некоторое время. Даже если LandingScreen будет перекомпонован, эффект, который ожидает некоторое время и уведомляет о том, что время прошло, не должен перезапускаться:

@Composable
fun LandingScreen(onTimeout: () -> Unit) {

    // This will always refer to the latest onTimeout function that
    // LandingScreen was recomposed with
    val currentOnTimeout by rememberUpdatedState(onTimeout)

    // Create an effect that matches the lifecycle of LandingScreen.
    // If LandingScreen recomposes, the delay shouldn't start again.
    LaunchedEffect(true) {
        delay(SplashWaitTimeMillis)
        currentOnTimeout()
    }

    /* Landing screen content */
}

Чтобы создать эффект, соответствующий жизненному циклу сайта вызова, в качестве параметра передается неизменная константа, например Unit или true. В приведенном выше коде используется LaunchedEffect(true). Чтобы onTimeout лямбда-выражение всегда содержало последнее значение, с которым была перекомпонована LandingScreen, onTimeout нужно обернуть функцией rememberUpdatedState. Возвращенный State, currentOnTimeout в коде, должен использоваться в эффекте.

DisposableEffect: эффекты, требующие очистки

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

Например, вы можете отправлять события аналитики на основе событий Lifecycle с помощью LifecycleObserver. Чтобы отслеживать эти события в Compose, используйте DisposableEffect для регистрации и отмены регистрации наблюдателя по мере необходимости.

@Composable
fun HomeScreen(
    lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current,
    onStart: () -> Unit, // Send the 'started' analytics event
    onStop: () -> Unit // Send the 'stopped' analytics event
) {
    // Safely update the current lambdas when a new one is provided
    val currentOnStart by rememberUpdatedState(onStart)
    val currentOnStop by rememberUpdatedState(onStop)

    // If `lifecycleOwner` changes, dispose and reset the effect
    DisposableEffect(lifecycleOwner) {
        // Create an observer that triggers our remembered callbacks
        // for sending analytics events
        val observer = LifecycleEventObserver { _, event ->
            if (event == Lifecycle.Event.ON_START) {
                currentOnStart()
            } else if (event == Lifecycle.Event.ON_STOP) {
                currentOnStop()
            }
        }

        // Add the observer to the lifecycle
        lifecycleOwner.lifecycle.addObserver(observer)

        // When the effect leaves the Composition, remove the observer
        onDispose {
            lifecycleOwner.lifecycle.removeObserver(observer)
        }
    }

    /* Home screen content */
}

В приведенном выше коде эффект добавит observer к lifecycleOwner. Если lifecycleOwner изменится, эффект будет удален и перезапущен с новым lifecycleOwner.

В блоке кода DisposableEffect в качестве последнего оператора должно быть указано предложение onDispose. В противном случае IDE покажет ошибку времени сборки.

SideEffect: публикация состояния Compose в коде, не относящемся к Compose

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

Например, библиотека аналитики может позволить вам сегментировать пользователей, добавляя настраиваемые метаданные ("свойства пользователя" в этом примере) ко всем последующим событиям аналитики. Чтобы передать тип текущего пользователя в библиотеку аналитики, используйте SideEffect.

@Composable
fun rememberFirebaseAnalytics(user: User): FirebaseAnalytics {
    val analytics: FirebaseAnalytics = remember {
        FirebaseAnalytics()
    }

    // On every successful composition, update FirebaseAnalytics with
    // the userType from the current User, ensuring that future analytics
    // events have this metadata attached
    SideEffect {
        analytics.setUserProperty("userType", user.userType)
    }
    return analytics
}

produceState: преобразовать состояние, не связанное с Compose, в состояние Compose

produceState запускает сопрограмму, ограниченную областью действия Composition, которая может передавать значения в возвращаемый объект State. Используйте его, чтобы преобразовать состояние, не относящееся к Compose, в состояние Compose, например перенести внешнее состояние, управляемое подпиской, такое как Flow, LiveData или RxJava, в Composition.

Производитель запускается, когда produceState входит в Composition, и отменяется, когда он выходит из Composition. Возвращаемый State объединяется; установка того же значения не вызовет рекомпозицию.

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

В следующем примере показано, как использовать produceState для загрузки изображения из сети. Функция loadNetworkImage возвращает объект State, который можно использовать в других composable-функциях.

@Composable
fun loadNetworkImage(
    url: String,
    imageRepository: ImageRepository = ImageRepository()
): State<Result<Image>> {
    // Creates a State<T> with Result.Loading as initial value
    // If either `url` or `imageRepository` changes, the running producer
    // will cancel and will be re-launched with the new inputs.
    return produceState<Result<Image>>(initialValue = Result.Loading, url, imageRepository) {
        // In a coroutine, can make suspend calls
        val image = imageRepository.load(url)

        // Update State with either an Error or Success result.
        // This will trigger a recomposition where this State is read
        value = if (image == null) {
            Result.Error
        } else {
            Result.Success(image)
        }
    }
}

derivedStateOf – преобразование одного или нескольких объектов состояния в другое состояние.

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

Функцию derivedStateOf следует использовать, когда входные данные composable-функции меняются чаще, чем требуется рекомпозиция. Это часто происходит, когда что-то часто меняется, например позиция прокрутки, но composable-функция должна реагировать на это только после того, как она пересечет определенное пороговое значение. derivedStateOf создает новый объект состояния Compose, который вы можете наблюдать и который обновляется только тогда, когда вам нужно. В этом отношении он похож на оператор distinctUntilChanged() в Kotlin Flow.

Правильное использование

В следующем фрагменте кода показан пример использования derivedStateOf:

@Composable
// When the messages parameter changes, the MessageList
// composable recomposes. derivedStateOf does not
// affect this recomposition.
fun MessageList(messages: List<Message>) {
    Box {
        val listState = rememberLazyListState()

        LazyColumn(state = listState) {
            // ...
        }

        // Show the button if the first visible item is past
        // the first item. We use a remembered derived state to
        // minimize unnecessary compositions
        val showButton by remember {
            derivedStateOf {
                listState.firstVisibleItemIndex > 0
            }
        }

        AnimatedVisibility(visible = showButton) {
            ScrollToTopButton()
        }
    }
}

В этом фрагменте firstVisibleItemIndex меняется каждый раз, когда меняется первый видимый элемент. As you scroll, the value becomes 0, 1, 2, 3, 4, 5, etc. However, recomposition only needs to occur if the value is greater than 0. Это несоответствие в частоте обновлений означает, что это хороший вариант использования для derivedStateOf.

Неправильное использование

Распространенная ошибка – предполагать, что при объединении двух объектов состояния Compose следует использовать derivedStateOf, поскольку вы "выводите состояние". Однако это не обязательно, как показано в следующем фрагменте кода:

// DO NOT USE. Incorrect usage of derivedStateOf.
var firstName by remember { mutableStateOf("") }
var lastName by remember { mutableStateOf("") }

val fullNameBad by remember { derivedStateOf { "$firstName $lastName" } } // This is bad!!!
val fullNameCorrect = "$firstName $lastName" // This is correct

В этом фрагменте fullName нужно обновлять так же часто, как firstName и lastName. Поэтому избыточная рекомпозиция не происходит, и использовать derivedStateOf не нужно.

snapshotFlow: преобразование состояния Compose в потоки

Используйте snapshotFlow, чтобы преобразовать объекты State<T> в холодный поток. snapshotFlow выполняет свой блок при сборе и выдает результат чтения объектов State. Когда один из объектов State, считанных внутри блока snapshotFlow, изменяется, Flow передает новое значение своему сборщику, если оно не равно предыдущему переданному значению (это поведение аналогично поведению Flow.distinctUntilChanged).

В следующем примере показан побочный эффект, который регистрирует, когда пользователь прокручивает первый элемент в списке, в аналитику:

val listState = rememberLazyListState()

LazyColumn(state = listState) {
    // ...
}

LaunchedEffect(listState) {
    snapshotFlow { listState.firstVisibleItemIndex }
        .map { index -> index > 0 }
        .distinctUntilChanged()
        .filter { it == true }
        .collect {
            MyAnalyticsService.sendScrolledPastFirstItemEvent()
        }
}

В приведенном выше коде listState.firstVisibleItemIndex преобразуется в Flow, что позволяет использовать операторы Flow.

Перезапуск эффектов

Некоторые эффекты в Compose, такие как LaunchedEffect, produceState или DisposableEffect, принимают переменное количество аргументов (ключей), которые используются для отмены текущего эффекта и запуска нового с новыми ключами.

Стандартный формат для этих API:

EffectName(restartIfThisKeyChanges, orThisKey, orThisKey, ...) { block }

Из-за особенностей такого поведения могут возникнуть проблемы, если параметры, используемые для перезапуска эффекта, неверны:

  • Если перезапускать меньше эффектов, чем нужно, в приложении могут возникать ошибки.
  • Перезапуск эффектов чаще, чем необходимо, может быть неэффективным.

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

В приведенном выше коде DisposableEffect эффект принимает в качестве параметра lifecycleOwner, используемый в его блоке, поскольку любое изменение в них должно привести к перезапуску эффекта.

@Composable
fun HomeScreen(
    lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current,
    onStart: () -> Unit, // Send the 'started' analytics event
    onStop: () -> Unit // Send the 'stopped' analytics event
) {
    // These values never change in Composition
    val currentOnStart by rememberUpdatedState(onStart)
    val currentOnStop by rememberUpdatedState(onStop)

    DisposableEffect(lifecycleOwner) {
        val observer = LifecycleEventObserver { _, event ->
            /* ... */
        }

        lifecycleOwner.lifecycle.addObserver(observer)
        onDispose {
            lifecycleOwner.lifecycle.removeObserver(observer)
        }
    }
}

currentOnStart и currentOnStop не нужны в качестве ключей DisposableEffect, потому что их значения никогда не меняются в Composition из-за использования rememberUpdatedState. Если вы не передадите lifecycleOwner в качестве параметра и он изменится, HomeScreen будет перекомпонован, но DisposableEffect не будет удален и перезапущен. Это приводит к проблемам, поскольку с этого момента используется неправильный lifecycleOwner.

Константы в качестве ключей

Вы можете использовать константу, например true, в качестве ключа эффекта, чтобы он следовал жизненному циклу сайта вызова. Однако в некоторых случаях, например в приведенном выше примере с LaunchedEffect, это допустимо. Однако прежде чем сделать это, подумайте дважды и убедитесь, что это именно то, что вам нужно.