Перейти к содержанию
Мобильная разработка

12 вопросов по теме «Android: Корутины и Flow» на собеседовании

В этом материале — 12 вопросов из русской колоды RecallDeck по теме «Android: Корутины и Flow». Сначала сформулируйте короткий ответ сами, затем откройте подробный разбор и проверьте примеры, ограничения и отказные случаи.

11 мин чтения12 подробных ответовПроверено 24 августа 2026
Главная мысль

Привязывайте платформенное решение к lifecycle, состоянию UI, обратной связи, доступности и цене поддержки для команды.

Вопросы и ответы

12 подробных ответов

01

Что такое корутина и чем suspend отличается от блокировки потока?

Короткий ответ: Корутина — легковесная единица конкурентности поверх потоков. Приостановка (suspend) «паркует» корутину и освобождает поток под другую работу; блокировка держит поток занятым. Поэтому тысячи корутин спокойно живут на одном потоке.

Подробно:

// Блокировка: поток занят и бесполезен
fun blocking() {
    Thread.sleep(1000)   // поток спит — недоступен никому
}

// Приостановка: корутина паркуется, поток свободен
suspend fun suspending() {
    delay(1000)          // поток в это время выполняет другие корутины
}

fun main() = runBlocking {
    repeat(100_000) { launch { delay(1000) } }  // ок даже на одном потоке
    // 100 000 потоков с Thread.sleep(1000) — OutOfMemoryError
}
  1. Приостановка ≠ блокировкаdelay возвращает управление диспатчеру, Thread.sleep удерживает поток.
  2. Лёгкость — корутина это объект в куче (continuation + стейт), а не стек ~1 МБ, как у потока.
  3. Кооперативность — корутина уступает поток только в точках приостановки (suspension points).

⚠️ Частая ошибка: «саспенд-функция выполняется в фоновом потоке». Нет: suspend сам по себе поток не переключает — где выполняться, решает диспатчер.

02

Как suspend-функция работает под капотом?

Короткий ответ: Через CPS (continuation-passing style) и конечный автомат. Компилятор добавляет функции скрытый параметр Continuation, режет тело на состояния по точкам приостановки, а при приостановке возвращает COROUTINE_SUSPENDED. Возобновление — вызов resumeWith, который снова входит в функцию с нужным label.

Подробно:

suspend fun login(user: String): Token {
    val id = authorize(user)    // точка приостановки 1
    return fetchToken(id)       // точка приостановки 2
}

// эскиз того, что генерирует компилятор:
fun login(user: String, cont: Continuation<Token>): Any? {
    val sm = cont as? LoginSM ?: LoginSM(cont)
    when (sm.label) {
        0 -> {
            sm.label = 1
            val r = authorize(user, sm)   // sm передаётся как колбэк
            if (r == COROUTINE_SUSPENDED) return COROUTINE_SUSPENDED
        }
        1 -> { /* нас возобновили: результат лежит в sm.result */ }
        // ...состояние 2 — для fetchToken
    }
    // ...
}
  1. CPS — реальная сигнатура в байткоде: login(String, Continuation<Token>): Any? — возвращает результат (если не приостановилась) или COROUTINE_SUSPENDED.
  2. Конечный автомат — один continuation-объект с label хранит локальные переменные между состояниями.
  3. Возобновление — нижний слой вызывает cont.resumeWith(result), и автомат прыгает в следующую ветку when.

⚠️ Частая ошибка: «suspend — это магия потоков». Потоков тут нет вообще: это чистая трансформация компилятора; поток выбирает диспатчер через ContinuationInterceptor.

03

Диспатчеры Main, IO, Default и Unconfined — что и где выполняется?

Короткий ответ: Default — пул размером в число ядер CPU, для вычислений. IO — эластичный пул (по умолчанию до 64 потоков) для блокирующего ввода-вывода. Main — единственный поток main looper, весь UI. Unconfined — вообще не пул: возобновляется в том потоке, где закончилась приостановка.

Подробно:

Диспатчер Потоки Для чего
Dispatchers.Main main looper UI, стейт «на мейне»
Dispatchers.Default = числу ядер CPU-bound: парсинг, сортировка, diff
Dispatchers.IO эластичный, до 64+ блокирующий I/O: файлы, сеть, Room
Dispatchers.Unconfined нет своего старт в текущем потоке, resume — где придётся
  1. IO и Default делят потоки — это один общий пул с разными лимитами, поэтому withContext(Dispatchers.IO) из Default может вообще не переключить поток — только сменить лимит.
  2. IO — не про скорость — лишние потоки не ускоряют CPU-работу; они нужны, чтобы много корутин могли одновременно «висеть» в блокирующих вызовах.
  3. Unconfined — инструмент для тестов и редкой низкоуровневой оптимизации, не для продакшн-логики.

