Поиск: пауза в наборе и пять состояний выдачи
Зачем это нужно
«Создайте компонент Поиск» — и рядом, в списке запросов к серверу, отдельной строкой: «Сделан запрос Поиск». Это один пункт критериев с двух сторон: компонент и то, что он вызывает.
Поиск выглядит как поле ввода с иконкой, а внутри — три вещи, которых в обычном поле нет: пауза перед отправкой, пять состояний выдачи вместо одного списка и гонка ответов, из-за которой на экране оказывается выдача по устаревшему запросу.
Весь код собран настоящим компилятором; сообщения об ошибках — его вывод.
Не на каждый символ
Прямое решение — отправлять запрос из onValueChange — даёт семь запросов на слово «ноутбук». Это не только трафик: выдача под пальцами мигает и перестраивается на каждую букву, и попасть по нужной строке невозможно.
Правильное поведение: подождать, пока человек перестанет печатать.
LaunchedEffect(query) {
if (query.length < minLength) return@LaunchedEffect
delay(debounceMillis)
onSearch(query)
}
Пауза здесь бесплатная — не нужно ни таймеров, ни отмены руками. Всю работу делает ключ у LaunchedEffect: сменился query — прошлая корутина отменена вместе со своим delay. Именно поэтому приём в Compose выглядит проще, чем в любой другой среде.
Проверим саму отмену на исполняемом примере — без Compose, на чистом Kotlin:
import kotlinx.coroutines.*
fun main() = runBlocking {
var otpravleno = 0
var rabota: Job? = null
for (q in listOf("н", "но", "ноу", "ноут", "ноутб", "ноутбу", "ноутбук")) {
rabota?.cancel() // так делает LaunchedEffect при смене ключа
rabota = launch {
delay(400)
otpravleno++
println("запрос: " + q)
}
delay(80) // человек печатает быстрее паузы
}
rabota?.join()
println("всего запросов: " + otpravleno)
}
Ключ обязателен
@Composable fun P(q: String) { LaunchedEffect { println(q) } }
e: Probe.kt:4:32 'fun LaunchedEffect(block: suspend CoroutineScope.() -> Unit): Unit' is deprecated.
LaunchedEffect must provide one or more 'key' parameters that define the identity of the
LaunchedEffect and determine when its previous effect coroutine should be cancelled and a new
effect launched for the new key.
Редкий случай, когда сообщение объясняет замысел целиком: ключ определяет, когда прошлую корутину отменить и запустить новую. Без ключа неясно, когда работу перезапускать, — поэтому вариант без ключей помечен устаревшим.
А вот попытка подождать прямо в разметке:
@Composable fun P() { delay(400); Text("готово") }
e: Probe.kt:5:23 Suspend function 'suspend fun delay(timeMillis: Long): Unit' should be called only from a coroutine or another suspend function.
То же сообщение, что в прошлой главе про hide(). Разметка не ждёт — ждут корутины.
Пять состояний, а не список
Вторая половина компонента — то, что под полем. Подставить туда просто список значит потерять четыре состояния из пяти:
sealed interface SearchState<out T> {
data object Idle : SearchState<Nothing>
data object Loading : SearchState<Nothing>
data object Empty : SearchState<Nothing>
data class Found<T>(val items: List<T>) : SearchState<T>
data class Failed(val message: String) : SearchState<Nothing>
}
Компилятор следит, чтобы ни одно состояние не забыли:
sealed interface S { data object A : S; data object B : S }
fun f(s: S): String = when (s) { is S.A -> "a" }
e: Probe.kt:3:23 'when' expression must be exhaustive. Add the 'B' branch or an 'else' branch.
Здесь стоит не писать else. Добавили шестое состояние — и компилятор пройдёт по всем местам, где его забыли обработать. С веткой else он промолчит, а пользователь увидит пустой экран. Это и есть главная причина брать закрытый набор, а не строку со статусом.
Разметка получается прямым перечислением:
when (state) {
is SearchState.Idle -> Text("Начните вводить название")
is SearchState.Loading -> Text("Ищем…")
is SearchState.Empty -> Text("Ничего не нашлось. Попробуйте другое слово")
is SearchState.Failed -> Text(state.message)
is SearchState.Found -> Column { state.items.forEach { item(it) } }
}
Различать «нашлось пусто» и «не смогли спросить» обязательно. Первое — нормальный результат, и человеку надо предложить изменить запрос. Второе — отказ, и там нужна кнопка «Повторить». Экран «ничего не нашлось» при выключенном интернете — классическая претензия на приёмке.
Гонка ответов
Даже с паузой запросов может уйти несколько: человек набрал «ноут», задумался на полсекунды, дописал «бук». Ушло два запроса. Ответы вернутся в произвольном порядке — короткий может обогнать длинный, потому что по нему меньше данных.
Результат: в поле «ноутбук», а на экране выдача по «ноут».
Лечение в Compose встроено и уже написано: LaunchedEffect(query) при смене запроса отменяет прошлую корутину. Отменённая до onSearch не дойдёт — устаревшему ответу просто неоткуда взяться.
Это одно из мест, где Compose заметно проще предшественников. В обычном коде от гонки защищаются номером запроса: запомнили последний, при ответе сверили и выбросили чужой. Здесь достаточно указать ключ — остальное делает отмена.
Компонент целиком
@Composable
fun KitSearchField(
query: String,
onQueryChange: (String) -> Unit,
onSearch: (String) -> Unit,
modifier: Modifier = Modifier,
label: String = "Поиск",
placeholder: String = "Название товара",
minLength: Int = 3,
debounceMillis: Long = 400,
) {
LaunchedEffect(query) {
if (query.length < minLength) return@LaunchedEffect
delay(debounceMillis)
onSearch(query)
}
KitTextField(
value = query,
onValueChange = onQueryChange,
label = label,
modifier = modifier,
placeholder = placeholder,
imeAction = ImeAction.Search,
trailing = if (query.isNotEmpty()) {
{ KitButtonGhost(text = "Очистить", onClick = { onQueryChange("") }) }
} else {
null
},
)
}
Три мелочи, которые оценивают:
ImeAction.Search— на клавиатуре появляется лупа вместо «Далее»;- кнопка очистки показывается только когда есть что очищать;
onQueryChangeиonSearch— разные параметры: первый срабатывает на каждый символ (поле должно печататься), второй — после паузы.
Разделение этих двух обработчиков — главное в сигнатуре. Попытка обойтись одним приводит либо к запросу на каждый символ, либо к полю, которое не печатается, пока идёт пауза.
Типичные ошибки
Ошибка 1. Запрос прямо из onValueChange.
Разобрано: семь запросов на слово.
Ошибка 2. Пауза руками, через таймер.
Заводят Handler, Timer или свою корутину и отменяют её вручную. Работает, но LaunchedEffect с ключом делает это надёжнее и без утечек: корутина отменится и при уходе с экрана.
Ошибка 3. Слишком длинная пауза.
Секунда и больше воспринимается как «приложение задумалось». Рабочий диапазон — 300–500 мс.
Ошибка 4. Нет состояния «ищем».
Между вводом и ответом экран остаётся прежним, и человек набирает снова, решив, что не сработало.
Ошибка 5. Кнопка очистки всегда видна.
У пустого поля очищать нечего — крестик на пустом поле выглядит недоделкой.
Ошибка 6. Поиск по нажатию кнопки и по паузе одновременно.
Оба пути вызывают onSearch, запросы дублируются. Если по макету есть кнопка — либо она, либо пауза.
Шпаргалка по главе
| Что нужно | Как |
|---|---|
| Пауза перед запросом | LaunchedEffect(query) { delay(400); onSearch(query) } |
| Порог длины | if (query.length < 3) return@LaunchedEffect |
| Защита от гонки | она же: смена ключа отменяет прошлую корутину |
| Лупа на клавиатуре | imeAction = ImeAction.Search |
| Кнопка очистки | показывать только при query.isNotEmpty() |
| Состояния выдачи | sealed interface с пятью наследниками |
| Полнота разбора | when без else — компилятор проверит |
| Два обработчика | onQueryChange на символ, onSearch после паузы |
Слова главы
| Термин | Что это |
|---|---|
| Пауза в наборе | Задержка перед отправкой; ждём, пока человек перестанет печатать |
| Ключ эффекта | Значение, при смене которого прошлая корутина отменяется |
| Гонка ответов | Ответы приходят не в том порядке, в каком уходили запросы |
| Закрытый набор | sealed interface: все наследники известны компилятору |
data object | Состояние без данных; экземпляр один на всю программу |
| Полнота разбора | Требование обработать все ветки when без else |
Цель: скорость ≥ 95 зн/мин, точность ≥ 90%
LaunchedEffect(query) {
Что должен уметь после главы
- объяснить, почему запрос не отправляют из
onValueChange; - сделать паузу через
LaunchedEffectс ключом и объяснить, что делает ключ; - объяснить гонку ответов и почему отмена решает её сама;
- перечислить пять состояний выдачи и различить
EmptyиFailed; - обосновать отказ от
elseв разборе закрытого набора; - назвать три мелочи поля поиска, которые оценивают на приёмке.
Проверка по главе
Сводная проверка по всей главе: 10 вопросов, 5:00 на всё, без подсказок по ходу. Вопросы показываются по одному, ответ изменить нельзя. Если время выйдет — экзамен завершится с тем, что успел ответить. Пересдавать можно сколько угодно раз.
Куда дальше: проверенные ресурсы
- Побочные эффекты в Compose —
LaunchedEffect, ключи и отмена. - Отмена корутин — что происходит с
delayпри отмене. - Закрытые классы и интерфейсы — почему
whenпо ним проверяется на полноту. - Search в Material 3 — готовая строка поиска и её состояния.
Комментарии
Обсуждение живёт в GitHub Discussions и грузится с чужого домена — поэтому только по нажатию.