Flow против LiveData, отмена и обработка ошибок
Вопросы уровня middle и выше: здесь легко выдать поверхностное знание, ответив «Flow — это как LiveData, только круче».
Flow — холодный асинхронный поток значений: код внутри
flow { }не выполняется, пока на поток не подписались, и выполняется заново для каждого подписчика.
Вопрос 1: «Чем Flow отличается от LiveData?»
Что проверяет интервьюер
Знаете ли вы про холодность/горячность и, главное, понимаете ли, что Flow не знает про жизненный цикл — а значит, ответственность за подписку переходит к вам.
Развёрнутый ответ
| LiveData | Flow | |
| Природа | горячий держатель значения | холодный поток (кроме 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()
}
Две ловушки, которые обязательно стоит назвать:
- Нельзя глотать
CancellationException. Блокcatch (e: Exception)перехватывает и его тоже — и корутина «оживает» вопреки отмене, а структурная конкурентность ломается. Правильно — пробрасывать его дальше. - После отмены 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возвращается сразу.»