⚠️ Частая ошибка: «IO делает код параллельным». Диспатчер лишь говорит, где выполняться; параллельность создают launch/async. А CPU-задачи на IO — это просто больше потоков, дерущихся за те же ядра.

04

Структурированная конкурентность: что гарантирует иерархия корутин?

Короткий ответ: Каждая корутина живёт внутри родительского Job и не может его пережить: родитель ждёт завершения всех детей, отмена распространяется вниз, ошибки всплывают вверх. «Осиротевших» корутин не бывает — скоуп очерчивает жизненный цикл работы.

Подробно:

suspend fun loadDashboard(): Dashboard = coroutineScope {
    val user = async { api.fetchUser() }
    val feed = async { api.fetchFeed() }
    Dashboard(user.await(), feed.await())
}   // не вернётся, пока живы дети;
    // упал fetchFeed — fetchUser отменится, ошибка уйдёт вызывающему
  1. Родитель ждёт детейcoroutineScope завершится только после всех async/launch внутри: забытых фоновых хвостов не остаётся.
  2. Отмена — вниз — отменили скоуп (пользователь ушёл с экрана) — отменилось всё дерево, включая вложенные корутины любых уровней.
  3. Ошибки — вверх — упавший ребёнок отменяет братьев и передаёт исключение родителю.
  4. Скоуп = граница жизни — viewModelScope, lifecycleScope: работа привязана к владельцу, утечки корутин исключены by design, а не дисциплиной.

⚠️ Частая ошибка: заводить GlobalScope или свой CoroutineScope(Dispatchers.IO) без владельца «чтобы точно доработало». Это ровно выход из структуры: такие корутины никто не отменит и никто не дождётся.

05

Job против SupervisorJob — и почему launch(SupervisorJob()) не работает так, как ждут?

Короткий ответ: С обычным Job ошибка ребёнка отменяет родителя и всех братьев; SupervisorJob гасит это распространение — упавший ребёнок не трогает остальных. Ловушка: launch(SupervisorJob()) делает SupervisorJob родителем только этой одной корутины, а её собственные дети по-прежнему связаны обычными Job.

Подробно:

// ❌ выглядит как «защита», но не работает
scope.launch(SupervisorJob()) {
    launch { throw IOException() }  // у детей — обычные Job!
    launch { /* всё равно отменится */ }
}

// ✓ supervisorScope — дети реально независимы
supervisorScope {
    launch { throw IOException() }  // упал один
    launch { /* продолжает работать */ }
}

// ✓ скоуп, собранный с супервизором — как viewModelScope
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
  1. Зачем супервизор — для уровня, где падение одной задачи не должно валить остальные: скоуп экрана, скоуп приложения, параллельные независимые загрузки.
  2. Почему трюк не работает — Job, переданный в контекст launch, становится родителем этой корутины. Внутри неё launch создаёт обычные child-Job, и supervisor-семантика на них не распространяется.
  3. viewModelScope — уже построен на SupervisorJob: одна упавшая корутина не убивает весь скоуп ViewModel.

⚠️ Частая ошибка: совать SupervisorJob() в контекст launch/async как амулет от крашей. Работают только supervisorScope { } или скоуп, изначально собранный с SupervisorJob.

06

Исключения в корутинах: launch против async и где реально работает CoroutineExceptionHandler?

Короткий ответ: launch пробрасывает исключение сразу вверх по дереву Job; async хранит его и выбрасывает при await(). CoroutineExceptionHandler срабатывает только на корневых корутинах (в контексте скоупа или корневого launch) — вешать его на детей бесполезно: исключение уже ушло родителю.

Подробно:

val handler = CoroutineExceptionHandler { _, e -> log(e) }
val scope = CoroutineScope(SupervisorJob() + handler)

scope.launch { throw IOException() }         // поймает handler
scope.launch {
    launch(handler) { throw IOException() }  // handler ПРОИГНОРИРОВАН: не корень
}

val d = scope.async { throw IOException() }  // тихо, пока...
d.await()                                    // ...не вызвали await

// отмену не глотать:
try { work() }
catch (e: CancellationException) { throw e }  // перевыбросить обязательно
catch (e: Exception) { log(e) }
  1. launch — fail-fast: исключение немедленно идёт вверх; с обычным Job оно валит весь скоуп, с SupervisorJob — доходит до CEH.
  2. async — отложенно: без await() исключение можно вообще не увидеть (хотя родителя с обычным Job оно всё равно отменит).
  3. CEH — «последний рубеж» вместо краша процесса; работает ровно там, где исключению уже некуда всплывать.

⚠️ Частая ошибка: catch (e: Exception) в цикле, глотающий CancellationException, — корутина перестаёт отменяться. Отмену всегда перевыбрасывайте.

07

viewModelScope, lifecycleScope и repeatOnLifecycle: как правильно собирать Flow из UI?

