Есть ли какой-нибудь необычный способ реализовать логику debounce с Kotlin Android?
Я не использую Rx в проекте.
В Ява есть путь, но он мне здесь великоват.
@AlexeyRomanov Да, как я понял, было бы очень эффективно.
Возможный дубликат Дросселирование onQueryTextChange в SearchView
@EpicPandaForce Попробовал бы и обновил, но вопрос кажется другим.
Для этого вы можете использовать котлин сопрограммы. Вот пример.
Имейте в виду, что сопрограммы - это экспериментальный на Котлине 1.1+, и он может быть изменен в следующих версиях kotlin.
Начиная с выпуска Котлин 1.3, сопрограммы теперь стабильны.
К сожалению, теперь, когда была выпущена версия 1.3.x, это кажется устаревшим.
@JoshuaKing, да. Может, medium.com/@pro100svitlo/… поможет. Попробую позже.
Спасибо! Потому что это очень полезно, но мне нужно обновить. Спасибо.
Вы уверены, что каналы считаются стабильными?
Любопытно: почему почти все решения предлагают использование сопрограммы, заставляя среди остальных добавлять определенную зависимость (вопрос не говорит, что сопрограммы уже используются)? Разве для такой простой операции не добавляются лишние накладные расходы? Не лучше ли использовать System.currentTimeMillis() или подобное?
Благодаря 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.
Извините, я понял, что если мы запустим несколько запросов, они вернутся асинхронно. Таким образом, не гарантируется, что последний запрос вернет последние данные, и вы обновите представление с правильными данными.
Также, как я понял, textChangedJob?.cancel() не отменяет запрос в Retrofit. Итак, будьте готовы к тому, что вы получите все ответы на все запросы в случайной последовательности.
Возможно, поможет новая версия Retrofit (2.6.0).
Более простое и универсальное решение - использовать функцию, которая возвращает функцию, которая выполняет логику противодействия, и сохраняет ее в 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 ()?
Конечно, этот фрагмент поддерживает это. В приведенном выше примере handleClickEventsDebounced(Unit)Unit - это параметр. Это может быть любой тип, который вам нужен, поскольку мы используем дженерики. Для строки, например, сделайте следующее: val handleClickEventsDebounced = debounce <String> = debounce <String> (500, coroutineContext) {doStuff (it)} Где 'it' - переданная строка. Или назовите его {myString -> doStuff (myString)}
Привет, @Patrick, можешь мне помочь: stackoverflow.com/q/59413001/1423773? Бьюсь об заклад, в нем отсутствует мелкая деталь, но я не могу ее найти.
Это напомнило мне о реализации Underscore в JavaScript, которая мне очень нравится за простоту. Поздравления и спасибо!
Эта реализация не дребезжит, а ограничивает.
Я создал одну функцию расширения из старых ответов о переполнении стека:
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
}
Да! Я не знаю, почему так много ответов усложняют это с помощью сопрограмм.
Я создал суть с тремя операторами противодействия, вдохновленный это элегантное решение из Патрик, где я добавил еще два похожих случая: 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)
было бы неплохо с некоторым выходом.
Обратите внимание, что onTextChanged() отсутствует в Android SDK, хотя эта почта содержит совместимую реализацию.
Звоню так: protected fun throttleClick(clickAction: (Unit) -> Unit) { viewModelScope.launch { throttleFirst(scope = this, action = clickAction) } }, но ничего не происходит, он просто возвращается, дроссель, возвращающий fun, не срабатывает. Почему?
@GuilhE Эти три функции не приостанавливаются, поэтому вам не нужно вызывать их внутри области сопрограммы. Что касается дросселирования щелчков, я обычно обрабатываю его на стороне фрагмента / активности, что-то вроде val invokeThrottledAction = throttleFirst(lifecycleScope, viewModel::doSomething); button.setOnClickListener { invokeThrottledAction() }. В основном вам нужно сначала создать объект функции, а затем вызывать его, когда вам нужно.
Хорошо, @Terenfear, теперь я понял, спасибо за объяснение. Что касается области сопрограммы, поскольку у нас есть задержка, нам нужна область сопрограммы, я просто абстрагировался от вызывающей модели ViewModel (я использую привязку данных), щелчки не создаются в представлении), и этот код находится в "BaseViewModel". Или вы говорите, что вместо launch для получения области видимости я мог бы просто вызвать val ViewModel.viewModelScope: CoroutineScope, поскольку я уже внутри ViewModel (если да, вы правы)? Кстати, действительно полезные функции, спасибо!
Почему при 2-м, 3-м или 4-м вызове и т. д. Job не считается нулевым?
@ I.Step возьмем для примера fun <T> debounce(...), то же самое и с другими функциями. Мы вызываем его один раз, и он создает объект функции типа (T) -> Unit, который чем-то похож на объект анонимного класса Java с одним методом. Ссылка на вакансию содержится внутри этого объекта (вроде как внутри частного поля анонимного класса Java). Каждый раз, когда что-то происходит, мы используем (вызываем) один и тот же функциональный объект. Итак, не имеет значения, сколько раз мы вызываем, это один и тот же объект с той же ссылкой на задание. Возможно, я немного ошибаюсь в терминах, но я так понимаю.
У меня проблемы с получением viewLifecycleOwner.lifecycleScope внутри адаптера (или даже в моем фрагменте!). Откуда это взялось? Я смог найти viewLifecycleOwner.lifecycle во фрагменте, но LifecycleScope нет.
@AlanNelson viewLifecyleOwner.lifecycleScope происходит от androidx.lifecycle:lifecycle-runtime-ktx:2.2.0. Дополнительная информация: developer.android.com/topic/libraries/architecture/…
Использование тегов кажется более надежным способом, особенно при работе с представлениями 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)
}
}
Вы ищете решение на основе сопрограмм?