Kotlin Android 去抖动

Posted

技术标签:

【中文标题】Kotlin Android 去抖动【英文标题】:Kotlin Android debounce 【发布时间】:2018-11-24 07:14:56 【问题描述】:

有没有什么奇特的方法可以用 Kotlin android 实现 debounce 逻辑?

我没有在项目中使用 Rx。

Java有办法,不过这里对我来说太大了。

【问题讨论】:

您在寻找基于协程的解决方案吗? @AlexeyRomanov 是的,据我所知,它会非常有效。 Throttle onQueryTextChange in SearchView的可能重复 ***.com/a/50007453/2413303 这个人对我标记为重复的问题有一个共同的回答。 @EpicPandaForce 会尝试更新,但问题似乎不同。 【参考方案1】:

我创建了一个gist,其中包含受this elegant solution 启发的三个去抖动运算符,来自Patrick,我在其中添加了两个类似的案例:throttleFirstthrottleLatest。这两者都与它们的 RxJava 类似物(throttleFirst、throttleLatest)非​​常相似。

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 中,但 this post 包含兼容的实现。 我这样打电话:protected fun throttleClick(clickAction: (Unit) -&gt; Unit) viewModelScope.launch throttleFirst(scope = this, action = clickAction) 但什么也没发生,它只是返回,返回 funthrottleFirst 不会触发。为什么? @GuilhE 这三个函数没有暂停,因此您不必在协程范围内调用它们。至于节流点击,我通常在片段/活动端处理,比如val invokeThrottledAction = throttleFirst(lifecycleScope, viewModel::doSomething); button.setOnClickListener invokeThrottledAction() 基本上你必须先创建一个函数对象,然后在需要时调用它。 好的@Terenfear 我现在明白了,谢谢你的解释。关于协程范围,由于我们有一个延迟,我们需要一个协程范围,我刚刚从 ViewModel 调用者(我正在使用数据绑定)中抽象出点击不是在视图中创建的)并且代码在“基础视图模型”。或者你是说我可以调用val ViewModel.viewModelScope: CoroutineScope 而不是launch 来获得一个范围,因为我已经在一个ViewModel 中(如果是这样,你是对的)?顺便说一句,非常有用的功能,谢谢!【参考方案2】:

对于ViewModel 内部的简单方法,您可以在viewModelScope 内启动一个作业,跟踪该作业,如果在作业完成之前出现新值,则取消它:

private var searchJob: Job? = null

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

【讨论】:

【参考方案3】:

我使用来自Kotlin Coroutines 的callbackFlow 和debounce 来实现去抖动。例如,要实现按钮单击事件的去抖动,您可以执行以下操作:

Button 上创建扩展方法以生成callbackFlow

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

订阅您的生命周期感知活动或片段中的事件。以下 sn-p 每 250 毫秒对点击事件进行去抖动:

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

【讨论】:

【参考方案4】:

一个更简单和通用的解决方案是使用一个函数,该函数返回一个执行去抖动逻辑的函数,并将其存储在一个 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()? 当然,这个 sn-p 支持它。在上面的示例中,handleClickEventsDebounced(Unit) Unit 是参数。它可以是您想要的任何类型,因为我们使用泛型。例如,对于字符串,请执行以下操作: val handleClickEventsDebounced = debounce = debounce(500, coroutineContext) doStuff(it) 其中 'it' 是传递的字符串。或者用 myString -> doStuff(myString) 命名 你好@Patrick,你能帮我解决这个问题吗:***.com/q/59413001/1423773?我敢打赌它缺少一个小细节,但我找不到它。 这让我想起了 javascript 的 Underscore 实现,我非常喜欢它的简单性。恭喜,谢谢! 此实现不会反跳,但会节流。【参考方案5】:

我根据堆栈溢出的旧答案创建了一个扩展函数:

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

【讨论】:

是的!我不知道为什么这里有这么多答案让协程复杂化。【参考方案6】:

感谢https://medium.com/@pro100svitlo/edittext-debounce-with-kotlin-coroutines-fd134d54f4e9 和https://***.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,因此如果未设置,它可能无法正常工作。有关信息,请参阅Migrate to Kotlin coroutines in Android with Kotlin 1.3。

【讨论】:

对不起,我明白了,如果我们运行多个查询,它们会异步返回。因此,不能保证最后一个请求将返回最后一个数据,并且您将使用正确的数据更新视图。 另外,据我了解,textChangedJob?.cancel() 不会取消 Retrofit 中的请求。因此,请准备好以随机顺序从所有请求中获得所有响应。 可能新版本的 Retrofit (2.6.0) 会有所帮助。【参考方案7】:

您可以使用 kotlin 协程 来实现。 Here is an example.

请注意,协程是experimental at kotlin 1.1+,它可能会在即将发布的 kotlin 版本中更改。

更新

自从Kotlin 1.3 发布以来,协程现在已经稳定了。

【讨论】:

不幸的是,现在 1.3.x 已经发布,这似乎已经过时了。 @JoshuaKing,是的。也许medium.com/@pro100svitlo/… 会有所帮助。我稍后再试。 谢谢!因为这非常有用,但我需要更新。谢谢。 您确定频道被认为是稳定的吗? 一个好奇:为什么几乎所有的解决方案都建议使用 coroutines,迫使其他解决方案添加特定的依赖项(问题不说协程已经在使用)?对于这样一个简单的操作,他们不是增加了不必要的开销吗?使用 System.currentTimeMillis() 或类似的不是更好吗?【参考方案8】:

使用标签似乎是一种更可靠的方式,尤其是在使用 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...

【讨论】:

【参考方案9】:

@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)

【讨论】:

【参考方案10】:

@masterwork,

很好的答案。这是我对带有 EditText 的动态搜索栏的实现。这提供了极大的性能改进,因此搜索查询不会立即在用户文本输入时执行。

    fun AppCompatEditText.textInputAsFlow() = callbackFlow 
        val watcher: TextWatcher = doOnTextChanged  textInput: CharSequence?, _, _, _ ->
            offer(textInput)
        
        awaitClose  this@textInputAsFlow.removeTextChangedListener(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)
    

【讨论】:

以上是关于Kotlin Android 去抖动的主要内容,如果未能解决你的问题,请参考以下文章

使用Kotlin开发Android

Android-Kotlin-代理和委托

Android性能优化解析-内存抖动 附:《Android性能优化指南》

使用Kotlin来开发Android,爱上它的优雅

view上下抖动特效

如何在android中检查抖动的持续时间?