Введение: операторы, которые спрашивают на собеседовании

«Назовите три оператора Flow и объясните, чем combine отличается от zip» — вопрос, который я задаю почти каждому кандидату уровня junior+. Отвечают полностью единицы.

Операторы — сердце Flow. На собеседовании достаточно попросить кандидата написать цепочку из map и filter, чтобы понять: читал он документацию или просто видел примеры в чужих проектах. А вот combine и zip уже разделяют тех, кто копирует код, и тех, кто понимает, как он работает.

В этой статье разберу пять операторов, которые покрывают большинство реальных задач: map, filter, combine, zip и debounce. Покажу, как работает каждый, где применяется в проекте и на чём ошибаются кандидаты. Все примеры — по официальной документации Kotlin.

Хорошая новость: тема конечная. Пять-семь операторов закрывают почти всё, что реально используется в проектах. Плохая новость: зубрёжка определений не спасает — интервьюеры всегда добавляют уточняющий вопрос «а что будет, если…». Поэтому разбираем не определения, а поведение.

map и filter: базовые преобразования

map преобразует каждое значение потока ровно в одно новое. filter пропускает только те значения, которые удовлетворяют условию. Это пара, с которой начинается любой разговор об операторах — и на ней же кандидаты часто теряются.

SearchRepository.kt
val searchResults: Flow<List<Result>> = flow {
    emit(loadFromCache())
    emit(loadFromNetwork())
}
    .map { results -> results.filterNot { it.isExpired } }
    .filter { results -> results.isNotEmpty() }

Два момента, которые отличают понимание от зубрёжки:

  1. Лямбды map и filter — suspending. Внутри можно вызывать подвешивающие функции: delay, suspend-функции обращения к базе или сети. В Sequence так нельзя — и это одна из причин, почему Flow выигрывает у последовательностей для асинхронных данных.
  2. Операторы не запускают поток. Цепочка из десяти map ничего не делает, пока не вызван collect: промежуточные операторы только возвращают новый поток. В документации это сформулировано прямо: промежуточные операторы холодные и не начинают обработку до сбора, «even when the upstream flow is hot».

Кстати, mapNotNull — удобная связка map и filter: преобразует значение и пропускает null-результаты. Полезно при разборе ответов API, где поля могут отсутствовать.

Если копнуть глубже: и map, и filter — частные случаи общего оператора transform, который умеет эмитить из лямбды несколько значений подряд. Понимание этой иерархии («transform — база, map и filter — его упрощения») на собеседовании выглядит сильно: кандидат показывает, что видит систему, а не список функций.

Ошибка-классика: «операторы выполняются сразу при вызове». Нет — они ленивы, как Sequence. Если кандидат не может объяснить, почему код внутри flow {} ничего не печатает до collect, это красный флаг: дальше по теме собеседование можно не продолжать.

combine и zip: соединяем потоки

Здесь начинается самое интересное, потому что combine и zip решают разные задачи, а кандидаты путают их почти всегда.

zip соединяет значения строго попарно: первое с первым, второе со вторым. Поток завершается, как только завершается один из исходных потоков — оставшиеся значения второго просто отбрасываются. Это механизм сопоставления двух последовательностей.

combine работает иначе: новый результат эмитится, когда любой из потоков выдал новое значение, при этом берутся последние значения из каждого. Документация формулирует так: «emits a new value when any upstream flow emits a value, using the latest value from each upstream flow».

OrdersViewModel.kt
val uiState = combine(userFlow, cartFlow) { user, cart ->
    UiState(user = user, cartCount = cart.size)
}

val pairs = userFlow.zip(cartFlow) { user, cart ->
    UserWithCart(user, cart) // первое с первым, второе со вторым
}

Где это в реальном проекте:

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

Ещё нюанс: combine принимает от двух потоков и выше — вариант с тремя и более источниками в проектах встречается постоянно, а zip в реальном коде почти не используется: случай «два потока строго попарно» редкий. Поэтому на вопрос «что чаще встречается в продакшене?» правильный ответ — combine, и кандидаты, которые это проговаривают сами, выглядят практиками, а не шпаргалкой.

Как запомнить разницу

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

debounce: антиспам для событий

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

Пример из документации: поток 1, 2, 3, 4, 5, где между первыми значениями по 90 мс, между остальными — 1010 мс. С debounce(1000) на выходе будет 3, 4, 5: первые два значения «задавлены» более новыми в пределах секунды.

