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

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

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

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

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

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

LaunchedEffect: выполнение функций suspend в области composable-функции

Чтобы выполнять задачи в течение всего жизненного цикла composable-функции и иметь возможность вызывать функции приостановки, используйте composable-функцию LaunchedEffect. Когда LaunchedEffect входит в композицию, он запускает сопрограмму с блоком кода, переданным в качестве параметра. Если 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. Это будет повторяться в течение всего времени существования элемента.

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

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

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

В приведенном выше примере этот код можно использовать, чтобы показывать 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-функция покидает композицию, используйте DisposableEffect. Если ключи DisposableEffect меняются, композиции нужно удалить (очистить) текущий эффект и сбросить его, вызвав снова.

Например, вы можете отправлять события Аналитики на основе событий 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: преобразовать состояние, отличное от состояния создания письма, в состояние создания письма

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

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

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

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

@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 повторная композиция происходит каждый раз, когда изменяется наблюдаемый объект состояния или компонуемый вход. Объект состояния или входные данные могут меняться чаще, чем нужно обновлять интерфейс, что приводит к ненужной повторной композиции.

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

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

Во фрагменте кода ниже показан подходящий вариант использования метода 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 меняется каждый раз, когда меняется первый видимый элемент. По мере прокрутки значение меняется на 0, 1, 2, 3, 4, 5 и т. д. Однако рекомпозиция требуется только в том случае, если значение больше 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.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 преобразуется в поток, который может использовать операторы потока.

Как перезапустить эффекты

Некоторые эффекты в 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, приведенном выше. Однако прежде чем это сделать, подумайте, действительно ли вам это нужно.