Перейти к содержимому
Все записи

Структурированная конкурентность в Android в 2026: области видимости, отмена и утечки, которых она не допускает

Практическое руководство по структурированной конкурентности Kotlin в Android — viewModelScope против lifecycleScope, почему отмена распространяется, и какие утечки корутин это тихо предотвращает.

MFKAPPS 5 мин чтения

Большинство багов с корутинами, которые я отлаживал в 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) внутри него, привязан к жизненному циклу view Activity или 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 — везде, где сбою нужно определённое место назначения, а не полное его отсутствие.

// По теме

Ещё из журнала

MFKAPPS 4 мин чтения

In-App Review API в Android: как просить оценку, не раздражая пользователя

Практическое руководство по In-App Review API от Google — как он на самом деле работает, когда его запускать и почему обычный попап «оцените нас» незаметно портит вашу оценку в Play Store.

#android #engineering #kotlin
MFKAPPS 5 мин чтения

Ярлыки приложений Android и плитка быстрых настроек: логирование воды без открытия приложения

Практическое руководство по динамическому ShortcutManager API и TileService в Android — как позволить действию в один тап полностью обойти приложение, и о подводных камнях, на которых спотыкается большинство реализаций.

#android #engineering #kotlin
MFKAPPS 4 мин чтения

Типы foreground-сервисов в Android в 2026 году: как выбрать тот, что реально подходит вашей функции

Практическое руководство по ограничениям типов foreground-сервисов Android — dataSync, mediaPlayback, specialUse, shortService — и как выбрать правильный, не получив ни принудительной остановки, ни отказа в проверке.

#android #engineering #kotlin