SearchViewModel.kt
class SearchViewModel(
    private val repository: SearchRepository
) : ViewModel() {

    private val _query = MutableStateFlow("")
    val suggestions: StateFlow<List<Suggestion>> =
        _query
            .debounce(300)
            .distinctUntilChanged()
            .mapLatest { query -> repository.search(query) }
            .stateIn(
                scope = viewModelScope,
                started = SharingStarted.WhileSubscribed(5_000),
                initialValue = emptyList()
            )
}

Это классический паттерн поиска с автодополнением: пользователь печатает — запросы «склеиваются», в сеть уходит только финальный, после паузы в 300 мс. Без debounce каждый символ порождал бы запрос, и сервер бы захлебнулся.

Нюансы, которые стоит знать:

  • debounce помечен @FlowPreview — API помечен как экспериментальный, но на практике оператор стабилен и используется повсеместно.
  • Есть вариант с динамическим таймаутом: debounce { value -> timeoutFor(value) } — таймаут считается для каждого значения отдельно.
  • distinctUntilChanged рядом с debounce — почти обязательная пара: debounce не отсекает повторные значения, а сеть дёргать дважды ради одинакового запроса не хочется.

Типичный debounce для поиска

Значение 300 мс — компромисс между отзывчивостью и числом запросов: пользователь успевает остановиться, а подсказки приходят без заметной глазу задержки. Для тяжёлых запросов ставят больше — до секунды.

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

Контекст и холодность: как операторы ведут себя на деле

Три вещи, которые нужно понимать про операторы в целом, чтобы ответ не рассыпался на уточняющих вопросах.

Первое: операторы холодные, даже если апстрим горячий. Обработка начинается только при сборе — поведение закреплено в документации Flow. Цепочка map(...).filter(...) сама по себе ничего не запускает.

Второе: flowOn меняет контекст только для апстрима. Оператор контекст-сохраняющий: всё, что выше flowOn, выполняется в указанном диспетчере, а collect — в контексте подписчика. Забыли flowOn(Dispatchers.IO) у источника с сетевыми запросами — запросы пойдут в главном потоке, и картинка на экране начнёт дёргаться.

Example.kt
flow { emit(loadFromNetwork()) }   // выполнится в IO
    .map { it.toDomain() }
    .flowOn(Dispatchers.IO)        // контекст апстрима — IO
    .collect { render(it) }        // подписчик — главный поток

Третье: flowOn при смене диспетчера вводит конкурентность. Документация прямо говорит: если диспетчер меняется, flowOn собирает апстрим в отдельной корутине и использует буфер между эмиссией и сбором. Производитель может работать вперёд, пока буфер не заполнен. Сильный ответ на собеседовании: «flowOn со сменой диспетчера неявно добавляет буфер — эмиссия и сбор идут конкурентно».

  • map и filter — базовые трансформации; лямбды suspending; операторы холодные, до collect ничего не делают.
  • zip — строго попарно, завершается с первым завершившимся потоком.
  • combine — пересчёт по последним значениям при любом обновлении любого источника.
  • debounce(300) — гасит шквал и отдаёт последнее; рядом всегда distinctUntilChanged.
  • flowOn — контекст апстрима; collect — контекст подписчика; смена диспетчера добавляет буфер.

Что я смотрю, когда спрашиваю про операторы

Я Рустем Бикбулатов, senior Android-разработчик, провожу технические собеседования и менторю в Яндекс Практикуме — больше сотни учеников и разборов за плечами. Вопрос про операторы Flow я задаю почти каждому кандидату с опытом от года: тема короткая, а по ответу видно, читал ли человек документацию.

Сильный ответ звучит так: перечислил map, filter, combine, zip, debounce; сказал, что операторы холодные и ничего не делают до collect; объяснил разницу combine/zip на примере; упомянул flowOn для контекста апстрима. Слабый — «map преобразует, filter фильтрует» и пауза. Разница — не в объёме выученного, а в системном понимании. Именно его я и закрываю с учениками при подготовке: Kotlin, Coroutines, архитектура — до уровня, когда ответы складываются в картину, а не в список фактов.

Нужна помощь в переходе на middle?

Бесплатная диагностика: разберу ваши пробелы в Coroutines, Flow и архитектуре и составлю план роста за 30–40 минут. Без обязательств — если менторство вам не подойдёт, честно скажу.

Итоги

Операторы Flow — тема, которая проверяется на каждом собеседовании, потому что за ней стоит понимание холодности, контекстов и отмены.

  • Базовые: map и filter — холодные преобразования с suspending-лямбдами.
  • Соединение: combine — состояние из нескольких источников; zip — строгое попарное сопоставление.
  • Тайминг: debounce гасит шквал событий и отдаёт последнее; рядом distinctUntilChanged, а flowOn выносит тяжёлую работу из главного потока.

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