Побочные эффекты в 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)
    }
}

В приведённом выше коде анимация использует функцию задержки (suspending function 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 . Использование SideEffect гарантирует выполнение эффекта после каждой успешной перекомпозиции. С другой стороны, некорректно выполнять эффект до того, как будет гарантирована успешная перекомпозиция, что имеет место при записи эффекта непосредственно в компонуемом объекте.

Например, ваша аналитическая библиотека может позволить вам сегментировать аудиторию пользователей, добавляя пользовательские метаданные (в данном примере — «свойства пользователя») ко всем последующим аналитическим событиям. Чтобы сообщить тип текущего пользователя в аналитическую библиотеку, используйте 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, например, для добавления в Composition состояния, управляемого внешней подпиской, такого как Flow , LiveData или RxJava .

Производитель запускается, когда 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 . При мутации одного из объектов 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 }

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

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

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

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