Представьте: пользователь открывает строку поиска и начинает набирать запрос. Каждый символ порождает новое значение в потоке:
"м" → "мо" → "мос" → "моск" → "москв" → "москва"
Если реагировать на каждый символ, приложение отправит шесть запросов к серверу за полторы секунды. Пять из них бессмысленны: пользователь ещё не закончил печатать. Сервер нагружен, батарея садится, а интерфейс дёргается, показывая промежуточные результаты, которые тут же устаревают.
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 — покрывают типичный сценарий поисковой строки. Но что делать, если сам поиск занимает время? Если пользователь успел набрать новый запрос, пока старый ещё обрабатывается? Для этого существует семейство операторов *Latest — mapLatest, transformLatest, flatMapLatest, — которые умеют отменять устаревшие вычисления. Но это тема для следующей статьи.