Структурированная конкурентность в Android в 2026: области видимости, отмена и утечки, которых она не допускает
Практическое руководство по структурированной конкурентности Kotlin в Android — viewModelScope против lifecycleScope, почему отмена распространяется, и какие утечки корутин это тихо предотвращает.
Большинство багов с корутинами, которые я отлаживал в Android, были не из-за того, что функция suspend делала что-то не то — а из-за того, что корутина жила дольше того, что её запустило. Сетевой вызов завершается уже после того, как экран исчез, и падает при попытке обновить уничтоженный view. Коллектор Flow продолжает работать в фоне, расходуя батарею на экран, на который уже никто не смотрит. Структурированная конкурентность — это дисциплина, которая исключает подобное самой своей конструкцией, а не тем, что вы не забыли всё убрать за собой, — и понимать, что она на самом деле гарантирует, ценнее, чем зазубрить, где какой Scope использовать.
Ключевая идея: корутина не может пережить свою область видимости
Каждая корутина в Kotlin принадлежит CoroutineScope, и эта область видимости определяет её время жизни. Запустите корутину внутри области видимости — и она становится дочерней по отношению к job этой области. Отмените область видимости — и каждая дочерняя корутина, независимо от того, насколько глубоко она вложена и на сколько вызовов async разветвилась, отменяется вместе с ней. Вот и вся гарантия целиком: вы не можете случайно допустить утечку корутины за пределы владеющей ею области видимости, потому что язык просто не даёт вам такой возможности.
class BudgetViewModel(private val repo: TransactionRepository) : ViewModel() {
fun refreshMonthlyTotal() {
viewModelScope.launch {
val total = repo.computeMonthlyTotal() // здесь suspend
_monthlyTotal.value = total
}
}
}
Если пользователь уходит на другой экран, и ViewModel очищается прямо посреди computeMonthlyTotal(), viewModelScope отменяется автоматически, и приведённая выше корутина останавливается — она никогда не доходит до строки, которая записывает значение в _monthlyTotal. Не нужен ни ручной флаг для проверки, ни защита isActive перед записью, потому что после того, как отмена достигает точки приостановки, корутина просто не возобновляется.
viewModelScope, lifecycleScope и как выбрать нужный
Android предоставляет две области видимости, привязанные к жизненным циклам компонентов, и выбор не той — самая частая ошибка структурированной конкурентности, которую я вижу:
viewModelScopeживёт столько же, сколькоViewModel— он переживает изменения конфигурации (поворот экрана, переключение тёмного режима) и отменяется только при очисткеViewModel, обычно когда пользователь окончательно покидает экран. Используйте его для работы, результат которой экрану всё ещё важен после поворота: загрузка данных, запись в репозиторий, вычисление производного значения.lifecycleScope, и в частностиrepeatOnLifecycle(Lifecycle.State.STARTED)внутри него, привязан к жизненному циклу viewActivityилиFragment. Используйте его для всего, что действительно должно остановиться, когда экран не виден, — чаще всего для сбораFlowс целью обновления UI:
class ReminderListFragment : Fragment() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.reminders.collect { reminders ->
adapter.submitList(reminders)
}
}
}
}
}
Сбор данных напрямую в viewModelScope вместо этого держал бы коллектор — и любой Room-запрос, который он запускает, — работающим, пока приложение свёрнуто, поскольку viewModelScope не знает, что view исчез. Это не утечка в смысле краша; это более тихая и медленная плата: впустую потраченный CPU, впустую потраченная батарея и Flow, который продолжает эмитить значения в UI, который никто не видит. repeatOnLifecycle — решение именно потому, что он заново выводит свежую область видимости, привязанную к STARTED, отменяя и перезапуская сбор по мере того, как жизненный цикл входит в это состояние и выходит из него.
Структурированная конкурентность делает обработку ошибок честной
До структурированной конкурентности распространённым паттерном было запускать «голую» корутину через GlobalScope.launch — без области видимости, без владельца, вне какого-либо родительского надзора. Если она бросала исключение, оно никуда полезно не попадало, а если к моменту её завершения экран уже исчезал, ловить крах было просто некому. Структурированная конкурентность заставляет каждую корутину иметь родителя, а значит у каждого исключения есть определённое место, куда распространяться: вверх по иерархии job до того места, где установлен CoroutineExceptionHandler, или к отмене самой области видимости, если такого обработчика никто не установил.
private val handler = CoroutineExceptionHandler { _, throwable ->
_uiState.value = UiState.Error(throwable.message)
}
fun syncPantryFromReceipt(bitmap: Bitmap) {
viewModelScope.launch(handler) {
val items = ocrEngine.extract(bitmap) // может бросить исключение
repo.insertAll(items)
}
}
Это тот самый паттерн, на котором строится обработка неудачного сканирования чека в Stocky — этап OCR может провалиться по десятку причин (плохое освещение, нечитаемый шрифт, формат чека, который приложение никогда раньше не видело), и структурированная конкурентность гарантирует, что этот сбой всплывёт ровно в одном месте, а не тихо исчезнет внутри корутины по принципу «запустил и забыл».
async/await: отмена работает в обе стороны
Билдеры coroutineScope { } и async распространяют ту же гарантию на дочерние корутины-«сёстры»: если одна дочерняя корутина coroutineScope бросает исключение, все остальные дочерние тоже отменяются, и исключение распространяется наружу только после того, как все они завершились. Это важно всякий раз, когда вы распределяете конкурентную работу, имеющую смысл только вместе:
suspend fun loadDashboard(): DashboardState = coroutineScope {
val totalDeferred = async { repo.computeMonthlyTotal() }
val trendDeferred = async { repo.computeSpendingTrend() }
DashboardState(totalDeferred.await(), trendDeferred.await())
}
Если computeSpendingTrend() бросает исключение, computeMonthlyTotal() тоже отменяется — вы никогда не окажетесь с наполовину отрисованной панелью, тихо собранной из частично провалившегося запроса. Без структурированной конкурентности второй async продолжал бы работать после краха первого, его результат никто бы не дожидался, а его работа оказалась бы впустую потраченной.
Итог
Структурированная конкурентность — не вопрос стиля, а то, что превращает вопрос «а не забыл ли я это отменить» из бага времени выполнения в невозможность на этапе компиляции. Правило, которое имеет значение на практике: запускайте внутри области видимости, соответствующей тому, как долго нужен результат, а не той, что просто оказалась под рукой. viewModelScope — для работы, которая экрану всё ещё важна после поворота, lifecycleScope с repeatOnLifecycle — для всего, что должно останавливаться, когда view не виден, и CoroutineExceptionHandler — везде, где сбою нужно определённое место назначения, а не полное его отсутствие.
// По теме
Ещё из журнала
In-App Review API в Android: как просить оценку, не раздражая пользователя
Практическое руководство по In-App Review API от Google — как он на самом деле работает, когда его запускать и почему обычный попап «оцените нас» незаметно портит вашу оценку в Play Store.
Ярлыки приложений Android и плитка быстрых настроек: логирование воды без открытия приложения
Практическое руководство по динамическому ShortcutManager API и TileService в Android — как позволить действию в один тап полностью обойти приложение, и о подводных камнях, на которых спотыкается большинство реализаций.
Типы foreground-сервисов в Android в 2026 году: как выбрать тот, что реально подходит вашей функции
Практическое руководство по ограничениям типов foreground-сервисов Android — dataSync, mediaPlayback, specialUse, shortService — и как выбрать правильный, не получив ни принудительной остановки, ни отказа в проверке.