Поколение большого пальца – вот как нас называли. Какие глубокомысленные переписки мы ухитрялись вести на кнопочных телефонах, набирая текст SMS большим пальцем асинхронно с конспектированием лекций…

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

Естественно, в Kotlin Flow, где данные текут непрерывным потоком и легко провоцируют избыточные реакции, эта проблема стоит особенно остро. Например, если мы построим на Flow систему автодополнений, то увидим что-то такое:

Пользователь и поток словно бегут наперегонки.

Порой бывает полезно искусственно сократить отзывчивость системы — не только интерфейса, но и любых потоков данных, — чтобы снизить нагрузку и отсечь мелкие шумы. Эту задачу решают так называемые временные фильтры.

Давайте напишем свою реализацию почти полнотекстового поиска, который установит вид пасущегося на лугу крупного рогатого скота.

С точки зрения системы автодополнений запрос пользователя, введенный в поисковой строке, — это не единая строка, а последовательность строк:

"н" → "на" → "на " → "на л" → ...

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

private val _query = MutableStateFlow("")
val query = _query.asStateFlow()

Теперь добавим реакцию на изменения. Дадим пользователю время подумать над запросом, переформулировать его, исправить опечатки. Как узнать, что он закончил? Проще всего поймать момент паузы в процессе ввода. Для этого применим оператор debounce().

Термин debounce (от bounce — «отскакивать», «дребезжать») пришел из электроники, где он означает способ фильтрации переходных электрических помех во входном сигнале. В Kotlin Flow оператор debounce устраняет «дребезг» потока — короткие, частые изменения, которые следуют одно за другим, и пропускает дальше только устойчивое значение после паузы. Его единственный параметр задает продолжительность этой паузы.

Вот наглядный пример:

В этом примере между первыми тремя элементами были паузы короче 300 мс, поэтому эти значения отброшены. Затем между «на л» и «на лу» произошла пауза длиной 500 мс — достаточно длинная, чтобы оператор debounce(300) решил, что ввод завершен, и выпустил значение «на л».

Однако пользователь продолжил печатать, и появилось новое значение «на лу». После него снова последовала пауза длиной 500 мс, поэтому и оно прошло фильтр.

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

Итак, мы написали:

query.debounce(300)

А дальше следует страшный сон программиста – все идет насмарку. Оператор debounce() возвращает холодный поток Flow, что сведет на нет все преимущества использования StateFlow. Поэтому придется подогреть поток, используя оператор stateIn().

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

class SearchViewModel : ViewModel() {

    private val phrases = listOf(
        "на лугу пасутся козы",
        "на лугу пасутся кони",
        "на лугу пасутся коровы"
    )

    private val _query = MutableStateFlow("")
    val query = _query.asStateFlow()

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

    val results: StateFlow<List<String>> =
        query
            .debounce(300) 
            .map { term ->
                delay(200) 
                if (term.isEmpty()) emptyList()
                else phrases.filter { it.contains(term, ignoreCase = true) }
            }
            .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), emptyList())
}

В UI нам остается только прокинуть все состояния из ViewModel и не забыть повесить наш метод onQueryChange() на изменения текстового поля.

@Composable
fun SearchScreen(vm: SearchViewModel) {
    val query by vm.query.collectAsState()
    val results by vm.results.collectAsState()
    var tf by remember { mutableStateOf(TextFieldValue(query)) }

    Column(Modifier.fillMaxSize().padding(16.dp)) {
        OutlinedTextField(
            value = tf,
            onValueChange = {
                tf = it
                vm.onQueryChange(it.text)
            },
            modifier = Modifier.fillMaxWidth(),
            singleLine = true,
            placeholder = { Text("Начните ввод...") }
        )
        Spacer(Modifier.height(16.dp))

        LazyColumn {
            items(results) { phrase ->
                Text(phrase, Modifier.padding(vertical = 4.dp))
                HorizontalDivider(Modifier, DividerDefaults.Thickness, DividerDefaults.color)
            }
        }
    }
}

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            MaterialTheme(colorScheme = lightColorScheme()) {
                val vm: SearchViewModel = viewModel()
                SearchScreen(vm)
            }
        }
    }
}

Оператор debounce имеет много сигнатур:

Мы можем задать не фиксированную задержку, а динамическую — с помощью лямбда-функции, передаваемой в качестве аргумента функции debounce(). Тогда таймаут будет вычислен индивидуально для каждого значения:

query.debounce { term ->
    if (term.length < 2) 0L else 300L
}

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

query.filter { it.length >= 2 }

Возможна и другая стратегия ограничения потока по времени – не ждать естественной паузы, а просто проверять состояние через равные интервалы. Замените debounce() на sample():

query.sample(300)

Эта стратегия подходит для потоков, где значение меняется непрерывно: положение скролла, показания сенсора, прогресс загрузки и т.д.

Иногда значения меняются так часто, что часть из них на самом деле не несет новой информации. Например, пользователь может стереть букву, передумать и тут же напечатать ее заново.

Чтобы не тратить время на обработку дубликатов, используется оператор distinctUntilChanged(). Он пропускает дальше только новое значение, отличающееся от предыдущего. Если два подряд элемента совпадают — второй просто отбрасывается:

val results: StateFlow<List<String>> =
    query
        .debounce(300)
        .distinctUntilChanged() // игнорируем одинаковые запросы подряд
        .map { term ->
            delay(200)
            if (term.isEmpty()) emptyList()
            else phrases.filter { it.contains(term, ignoreCase = true) }
        }
        .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), emptyList())

У оператора distinctUntilChanged тоже много вариантов сигнатуры:

Во второй сигнатуре появляется параметр с говорящим названием areEquivalent. Это лямбда-функция с двумя аргументами, которая возвращает true, если предыдущий и текущий элементы эквивалентны. Таким образом можно задать собственное правило сравнения.

Например, давайте не будем перезапускать поиск автодополнений, если пользователь просто добавил в конце запроса пробел:

.distinctUntilChanged { old, new ->
    old.trim() == new.trim()
}

Если элемент потока является сложным объектом, удобнее использовать оператор distinctUntilChangedBy. Он принимает селектор ключа keySelector — лямбду, которая выбирает, по какому свойству сравнивать элементы:

data class SearchQuery(val text: String, val region: Int)
…
query.distinctUntilChangedBy { it.text }

В этом примере поисковый запрос содержит и текст, и регион, но нас интересует только изменение текста. Пока text остается тем же самым, поток не будет перезапускать поиск, даже если изменился регион.

Итак, мы рассмотрели несколько операторов Kotlin Flow, которые помогают укротить поток, не позволяя ему состязаться с пользователем в скорости. Если вы хотите прочитать о других подобных операторах или больше узнать о Flow, то добро пожаловать на мой курс о применении Flow в Android-разработке.

Комментарии (0)