Kotlin Android дебаунс

Есть ли какой-нибудь необычный способ реализовать логику debounce с Kotlin Android?

Я не использую Rx в проекте.

В Ява есть путь, но он мне здесь великоват.

Вы ищете решение на основе сопрограмм?

Alexey Romanov 14.06.2018 15:50

@AlexeyRomanov Да, как я понял, было бы очень эффективно.

Kyryl Zotov 14.06.2018 15:56

Возможный дубликат Дросселирование onQueryTextChange в SearchView

EpicPandaForce 14.06.2018 16:22
stackoverflow.com/a/50007453/2413303 у этого парня был обычный ответ на вопрос, который я пометил как повторяющийся.
EpicPandaForce 14.06.2018 18:17

@EpicPandaForce Попробовал бы и обновил, но вопрос кажется другим.

Kyryl Zotov 14.06.2018 18:20
33
5
23 502
10
Перейти к ответу Данный вопрос помечен как решенный

Ответы 10

Ответ принят как подходящий

Для этого вы можете использовать котлин сопрограммы. Вот пример.

Имейте в виду, что сопрограммы - это экспериментальный на Котлине 1.1+, и он может быть изменен в следующих версиях kotlin.

ОБНОВИТЬ

Начиная с выпуска Котлин 1.3, сопрограммы теперь стабильны.

К сожалению, теперь, когда была выпущена версия 1.3.x, это кажется устаревшим.

Joshua King 20.11.2018 03:47

@JoshuaKing, да. Может, medium.com/@pro100svitlo/… поможет. Попробую позже.

CoolMind 10.12.2018 08:24

Спасибо! Потому что это очень полезно, но мне нужно обновить. Спасибо.

Joshua King 10.12.2018 17:51

Вы уверены, что каналы считаются стабильными?

EpicPandaForce 19.12.2018 17:14

Любопытно: почему почти все решения предлагают использование сопрограммы, заставляя среди остальных добавлять определенную зависимость (вопрос не говорит, что сопрограммы уже используются)? Разве для такой простой операции не добавляются лишние накладные расходы? Не лучше ли использовать System.currentTimeMillis() или подобное?

Mabsten 23.05.2021 22:08

Благодаря https://medium.com/@pro100svitlo/edittext-debounce-with-kotlin-coroutines-fd134d54f4e9 и https://stackoverflow.com/a/50007453/2914140 я написал этот код:

private var textChangedJob: Job? = null
private lateinit var textListener: TextWatcher

override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?,
                          savedInstanceState: Bundle?): View? {

    textListener = object : TextWatcher {
        private var searchFor = "" // Or view.editText.text.toString()

        override fun afterTextChanged(s: Editable?) {}

        override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}

        override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {
            val searchText = s.toString().trim()
            if (searchText != searchFor) {
                searchFor = searchText

                textChangedJob?.cancel()
                textChangedJob = launch(Dispatchers.Main) {
                    delay(500L)
                    if (searchText == searchFor) {
                        loadList(searchText)
                    }
                }
            }
        }
    }
}

override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
    super.onViewCreated(view, savedInstanceState)

    editText.setText("")
    loadList("")
}


override fun onResume() {
    super.onResume()
    editText.addTextChangedListener(textListener)
}

override fun onPause() {
    editText.removeTextChangedListener(textListener)
    super.onPause()
}


override fun onDestroy() {
    textChangedJob?.cancel()
    super.onDestroy()
}

Я не включил сюда coroutineContext, поэтому он, вероятно, не будет работать, если не будет установлен. Для получения информации см. Переход на сопрограммы Kotlin в Android с помощью Kotlin 1.3.

Извините, я понял, что если мы запустим несколько запросов, они вернутся асинхронно. Таким образом, не гарантируется, что последний запрос вернет последние данные, и вы обновите представление с правильными данными.

CoolMind 27.03.2019 11:55