Короткий ответ: Во ViewModel — viewModelScope: отменяется в onCleared. В UI Flow собирают через lifecycleScope.launch { repeatOnLifecycle(STARTED) { ... } }: сбор стартует на onStart и полностью отменяется на onStop, а не просто «замирает».

Подробно:

// канонический паттерн сбора стейта во фрагменте/активити
viewLifecycleOwner.lifecycleScope.launch {
    viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
        viewModel.uiState.collect { render(it) }
    }
}
  1. viewModelScope — SupervisorJob + Main.immediate, отмена в onCleared(): работа живёт, пока жива ViewModel, и переживает поворот экрана.
  2. repeatOnLifecycle(STARTED) — на каждый onStart запускает блок заново, на onStop отменяет. Пока приложение в фоне, продюсер не работает: GPS, БД, сеть — свободны.
  3. Чем плох launchWhenStarted — он лишь ПРИОСТАНАВЛИВАЕТ коллектор, а апстрим продолжает работать в фоне; именно поэтому он объявлен deprecated.
  4. ComposecollectAsStateWithLifecycle() делает то же самое одной строкой.

⚠️ Частая ошибка: lifecycleScope.launch { flow.collect { } } без repeatOnLifecycle: сбор идёт, даже когда приложение в фоне, и с горячим Flow это работа впустую вплоть до destroy.

08

withContext против async/await: когда что уместно?

Короткий ответ: withContext — последовательное переключение контекста: выполнить блок на другом диспатчере и вернуть результат. async/await — про конкурентность: запустить несколько работ параллельно и дождаться всех. async ради одного вызова — классический антипаттерн.

Подробно:

// последовательно: просто уйти с мейна
suspend fun loadUser(): User = withContext(Dispatchers.IO) {
    api.fetchUser()   // результат возвращается вызывающему
}

// параллельно: два запроса одновременно
suspend fun loadScreen(): Screen = coroutineScope {
    val user = async { api.fetchUser() }
    val posts = async { api.fetchPosts() }
    Screen(user.await(), posts.await())   // ~время самого долгого запроса
}
  1. withContext — конкурентности не добавляет: та же корутина продолжает выполняться, просто в другом контексте; вернулся блок — вернулся результат.
  2. async — возвращает Deferred; смысл появляется, когда запусков минимум два и они независимы.
  3. async { }.await() сразу же — семантически то же, что withContext, только с лишней корутиной и шумом в коде.

⚠️ Частая ошибка: оборачивать каждый вызов репозитория в async { }.await() «для асинхронности». Асинхронность уже даёт suspend; async нужен ради параллелизма.

09

Холодный и горячий Flow: в чём разница и какие из них какие?

Короткий ответ: Холодный Flow — рецепт: код продюсера запускается заново для каждого коллектора (flow { }, стримы Room/Retrofit). Горячий живёт независимо от подписчиков и делит эмиссии между ними: StateFlow и SharedFlow. Холодный превращают в горячий через stateIn/shareIn.

Подробно:

Холодный Горячий
Продюсер стартует на каждый collect работает независимо
Подписчики у каждого свой поток данных делят один общий
Без коллекторов ничего не происходит может эмитить «в пустоту»
Примеры flow {}, flowOf, Room StateFlow, SharedFlow
val uiState: StateFlow<UiState> = repository.observeUser()  // холодный
    .map(::toUiState)
    .stateIn(
        scope = viewModelScope,
        started = SharingStarted.WhileSubscribed(5_000),
        initialValue = UiState.Loading,
    )
  1. WhileSubscribed(5000) — апстрим останавливается через 5 с после ухода последнего подписчика: поворот экрана (пересоздание короче 5 с) подписка переживает без рестарта апстрима, а реальный уход в фон её честно останавливает.
  2. stateIn против shareIn — stateIn всегда держит текущее значение (стейт), shareIn настраивается через replay (события, разделяемые стримы).

⚠️ Частая ошибка: collect-нуть холодный Flow с сетевым запросом в двух местах и удивиться двум запросам — каждый коллектор запускает продюсер заново.

10

StateFlow, SharedFlow или LiveData: что для стейта, а что для событий?

Короткий ответ: StateFlow — для стейта экрана: всегда есть значение, эмиссии конфлейтятся, повторы отфильтровываются как в distinctUntilChanged. SharedFlow — для одноразовых событий: настраиваемые replay и буфер, без «текущего значения». LiveData — lifecycle-aware легаси: в новом коде её место занимают StateFlow + repeatOnLifecycle.

Подробно:

