debounce, distinctUntilChanged и filter: три оператора, без которых не обходится ни один поиск

Представьте: пользователь открывает строку поиска и начинает набирать запрос. Каждый символ порождает новое значение в потоке:

"м" → "мо" → "мос" → "моск" → "москв" → "москва"

Если реагировать на каждый символ, приложение отправит шесть запросов к серверу за полторы секунды. Пять из них бессмысленны: пользователь ещё не закончил печатать. Сервер нагружен, батарея садится, а интерфейс дёргается, показывая промежуточные результаты, которые тут же устаревают.

Kotlin Flow предлагает набор операторов, которые превращают хаотичный поток в осмысленный сигнал. Три из них — debounce(), distinctUntilChanged() и filter() — работают в связке и решают проблему «слишком отзывчивого приложения».

debounce: подожди, пока пользователь замолчит

Термин пришёл из электроники. Когда вы нажимаете кнопку, контакт не замыкается чисто — он «дребезжит», быстро замыкаясь и размыкаясь несколько раз за миллисекунды. Схема debounce фильтрует этот дребезг и фиксирует одно устойчивое нажатие.

В Kotlin Flow оператор debounce() делает то же самое с потоком данных. Он пропускает значение дальше, только если после него наступила тишина — пауза заданной длительности.

private val _query = MutableStateFlow("")

val searchResults: StateFlow<List<String>> = _query
    .debounce(300)
    .map { term -> performSearch(term) }
    .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), emptyList())

Что здесь происходит: пользователь печатает «москва». Каждый символ обновляет _query.value. Оператор debounce(300) запускает таймер на 300 миллисекунд. Если за это время приходит новый символ — таймер сбрасывается. И только когда пользователь остановился на 300 мс, последнее значение проходит дальше.

Вместо шести запросов — один. Вместо дёргающегося интерфейса — плавное появление результатов.

Задержку в 300 мс не стоит считать догмой. Для поиска по локальной базе данных хватит 150—200 мс. Для сетевых запросов лучше дать 300—500 мс. А для операций, где пользователь вводит длинный текст (адрес, описание), можно увеличить до 500—800 мс.

Кстати, debounce() принимает не только число, но и лямбду, которая вычисляет задержку для каждого значения:

.debounce { term ->
    if (term.length < 3) 500L else 300L
}

Короткие запросы дают больше вариантов — даём пользователю больше времени подумать. Длинные запросы уже достаточно конкретны — реагируем быстрее.

distinctUntilChanged: не повторяйся

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

Оператор distinctUntilChanged() решает эту проблему. Он сравнивает текущее значение с предыдущим и отбрасывает дубликаты:

_query
    .debounce(300)
    .distinctUntilChanged()
    .map { term -> performSearch(term) }

Теперь, если после debounce значение оказалось таким же, как предыдущее, — оно молча проглатывается. Никакого лишнего запроса.

Сравнение по умолчанию использует equals(). Но что, если пользователь добавил пробел в конце строки? Технически это другое значение, хотя для поиска разницы нет. Для таких случаев у оператора есть версия с кастомным компаратором:

_query
    .debounce(300)
    .distinctUntilChanged()
    .map { term -> performSearch(term) }

Есть и оператор distinctUntilChangedBy(), который позволяет сравнивать объекты по конкретному полю:

data class SearchRequest(val text: String, val page: Int)

requestFlow.distinctUntilChangedBy { it.text }
// игнорирует изменение page, если text не изменился

filter: отсекай бессмысленное

Нужно ли вообще искать по запросу из одного символа? Скорее всего, нет — результатов будет слишком много, и ни один из них не окажется полезным. Оператор filter() отсекает значения, не прошедшие проверку:

_query
    .debounce(300)
    .distinctUntilChanged()
    .filter { it.length >= 2 }
    .map { term -> performSearch(term) }

Теперь поиск запускается только при длине запроса от двух символов. Один символ — молчание.

Можно пойти дальше и отсечь пустые строки отдельно, чтобы при очистке поля возвращать пустой результат мгновенно, без задержки debounce:

_query
    .debounce { term ->
        if (term.isEmpty()) 0L else 300L
    }
    .distinctUntilChanged()
    .map { term ->
        if (term.length < 2) emptyList()
        else performSearch(term)
    }

Каноничная тройка

В большинстве реальных проектов эти три оператора стоят рядом:

query
    .debounce(300)                // ждём паузу в вводе
    .distinctUntilChanged()       // игнорируем повторы
    .filter { it.isNotBlank() }   // отсекаем пустые строки

Порядок имеет значение. debounce первым снижает частоту потока. distinctUntilChanged вторым убирает дубликаты. filter третьим отбрасывает бессмысленные значения. Если поменять порядок — например, поставить filter перед debounce, — вы можете получить неожиданное поведение: пустая строка пролетит через debounce и запустит лишнюю обработку.


Три оператора — debounce, distinctUntilChanged, filter — покрывают типичный сценарий поисковой строки. Но что делать, если сам поиск занимает время? Если пользователь успел набрать новый запрос, пока старый ещё обрабатывается? Для этого существует семейство операторов *LatestmapLatest, transformLatest, flatMapLatest, — которые умеют отменять устаревшие вычисления. Но это тема для следующей статьи.