Также, как я понял, textChangedJob?.cancel() не отменяет запрос в Retrofit. Итак, будьте готовы к тому, что вы получите все ответы на все запросы в случайной последовательности.

CoolMind 06.06.2019 09:31

Возможно, поможет новая версия Retrofit (2.6.0).

CoolMind 06.06.2019 09:41

Более простое и универсальное решение - использовать функцию, которая возвращает функцию, которая выполняет логику противодействия, и сохраняет ее в val.

fun <T> debounce(delayMs: Long = 500L,
                   coroutineContext: CoroutineContext,
                   f: (T) -> Unit): (T) -> Unit {
    var debounceJob: Job? = null
    return { param: T ->
        if (debounceJob?.isCompleted != false) {
            debounceJob = CoroutineScope(coroutineContext).launch {
                delay(delayMs)
                f(param)
            }
        }
    }
}

Теперь его можно использовать с:

val handleClickEventsDebounced = debounce<Unit>(500, coroutineContext) {
    doStuff()
}

fun initViews() {
   myButton.setOnClickListener { handleClickEventsDebounced(Unit) }
}

Я следую вашей логике при написании кода отказа. Однако в моем случае doStuff () принимает параметры. Есть ли способ передать параметры при вызове handleClickEventsDebounced, который затем передается в doStuff ()?

tech_human 16.11.2019 00:56

Конечно, этот фрагмент поддерживает это. В приведенном выше примере handleClickEventsDebounced(Unit)Unit - это параметр. Это может быть любой тип, который вам нужен, поскольку мы используем дженерики. Для строки, например, сделайте следующее: val handleClickEventsDebounced = debounce <String> = debounce <String> (500, coroutineContext) {doStuff (it)} Где 'it' - переданная строка. Или назовите его {myString -> doStuff (myString)}

Patrick 20.11.2019 19:05

Привет, @Patrick, можешь мне помочь: stackoverflow.com/q/59413001/1423773? Бьюсь об заклад, в нем отсутствует мелкая деталь, но я не могу ее найти.

GuilhE 19.12.2019 17:24

Это напомнило мне о реализации Underscore в JavaScript, которая мне очень нравится за простоту. Поздравления и спасибо!

Machado 19.04.2020 09:06

Эта реализация не дребезжит, а ограничивает.

Simon 09.03.2021 20:13

Я создал одну функцию расширения из старых ответов о переполнении стека:

fun View.clickWithDebounce(debounceTime: Long = 600L, action: () -> Unit) {
    this.setOnClickListener(object : View.OnClickListener {
        private var lastClickTime: Long = 0

        override fun onClick(v: View) {
            if (SystemClock.elapsedRealtime() - lastClickTime < debounceTime) return
            else action()

            lastClickTime = SystemClock.elapsedRealtime()
        }
    })
}

Просмотрите onClick, используя приведенный ниже код:

buttonShare.clickWithDebounce { 
   // Do anything you want
}

Да! Я не знаю, почему так много ответов усложняют это с помощью сопрограмм.

Tenfour04 05.03.2021 14:47

Я создал суть с тремя операторами противодействия, вдохновленный это элегантное решение из Патрик, где я добавил еще два похожих случая: throttleFirst и throttleLatest. Оба они очень похожи на свои аналоги RxJava (дроссель, дроссельная заслонка).

throttleLatest работает аналогично debounce, но работает с временными интервалами и возвращает последние данные для каждого из них, что позволяет вам получать и обрабатывать промежуточные данные, если это необходимо.

fun <T> throttleLatest(
    intervalMs: Long = 300L,
    coroutineScope: CoroutineScope,
    destinationFunction: (T) -> Unit
): (T) -> Unit {
    var throttleJob: Job? = null
    var latestParam: T
    return { param: T ->
        latestParam = param
        if (throttleJob?.isCompleted != false) {
            throttleJob = coroutineScope.launch {
                delay(intervalMs)
                latestParam.let(destinationFunction)
            }
        }
    }
}

