> Как реализовать debounce для поиска с корутинами в Kotlin (Kotlin, Android)

Уровень: senior · Роль: mobile · Категория: Технические вопросы

Компании: Wildberries

Стек: Kotlin, Android

> Пример ответа

Короткий ответ

Debounce в корутинах реализуется через Flow.debounce() или ручной отменой Job в launch. Для поиска оптимально использовать SnapshotFlow или callbackFlow с debounce, чтобы отменять предыдущий запрос при новом вводе. Ключевой момент - отмена предыдущей корутины через Job.cancel() или оператор debounce на потоке, который фильтрует слишком частые события. Это предотвращает лишние сетевые вызовы и гонки состояний.

Подробное объяснение

Debounce - это техника, которая откладывает выполнение действия до тех пор, пока не пройдёт заданный интервал после последнего события. В контексте поиска это означает: пользователь вводит текст, мы ждём паузу (например, 300-500 мс), и только потом отправляем запрос.

В Kotlin с корутинами есть два основных подхода:

  1. Через Flow.debounce() - оператор, который работает на холодных или горячих потоках. Он пропускает значения, если между ними меньше указанного времени. Это декларативный способ, который хорошо сочетается с flatMapLatest для отмены предыдущего запроса.

  2. Через ручное управление Job - в launch мы сохраняем ссылку на текущую корутину и отменяем её при новом событии. Это более императивный подход, который даёт больше контроля, но требует аккуратности с жизненным циклом.

Для Android-разработки важно учитывать жизненный цикл ViewModel: использовать viewModelScope и отменять всё при onCleared(). Также нужно обрабатывать гонки: если старый запрос вернулся после нового, его результат не должен перезаписать актуальные данные.

На практике

В реальном проекте чаще всего используют MutableStateFlow для текста поиска и debounce в комбинации с flatMapLatest. Это даёт:

  • автоматическую отмену предыдущего запроса;
  • обработку только последнего введённого значения;
  • простую интеграцию с stateIn для UI-состояния.

Для поиска с сервером добавляют distinctUntilChanged, чтобы не отправлять одинаковые запросы, и обработку ошибок через catch или retry. Если нужен дебаунс только для первого символа (например, чтобы не искать по одному символу), можно добавить фильтр по длине строки.

Пример кода

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

    private val _query = MutableStateFlow("")
    val query: StateFlow<String> = _query.asStateFlow()

    val searchResults: StateFlow<UiState<List<Result>>> = query
        .debounce(300)
        .filter { it.length >= 3 }
        .distinctUntilChanged()
        .flatMapLatest { text ->
            flow {
                emit(UiState.Loading)
                emit(UiState.Success(repository.search(text)))
            }.catch { emit(UiState.Error(it.message ?: "Unknown error")) }
        }
        .stateIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(5000),
            initialValue = UiState.Idle
        )

    fun onQueryChange(newQuery: String) {
        _query.value = newQuery
    }
}

Альтернативный вариант с ручной отменой:

private var searchJob: Job? = null

fun onQueryChange(newQuery: String) {
    searchJob?.cancel()
    searchJob = viewModelScope.launch {
        delay(300)
        if (newQuery.length >= 3) {
            _uiState.value = UiState.Loading
            _uiState.value = try {
                UiState.Success(repository.search(newQuery))
            } catch (e: Exception) {
                UiState.Error(e.message ?: "Unknown error")
            }
        }
    }
}

Как отвечать на собеседовании

Начните с определения debounce и зачем он нужен. Затем назовите два основных подхода: Flow.debounce и ручная отмена Job. Объясните, почему flatMapLatest - лучший выбор для поиска: он автоматически отменяет предыдущий запрос. Упомяните viewModelScope и отмену при очистке. Если спросят про гонки - скажите, что flatMapLatest решает эту проблему. Для senior-уровня добавьте про SharingStarted и stateIn, а также про обработку ошибок и retry при сетевых сбоях.

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

Интервьюер оценивает:

  • понимание корутин и Flow, а не только заученные API;
  • умение работать с жизненным циклом и отменой;
  • знание операторов debounce, flatMapLatest, distinctUntilChanged;
  • способность объяснить trade-off между декларативным и императивным подходом;
  • понимание гонок и способов их избежать.

Типичные ошибки

  • Использование collect внутри launch без отмены предыдущей корутины - приводит к гонкам.
  • Забывают про distinctUntilChanged - отправляются повторные запросы.
  • Не обрабатывают ошибки в flow - приложение падает.
  • Используют debounce на холодном flow без stateIn - поток пересоздаётся при каждой подписке.
  • Не учитывают жизненный цикл: корутина продолжает работать после onCleared.
  • Применяют delay в launch без сохранения Job - дебаунс не работает корректно.

> ГОТОВЫ К СЛЕДУЮЩЕМУ СОБЕСЕДОВАНИЮ?

Запустите тренировочную сессию с ИИ и получите детальную обратную связь, чтобы увереннее проходить реальные интервью