Flow против LiveData, отмена и обработка ошибок

Вопросы уровня middle и выше: здесь легко выдать поверхностное знание, ответив «Flow — это как LiveData, только круче».

Flow — холодный асинхронный поток значений: код внутри flow { } не выполняется, пока на поток не подписались, и выполняется заново для каждого подписчика.

Вопрос 1: «Чем Flow отличается от LiveData?»

Что проверяет интервьюер

Знаете ли вы про холодность/горячность и, главное, понимаете ли, что Flow не знает про жизненный цикл — а значит, ответственность за подписку переходит к вам.

Развёрнутый ответ

LiveDataFlow
Природагорячий держатель значенияхолодный поток (кроме StateFlow/SharedFlow)
Жизненный циклзнает, сам отписываетсяне знает, нужен repeatOnLifecycle
Операторынесколько через Transformationsдесятки: map, filter, combine, debounce, flatMapLatest
Поток выполнениятолько главныйлюбой, через flowOn
Слой примененияpresentationлюбой, включая data

Отсюда типичное современное разделение ответственности: репозиторий возвращает Flow, ViewModel превращает его в StateFlow состояния экрана, UI подписывается с учётом жизненного цикла.

class FeedViewModel(repo: FeedRepository) : ViewModel() {

    val uiState: StateFlow<UiState> = repo.observeFeed()
        .map { UiState.Success(it) as UiState }
        .catch { emit(UiState.Error(it.message.orEmpty())) }
        .stateIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(5_000),
            initialValue = UiState.Loading
        )
}

Разберём три детали, которые тут ценят:

  • StateFlow — горячий поток с обязательным начальным значением и «схлопыванием» одинаковых значений подряд (conflation). Это ближайший аналог LiveData, но без привязки к жизненному циклу.
  • SharedFlow без replay — для одноразовых событий (показать снекбар, перейти на экран), потому что StateFlow при пересоздании экрана повторно выдаст последнее значение и событие сработает дважды.
  • SharingStarted.WhileSubscribed(5_000) — держим апстрим живым ещё 5 секунд после ухода последнего подписчика. Это ровно то окно, за которое проходит поворот экрана: данные не перезапрашиваются, но и не висят вечно.

На стороне UI критично собирать поток правильно:

// ПЛОХО: сбор продолжается в фоне, экран невидим, а работа идёт
lifecycleScope.launch {
    viewModel.uiState.collect { render(it) }
}

// ХОРОШО: сбор останавливается ниже STARTED и возобновляется при возврате
viewLifecycleOwner.lifecycleScope.launch {
    viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
        viewModel.uiState.collect { render(it) }
    }
}

Вопрос 2: «Как устроена отмена корутины?»

Что проверяет интервьюер

Понимаете ли, что отмена — кооперативная: никто не убивает корутину принудительно, она сама обязана заметить отмену.

Развёрнутый ответ

Отмена помечает Job как cancelled. Все suspend-функции из kotlinx.coroutines (delay, withContext, await) проверяют это состояние и бросают CancellationException. Но обычный цикл с вычислениями отмену не заметит:

// НЕ отменится: нет ни одной точки проверки
viewModelScope.launch(Dispatchers.Default) {
    while (true) { heavyStep() }
}

// Отменится корректно
viewModelScope.launch(Dispatchers.Default) {
    while (isActive) { heavyStep() }
}

// или явная проверка внутри длинного цикла
repeat(1_000_000) {
    ensureActive()
    heavyStep()
}

Две ловушки, которые обязательно стоит назвать:

  1. Нельзя глотать CancellationException. Блок catch (e: Exception) перехватывает и его тоже — и корутина «оживает» вопреки отмене, а структурная конкурентность ломается. Правильно — пробрасывать его дальше.
  2. После отмены suspend-вызовы не работают. Если в finally нужно доделать очистку с приостановкой, оберните её в withContext(NonCancellable).
try {
    repo.upload(file)
} catch (e: CancellationException) {
    throw e                 // отмену пробрасываем всегда
} catch (e: Exception) {
    showError(e)
} finally {
    withContext(NonCancellable) { repo.cleanup() }
}

Вопрос 3: «Почему try/catch вокруг launch не ловит исключение?»

Потому что launch возвращается мгновенно, ещё до того, как тело корутины начнёт выполняться. Исключение возникает позже и в другом контексте:

// НЕ работает: launch уже вернул управление
try {
    viewModelScope.launch { failingCall() }
} catch (e: Exception) { /* сюда не попадём */ }

// Работает: catch внутри корутины
viewModelScope.launch {
    try {
        failingCall()
    } catch (e: Exception) {
        _state.value = UiState.Error(e.message.orEmpty())
    }
}

Полная картина обработки ошибок: у launch необработанное исключение сразу идёт вверх по иерархии Job и может уронить приложение — глобально его перехватывает CoroutineExceptionHandler. У async исключение хранится в Deferred и выбрасывается при await(), поэтому CoroutineExceptionHandler для него не работает. Внутри Flow ошибки ловит оператор catch { }, который перехватывает исключения апстрима, но не вашего кода в collect.

Типичные ошибки кандидатов

  • Собирают Flow прямо в lifecycleScope.launch без repeatOnLifecycle — работа продолжается в фоне.
  • Отдают одноразовые события через StateFlow — после поворота экрана снекбар показывается снова.
  • Пишут catch (e: Exception) и незаметно глушат отмену.
  • Ждут, что CoroutineExceptionHandler поймает ошибку из async.
  • Считают, что Flow «сам отпишется» — это про LiveData, не про Flow.

Как ответить кратко (20–30 секунд)

«Flow — холодный поток: код выполняется на каждого подписчика и не знает про жизненный цикл, поэтому в UI собираю его через repeatOnLifecycle(STARTED). LiveData горячая и lifecycle-aware, но живёт только в presentation и почти без операторов. Состояние экрана держу в StateFlow через stateIn с WhileSubscribed(5000), одноразовые события — в SharedFlow или Channel. Отмена кооперативная: корутина обязана сама проверять isActive или вызывать suspend-функции, а CancellationException нельзя глотать — его надо пробрасывать. И try/catch ставлю внутри launch: снаружи он ничего не поймает, потому что launch возвращается сразу.»

Проверьте себя
1. Почему сбор Flow во фрагменте оборачивают в repeatOnLifecycle(Lifecycle.State.STARTED)?
Aиначе Flow не запустится вовсе
Bчтобы сбор останавливался, когда экран не виден, и возобновлялся при возврате — Flow сам про жизненный цикл не знает
Cчтобы переключить Flow на Dispatchers.Main
Dчтобы избежать дублирования значений в StateFlow
2. Что не так с кодом: try { viewModelScope.launch { api.load() } } catch (e: Exception) { showError() }?
Aничего, это корректная обработка ошибок корутины
Bнужно добавить Dispatchers.IO, иначе исключение не возникнет
Claunch возвращается сразу, тело выполняется позже — внешний catch исключение не увидит
Dcatch должен ловить Throwable вместо Exception
3. Почему одноразовые события (показать снекбар, навигация) не стоит отдавать через StateFlow?
AStateFlow не поддерживает объекты, только примитивы
BStateFlow хранит последнее значение и выдаст его новому подписчику, поэтому после поворота событие сработает повторно
CStateFlow нельзя собирать из фрагмента
DStateFlow не работает вместе с repeatOnLifecycle