throttleFirst полезен, когда вам нужно сразу обработать первый вызов, а затем пропустить последующие вызовы на некоторое время, чтобы избежать нежелательного поведения (например, избегайте запуска двух идентичных действий на Android).

fun <T> throttleFirst(
    skipMs: Long = 300L,
    coroutineScope: CoroutineScope,
    destinationFunction: (T) -> Unit
): (T) -> Unit {
    var throttleJob: Job? = null
    return { param: T ->
        if (throttleJob?.isCompleted != false) {
            throttleJob = coroutineScope.launch {
                destinationFunction(param)
                delay(skipMs)
            }
        }
    }
}

debounce помогает обнаружить состояние, когда новые данные не отправляются в течение некоторого времени, что позволяет эффективно обрабатывать данные после завершения ввода.

fun <T> debounce(
    waitMs: Long = 300L,
    coroutineScope: CoroutineScope,
    destinationFunction: (T) -> Unit
): (T) -> Unit {
    var debounceJob: Job? = null
    return { param: T ->
        debounceJob?.cancel()
        debounceJob = coroutineScope.launch {
            delay(waitMs)
            destinationFunction(param)
        }
    }
}

Все эти операторы можно использовать следующим образом:

val onEmailChange: (String) -> Unit = throttleLatest(
            300L, 
            viewLifecycleOwner.lifecycleScope, 
            viewModel::onEmailChanged
        )
emailView.onTextChanged(onEmailChange)

было бы неплохо с некоторым выходом.

Kebab Krabby 29.08.2019 09:41

Обратите внимание, что onTextChanged() отсутствует в Android SDK, хотя эта почта содержит совместимую реализацию.

CommonsWare 29.09.2019 15:41

Звоню так: protected fun throttleClick(clickAction: (Unit) -> Unit) { viewModelScope.launch { throttleFirst(scope = this, action = clickAction) } }, но ничего не происходит, он просто возвращается, дроссель, возвращающий fun, не срабатывает. Почему?

GuilhE 19.12.2019 13:01

@GuilhE Эти три функции не приостанавливаются, поэтому вам не нужно вызывать их внутри области сопрограммы. Что касается дросселирования щелчков, я обычно обрабатываю его на стороне фрагмента / активности, что-то вроде val invokeThrottledAction = throttleFirst(lifecycleScope, viewModel::doSomething); button.setOnClickListener { invokeThrottledAction() }. В основном вам нужно сначала создать объект функции, а затем вызывать его, когда вам нужно.

Terenfear 19.12.2019 20:30

Хорошо, @Terenfear, теперь я понял, спасибо за объяснение. Что касается области сопрограммы, поскольку у нас есть задержка, нам нужна область сопрограммы, я просто абстрагировался от вызывающей модели ViewModel (я использую привязку данных), щелчки не создаются в представлении), и этот код находится в "BaseViewModel". Или вы говорите, что вместо launch для получения области видимости я мог бы просто вызвать val ViewModel.viewModelScope: CoroutineScope, поскольку я уже внутри ViewModel (если да, вы правы)? Кстати, действительно полезные функции, спасибо!

GuilhE 20.12.2019 12:53

Почему при 2-м, 3-м или 4-м вызове и т. д. Job не считается нулевым?

I.Step 16.12.2020 19:35

@ I.Step возьмем для примера fun <T> debounce(...), то же самое и с другими функциями. Мы вызываем его один раз, и он создает объект функции типа (T) -> Unit, который чем-то похож на объект анонимного класса Java с одним методом. Ссылка на вакансию содержится внутри этого объекта (вроде как внутри частного поля анонимного класса Java). Каждый раз, когда что-то происходит, мы используем (вызываем) один и тот же функциональный объект. Итак, не имеет значения, сколько раз мы вызываем, это один и тот же объект с той же ссылкой на задание. Возможно, я немного ошибаюсь в терминах, но я так понимаю.

Terenfear 17.12.2020 12:14

