LaunchedEffect и rememberCoroutineScope: корутины в Jetpack Compose

В мире View корутины запускались из чётко определённых точек: onCreate(), onResume(), onDestroy(). В Compose этих методов нет. Composable-функции вызываются многократно, перестраиваются при каждом изменении состояния и не гарантируют стабильного жизненного цикла. Запустить корутину прямо в теле Composable-функции — всё равно что поставить чайник на плиту, которая включается и выключается сама по себе.

Compose предлагает два инструмента для запуска корутин: LaunchedEffect и rememberCoroutineScope. Они решают разные задачи, и выбор между ними зависит от того, кто инициирует запуск корутины — сам Compose или пользователь.

LaunchedEffect: корутина, привязанная к жизни Composable

LaunchedEffect — это Composable-функция, которая запускает корутину при первом появлении на экране и автоматически отменяет её, когда Composable исчезает из композиции.

Простейший пример — секундомер:

@Composable
fun Stopwatch() {
    var seconds by remember { mutableIntStateOf(0) }

    LaunchedEffect(Unit) {
        while (true) {
            delay(1000)
            seconds++
        }
    }

    Text(
        text = "Прошло: $seconds с",
        fontSize = 24.sp
    )
}

Корутина стартует один раз, когда Stopwatch() попадает на экран. Каждую секунду она увеличивает счётчик, Compose перерисовывает текст. Когда пользователь уходит с экрана и Stopwatch() покидает композицию — корутина отменяется.

Аргумент Unit, который мы передаём в LaunchedEffect, — это ключ. Он определяет, при каких условиях корутина будет перезапущена. Если ключ не меняется, корутина живёт спокойно. Если ключ изменился — старая корутина отменяется, и на её месте стартует новая.

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

@Composable
fun ProductDetails(productId: Int) {
    var description by remember { mutableStateOf("Загрузка...") }

    LaunchedEffect(productId) {
        description = "Загрузка..."
        delay(1000) // имитация сетевого запроса
        description = "Описание товара #$productId"
    }

    Text(description)
}

Ключ здесь — productId. Пока пользователь смотрит один и тот же товар, корутина не перезапускается. Как только productId изменился, старая корутина отменяется (даже если запрос ещё не завершился), и стартует новая — уже с актуальным идентификатором.

Ключей может быть несколько:

LaunchedEffect(userId, language) {
    // перезапустится, если изменится userId ИЛИ language
}

Раньше у LaunchedEffect была версия без ключей — просто LaunchedEffect { ... }. Сейчас она помечена как deprecated. Даже если перезапуск вам не нужен, передайте хотя бы фиктивный ключ Unit.

rememberCoroutineScope: корутина по запросу

LaunchedEffect хорош, когда корутина должна стартовать автоматически, вместе с появлением Composable на экране. Но что делать, если корутину нужно запустить по нажатию кнопки?

Вызвать launch прямо из onClick нельзя — у нас нет скоупа. Вот тут и пригодится rememberCoroutineScope:

@Composable
fun SaveButton(viewModel: SettingsViewModel) {
    val scope = rememberCoroutineScope()
    var isSaving by remember { mutableStateOf(false) }

    Button(
        onClick = {
            scope.launch {
                isSaving = true
                viewModel.saveSettings()
                isSaving = false
            }
        },
        enabled = !isSaving
    ) {
        Text(if (isSaving) "Сохранение..." else "Сохранить")
    }
}

Функция rememberCoroutineScope() создаёт скоуп, привязанный к текущему Composable. Пока SaveButton на экране, скоуп жив. Исчезнет SaveButton — скоуп отменит все свои корутины.

Важное отличие от LaunchedEffect: корутина стартует не автоматически, а только когда мы сами вызовем scope.launch { }. Это даёт нам полный контроль над моментом запуска.

Когда что использовать

Разница между этими инструментами — в инициаторе запуска:

LaunchedEffectrememberCoroutineScope
ЗапускАвтоматический, при входе в композициюРучной, по событию (клик, жест)
ПерезапускПри изменении ключейТолько при повторном вызове launch
ОтменаПри выходе из композиции или смене ключейПри выходе из композиции
Типичные задачиЗагрузка данных, подписка на события, таймерыОтправка формы, сохранение, навигация по клику

Есть простой способ не ошибиться: если корутина должна работать всегда, пока виден экран, — используйте LaunchedEffect. Если она нужна в ответ на действие пользователя — берите rememberCoroutineScope.

Частая ошибка: launch внутри Composable

Иногда возникает соблазн написать так:

// не делайте так
@Composable
fun BadExample() {
    val scope = rememberCoroutineScope()
    scope.launch {
        // корутина запускается при каждой рекомпозиции!
    }
}

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

Если корутина должна стартовать автоматически — используйте LaunchedEffect, а не голый launch в теле Composable. А rememberCoroutineScope вызывайте только из обработчиков событий: onClick, onValueChange и им подобных.


Оба этих инструмента работают на стороне UI. Для долгих операций с данными — сетевых запросов, работы с базой — по-прежнему лучше использовать viewModelScope внутри ViewModel. А LaunchedEffect и rememberCoroutineScope подключат результат этой работы к интерфейсу.