lifecycleScope vs viewModelScope: где запускать корутины в Android

Если вы только что закончили изучать корутины по документации или учебному курсу, то наверняка привыкли к простой схеме: функция main(), билдер runBlocking, пара вызовов delay() — и вот уже корутина делает что-то полезное. В Android этот уютный мир рушится в первую же минуту.

Функции main() здесь нет. Блокировать потоки нельзя — главный поток отвечает за отрисовку интерфейса, и если вы его остановите хотя бы на несколько секунд, система покажет пользователю диалог «Приложение не отвечает». А компоненты — Activity, Fragment, Service — рождаются и умирают по воле операционной системы, иногда в самый неподходящий момент.

Отсюда главное правило: корутина в Android всегда запускается в скоупе, привязанном к жизненному циклу того компонента, который её породил. Скоуп — это контейнер. Пока он жив, живут и его корутины. Умер — все корутины внутри автоматически отменяются.

Android предоставляет два готовых скоупа через библиотеки Jetpack: lifecycleScope и viewModelScope. Они решают разные задачи, и путаница между ними — одна из самых частых ошибок новичков.

lifecycleScope: спутник Activity и Fragment

У каждой Activity и каждого Fragment есть свой lifecycleScope. Он создаётся вместе с компонентом и уничтожается вместе с ним.

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

class WeatherActivity : AppCompatActivity() {

    private val forecasts = listOf(
        "Москва: +12, облачно",
        "Петербург: +8, дождь",
        "Казань: +15, ясно"
    )

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_weather)

        val textView = findViewById<TextView>(R.id.textForecast)

        lifecycleScope.launch {
            var index = 0
            while (isActive) {
                textView.text = forecasts[index]
                index = (index + 1) % forecasts.size
                delay(3000)
            }
        }
    }
}

Пока Activity на экране, корутина крутится и меняет текст. Стоит пользователю закрыть экран — скоуп уничтожается, корутина отменяется. Утечки памяти нет, лишняя работа не выполняется.

Но попробуйте повернуть телефон. Android уничтожит старую Activity и создаст новую. Вместе со старой Activity погибнет и её lifecycleScope, а значит, и корутина. Новая Activity запустит новую корутину — и бегущая строка начнётся сначала.

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

viewModelScope: переживает повороты

Здесь на сцену выходит viewModelScope. Он живёт внутри ViewModel — объекта, который Android сохраняет при пересоздании Activity. Поворот экрана уничтожает Activity, но не трогает ViewModel. Корутины, запущенные в viewModelScope, продолжают работать как ни в чём не бывало.

class WeatherViewModel : ViewModel() {

    private val _forecast = MutableStateFlow("Загрузка...")
    val forecast: StateFlow<String> = _forecast

    init {
        viewModelScope.launch {
            val result = fetchForecastFromNetwork() // долгий сетевой запрос
            _forecast.value = result
        }
    }

    private suspend fun fetchForecastFromNetwork(): String {
        delay(2000) // имитация сети
        return "Москва: +12, облачно"
    }
}

Даже если пользователь десять раз повернёт телефон, пока идёт запрос, — корутина не перезапустится. Когда результат будет готов, он попадёт в StateFlow, и новая Activity подхватит его.

Ещё одна важная деталь: viewModelScope под капотом создаётся с SupervisorJob. Это значит, что если одна из корутин внутри скоупа упадёт с исключением, остальные продолжат работать. В lifecycleScope поведение аналогичное, но для ViewModel это особенно критично: там часто запущено сразу несколько параллельных операций, и падение одной не должно утянуть за собой весь экран.

Что выбрать?

Правило простое. Если корутина обслуживает UI — анимации, таймеры, обновление текста, обработка жестов — ей место в lifecycleScope. Она привязана к конкретному экрану и должна умереть вместе с ним.

Если корутина работает с данными — сетевые запросы, чтение и запись в базу, тяжёлые вычисления — запускайте её в viewModelScope. Данные не должны зависеть от капризов системы.

lifecycleScopeviewModelScope
Привязан кActivity / FragmentViewModel
Поворот экранаКорутина отменяетсяКорутина продолжает работу
Типичные задачиАнимации, таймеры, обновление UIСеть, база данных, бизнес-логика
Диспетчер по умолчаниюMainMain

Обратите внимание: оба скоупа по умолчанию работают на диспетчере Main. Если внутри корутины вы обращаетесь к сети или базе данных, не забудьте переключить контекст:

viewModelScope.launch {
    val data = withContext(Dispatchers.IO) {
        repository.loadFromDatabase()
    }
    _state.value = data
}

А что в Compose?

В Jetpack Compose понятие lifecycleScope отходит на второй план. Ему на смену приходят LaunchedEffect и rememberCoroutineScope, которые привязывают корутины не к Activity, а к отдельным Composable-функциям. Но это тема для отдельного разговора.

А viewModelScope остаётся на месте — он по-прежнему лучший выбор для работы с данными, независимо от того, строите вы интерфейс на View или на Compose.