У меня проблемы с получением viewLifecycleOwner.lifecycleScope внутри адаптера (или даже в моем фрагменте!). Откуда это взялось? Я смог найти viewLifecycleOwner.lifecycle во фрагменте, но LifecycleScope нет.

Alan Nelson 11.02.2021 21:15

@AlanNelson viewLifecyleOwner.lifecycleScope происходит от androidx.lifecycle:lifecycle-runtime-ktx:2.2.0. Дополнительная информация: developer.android.com/topic/libraries/architecture/…

Terenfear 12.02.2021 13:30

Использование тегов кажется более надежным способом, особенно при работе с представлениями RecyclerView.ViewHolder.

например

fun View.debounceClick(debounceTime: Long = 1000L, action: () -> Unit) {
    setOnClickListener {
        when {
            tag != null && (tag as Long) > System.currentTimeMillis() -> return@setOnClickListener
            else -> {
                tag = System.currentTimeMillis() + debounceTime
                action()
            }
        }
    }
}

Применение:

debounceClick {
    // code block...
}

Я использую callbackFlow и дебонсировать из Котлинские сопрограммы, чтобы добиться устранения неполадок. Например, чтобы добиться устранения неполадок при нажатии кнопки, выполните следующие действия:

Создайте метод расширения на Button для создания callbackFlow:

fun Button.onClicked() = callbackFlow<Unit> {
    setOnClickListener { offer(Unit) }
    awaitClose { setOnClickListener(null) }
}

Подпишитесь на события в рамках вашей активности или фрагмента, относящегося к жизненному циклу. Следующий фрагмент кода обрабатывает события кликов каждые 250 мс:

buttonFoo
    .onClicked()
    .debounce(250)
    .onEach { doSomethingRadical() }
    .launchIn(lifecycleScope)

Ответ @ masterwork работал отлично. Вот он для ImageButton с удаленными предупреждениями компилятора:

@ExperimentalCoroutinesApi // This is still experimental API
fun ImageButton.onClicked() = callbackFlow<Unit> {
    setOnClickListener { offer(Unit) }
    awaitClose { setOnClickListener(null) }
}

// Listener for button
val someButton = someView.findViewById<ImageButton>(R.id.some_button)
someButton
    .onClicked()
    .debounce(500) // 500ms debounce time
    .onEach {
        clickAction()
    }
    .launchIn(lifecycleScope)

@masterwork,

Отличный ответ. Это моя реализация для динамической панели поиска с EditText. Это обеспечивает значительное повышение производительности, поэтому поисковый запрос не выполняется сразу после ввода текста пользователем.

    fun AppCompatEditText.textInputAsFlow() = callbackFlow {
        val watcher: TextWatcher = doOnTextChanged { textInput: CharSequence?, _, _, _ ->
            offer(textInput)
        }
        awaitClose { [email protected](watcher) }
    }
        searchEditText
                .textInputAsFlow()
                .map {
                    val searchBarIsEmpty: Boolean = it.isNullOrBlank()
                    searchIcon.isVisible = searchBarIsEmpty
                    clearTextIcon.isVisible = !searchBarIsEmpty
                    viewModel.isLoading.value = true
                    return@map it
                }
                .debounce(750) // delay to prevent searching immediately on every character input
                .onEach {
                    viewModel.filterPodcastsAndEpisodes(it.toString())
                    viewModel.latestSearch.value = it.toString()
                    viewModel.activeSearch.value = !it.isNullOrBlank()
                    viewModel.isLoading.value = false
                }
                .launchIn(lifecycleScope)
    }

Для простого подхода из ViewModel вы можете просто запустить задание в viewModelScope, отслеживать задание и отменить его, если новое значение появляется до завершения задания:

private var searchJob: Job? = null

fun searchDebounced(searchText: String) {
    searchJob?.cancel()
    searchJob = viewModelScope.launch {
        delay(500)
        search(searchText)
    }
}

Другие вопросы по теме