Перейти к основному содержимому
Мобилка22ГЛАВА 22Поиск: пауза в наборе и пять состояний выдачиноутбук×пауза 400 мсIdleLoadingFoundEmptyFailedодин запрос вместо семи

Поиск: пауза в наборе и пять состояний выдачи

Прочитано0%
Квизы0/4
Тренажёрыне пройден0/1
Уровень 1 · Новичок

Зачем это нужно​

«Создайте компонент Поиск» — и рядом, в списке запросов к серверу, отдельной строкой: «Сделан запрос Поиск». Это один пункт критериев с двух сторон: компонент и то, что он вызывает.

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

Весь код собран настоящим компилятором; сообщения об ошибках — его вывод.

Не на каждый символ​

ЧЕЛОВЕК НАБРАЛ «НОУТБУК» — СКОЛЬКО ЗАПРОСОВ УШЛОНа каждый символнноноуноутноутбноутбуноутбук7 запросов на одно словоПосле паузы в наборен, но, ноу… — тихопауза 400 мс → «ноутбук»1 запроскаждая лишняя отправка — это и трафик, и нагрузка, и мигающая выдача под пальцамиплюс порог длины: по одной букве искать нечего, запрос всё равно вернёт полкаталога
Семь запросов на одно слово против одного

Прямое решение — отправлять запрос из 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(). Разметка не ждёт — ждут корутины.

Отвечено вопросов: 0 из 3
Почему нельзя отправлять запрос прямо из onValueChange?
Что делает ключ у LaunchedEffect?
Зачем порог минимальной длины запроса?
Отвечено верно: 0 из 3

Пять состояний, а не список​

ПОД ПОЛЕМ ПОИСКА НЕ ОДИН СПИСОК, А ПЯТЬ СОСТОЯНИЙIdleНачните вводить названиеещё ничего не набралиLoadingИщем…запрос ушёл, ответа нетFoundсписок товаровнашлосьEmptyНичего не нашлось. Попробуйте другое словоответ пришёл, он пустFailedНет сети. Повторитьответа не будет«нашлось пусто» и «не смогли спросить» — разные вещи, и человеку нужны разные слова
Под полем поиска пять разных экранов

Вторая половина компонента — то, что под полем. Подставить туда просто список значит потерять четыре состояния из пяти:

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) } }
}
Интересный факт

Различать «нашлось пусто» и «не смогли спросить» обязательно. Первое — нормальный результат, и человеку надо предложить изменить запрос. Второе — отказ, и там нужна кнопка «Повторить». Экран «ничего не нашлось» при выключенном интернете — классическая претензия на приёмке.

Гонка ответов​

ОТВЕТЫ ПРИХОДЯТ НЕ В ТОМ ПОРЯДКЕ, В КАКОМ СПРАШИВАЛИзапрос «ноут»ответ пришёл вторымзапрос «ноутбук»ответ пришёл первымв поле «ноутбук», а на экране выдача по «ноут» — она приехала позже и всё затёрлалечение встроено: LaunchedEffect с ключом отменяет прошлую работу при смене запросаотменённая корутина до onSearch уже не дойдёт — устаревшему ответу неоткуда взятьсяименно поэтому ключ обязателен, и без него функция помечена устаревшей
Ответ на «ноут» приехал позже и затёр выдачу по «ноутбук»

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

Результат: в поле «ноутбук», а на экране выдача по «ноут».

Лечение в 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 — разные параметры: первый срабатывает на каждый символ (поле должно печататься), второй — после паузы.
Важно

Разделение этих двух обработчиков — главное в сигнатуре. Попытка обойтись одним приводит либо к запросу на каждый символ, либо к полю, которое не печатается, пока идёт пауза.

Отвечено вопросов: 0 из 3
Почему в закрытом наборе состояний не стоит писать else?
Чем отличается Empty от Failed?
Зачем onQueryChange и onSearch — разные параметры?
Отвечено верно: 0 из 3

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

Ошибка 1. Запрос прямо из onValueChange.

Разобрано: семь запросов на слово.

Ошибка 2. Пауза руками, через таймер.

Заводят Handler, Timer или свою корутину и отменяют её вручную. Работает, но LaunchedEffect с ключом делает это надёжнее и без утечек: корутина отменится и при уходе с экрана.

Ошибка 3. Слишком длинная пауза.

Секунда и больше воспринимается как «приложение задумалось». Рабочий диапазон — 300–500 мс.

Ошибка 4. Нет состояния «ищем».

Между вводом и ответом экран остаётся прежним, и человек набирает снова, решив, что не сработало.

Ошибка 5. Кнопка очистки всегда видна.

У пустого поля очищать нечего — крестик на пустом поле выглядит недоделкой.

Ошибка 6. Поиск по нажатию кнопки и по паузе одновременно.

Оба пути вызывают onSearch, запросы дублируются. Если по макету есть кнопка — либо она, либо пауза.

Отвечено вопросов: 0 из 3
Какая пауза перед отправкой разумна?
Чем LaunchedEffect лучше своего таймера?
Между вводом и ответом экран не меняется. Чем это плохо?
Отвечено верно: 0 из 3

Шпаргалка по главе​

Что нужноКак
Пауза перед запросом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 на всё, без подсказок по ходу. Вопросы показываются по одному, ответ изменить нельзя. Если время выйдет — экзамен завершится с тем, что успел ответить. Пересдавать можно сколько угодно раз.

Куда дальше: проверенные ресурсы​

Видео по теме

ВидеоThe BEST Way to Implement a Search In Jetpack Compose - Android Studio TutorialPhilipp Lackner
ВидеоFull Guide to Jetpack Compose Effect HandlersPhilipp Lackner
Видео#9 - Kotlin Flow debounce: Stop Firing 5 Requests for One Search | Flow & StateAndroid Atlas
ВидеоKotlin Flows: Buffer, Conflate, and DebounceDave Leeds
ВидеоLaunchedEffect in Composable | Side Effects - Android SideEffects | Jetpack Compose [Kotlin]- 43Codetutor

Комментарии

Обсуждение живёт в GitHub Discussions и грузится с чужого домена — поэтому только по нажатию.