StateFlow SharedFlow LiveData
Текущее значение всегда (value) нет (replay опционален) есть, может быть пустым
Конфляция / дедуп да, distinct нет нет дедупа
Для чего стейт UI события: тост, навигация легаси-стейт
Lifecycle через repeatOnLifecycle так же встроен
  1. Стейт — «что показывать сейчас»: промежуточные значения терять безопасно, важно последнее → StateFlow.
  2. События — «сделай ровно один раз»: терять нельзя, повторять нельзя → MutableSharedFlow(replay = 0, extraBufferCapacity = 1) или Channel. Оговорка: без активного подписчика событие из SharedFlow с replay 0 просто пропадёт — отсюда и споры «events vs state».
  3. LiveData — маркер возраста кодовой базы; миграция почти механическая: stateIn ↔ asLiveData().

⚠️ Частая ошибка: класть событие «покажи тост» в StateFlow: после поворота экрана новый коллектор получит последнее значение — и тост покажется второй раз.

11

Как реализовать поиск по мере ввода (search-as-you-type) на операторах Flow?

Короткий ответ: debounce, чтобы не дёргать сеть на каждый символ; distinctUntilChanged, чтобы не повторять тот же запрос; flatMapLatest, чтобы новый ввод отменял предыдущий запрос; catch — чтобы ошибка не убила цепочку.

Подробно:

val results: StateFlow<SearchState> = queryFlow   // MutableStateFlow из поля ввода
    .debounce(300)                 // ждём паузу в наборе
    .distinctUntilChanged()        // тот же текст повторно не ищем
    .flatMapLatest { q ->
        flow { emit(repo.search(q)) }
            .map<List<Item>, SearchState> { SearchState.Data(it) }
            .onStart { emit(SearchState.Loading) }
            .catch { emit(SearchState.Error(it)) }
    }
    .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), SearchState.Idle)
  1. flatMapLatest — на каждую новую эмиссию отменяет предыдущий внутренний flow вместе с его сетевым запросом: устаревший ответ физически не может приехать поверх свежего.
  2. Почему не flatMapMerge/Concat — merge выполняет запросы параллельно, и ответы приходят вперемешку (старый может перезаписать новый); concat честно ждёт каждый — растёт очередь заведомо устаревших запросов.
  3. catch внутри flatMapLatest — ошибка одного запроса превращается в стейт ошибки, а не в смерть всего потока поиска.

⚠️ Частая ошибка: поставить catch снаружи flatMapLatest: первая же сетевая ошибка завершит всю цепочку — поиск «умирает» до пересоздания экрана.

12

Почему корутина «отказывается» отменяться и как это чинить?

Короткий ответ: Отмена кооперативна: cancel() лишь взводит флаг, а CancellationException выбрасывается в точках приостановки. CPU-цикл без yield()/ensureActive()/isActive флага не замечает и молотит до конца. Для обязательной очистки — withContext(NonCancellable).

Подробно:

val job = scope.launch(Dispatchers.Default) {
    var i = 0
    while (i < 1_000_000_000) {   // ❌ ни одной точки приостановки
        crunch(i++)                // cancel() ничего не изменит
    }
}
job.cancel()   // а корутина продолжает работать

// ✓ кооперативная версия
while (isActive && i < 1_000_000_000) { crunch(i++) }
// или ensureActive() / yield() в теле цикла

// ✓ suspend-очистка после отмены
try { work() } finally {
    withContext(NonCancellable) { connection.close() }  // suspend-вызовы в finally
}
  1. Точки приостановки — delay, yield, await и все suspend-функции kotlinx.coroutines проверяют флаг и бросают CancellationException.
  2. isActive / ensureActive / yield — isActive как условие цикла; ensureActive бросает исключение сам; yield вдобавок уступает поток.
  3. NonCancellable — в уже отменённой корутине любая точка приостановки немедленно бросит отмену снова, поэтому suspend-очистка в finally обязана жить внутри withContext(NonCancellable).

⚠️ Частая ошибка: считать cancel() «убийством потока». Это просьба: код, который не приостанавливается и не проверяет флаг, не отменяется вообще.

Источники

Источники и редакционная политика

Материалы RecallDeck сопоставлены с официальной документацией и открытыми публикациями компаний, когда первичный источник доступен. Мы не связаны с упомянутыми работодателями, не публикуем конфиденциальные задания и не продаём места в подборках. Формат найма может меняться — уточняйте его у рекрутера.

От чтения к воспроизведению

Отрепетируйте полный цикл интервью.

RecallDeck возвращает сложные темы по расписанию и помогает удерживать в памяти язык, SQL, архитектуру и поведенческие истории.

Начать подготовку

Продолжить подготовку

Мобильная разработка8 мин

10 вопросов по теме «Android: Компоненты и жизненный цикл» на собеседовании

10 вопросов по теме «Android: Компоненты и жизненный цикл» с подробными ответами, примерами, компромиссами и частыми ошибками кандидатов на техническом интервью.

10 подробных ответов
Библиотека собеседований RecallDeck

Подробные русские ответы, разборы этапов найма и планы подготовки для российского IT-рынка.

RSS