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

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

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

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

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

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

LaunchedEffect : запуск функций приостановки в области действия компонуемого объекта

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

rememberCoroutineScope — это составная функция, которая возвращает 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 : эффекты, требующие очистки

Для побочных эффектов, которые необходимо очистить после изменения ключей или если компонуемый объект покидает композицию, используйте 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, используйте SideEffect composable. Использование 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 запускает сопрограмму, ограниченную Composition, которая может помещать значения в возвращаемое 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 , когда ваши входные данные для компонуемого объекта меняются чаще, чем вам нужно перекомпоновывать. Это часто происходит, когда что-то часто меняется, например, положение прокрутки, но компонуемому объекту нужно реагировать на это только после того, как оно пересечет определенный порог. derivedStateOf создает новый объект состояния Compose, который вы можете наблюдать, который обновляется только настолько, насколько вам нужно. Таким образом, он действует аналогично оператору distinctUntilChanged() в Kotlin Flows.

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

В следующем фрагменте показан подходящий вариант использования 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> в холодный Flow. snapshotFlow запускает свой блок при сборе и выдает результат считанных в нем объектов State . Когда один из считанных внутри блока snapshotFlow объектов State мутирует, 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 }

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

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

Как правило, изменяемые и неизменяемые переменные, используемые в блоке кода эффекта, следует добавлять в качестве параметров к компонуемому эффекту. Помимо этого, можно добавить больше параметров для принудительного перезапуска эффекта. Если изменение переменной не должно приводить к перезапуску эффекта, переменную следует обернуть в 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 в качестве ключа эффекта, чтобы он следовал жизненному циклу call site . Для этого есть допустимые варианты использования, например, пример LaunchedEffect показанный выше. Однако, прежде чем сделать это, подумайте дважды и убедитесь, что это то, что вам нужно.

{% дословно %} {% endverbatim %} {% дословно %} {% endverbatim %}