如何对一个方法调用进行去抖处理?

53

我正在尝试使用UISearchView查询谷歌地点。在这样做时,对于我的UISearchBar的文本更改调用,我会向谷歌地点发出请求。问题是,我宁愿将此调用防抖动以便每250毫秒只请求一次,以避免不必要的网络流量。如果需要,我宁愿不编写此功能,但如果需要,我会编写它。

我找到了:https://gist.github.com/ShamylZakariya/54ee03228d955f458389,但我不太确定如何使用它:

func debounce( delay:NSTimeInterval, #queue:dispatch_queue_t, action: (()->()) ) -> ()->() {

    var lastFireTime:dispatch_time_t = 0
    let dispatchDelay = Int64(delay * Double(NSEC_PER_SEC))

    return {
        lastFireTime = dispatch_time(DISPATCH_TIME_NOW,0)
        dispatch_after(
            dispatch_time(
                DISPATCH_TIME_NOW,
                dispatchDelay
            ),
            queue) {
                let now = dispatch_time(DISPATCH_TIME_NOW,0)
                let when = dispatch_time(lastFireTime, dispatchDelay)
                if now >= when {
                    action()
                }
            }
    }
}

这里是我使用上述代码尝试过的一件事:

let searchDebounceInterval: NSTimeInterval = NSTimeInterval(0.25)

func findPlaces() {
    // ...
}

func searchBar(searchBar: UISearchBar!, textDidChange searchText: String!) {
    debounce(
        searchDebounceInterval,
        dispatch_get_main_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT),
        self.findPlaces
    )
}

产生的错误是 Cannot invoke function with an argument list of type '(NSTimeInterval, $T5, () -> ())

我该如何使用这个方法,或者在iOS/Swift中有更好的方法吗。


@nhgrif 我不确定那是什么,我在原生iOS领域还很新。 - Parris
1
什么是?你链接到了一个Github页面。我没有看它。你尝试使用那里提供的内容了吗?为什么要链接它? - nhgrif
@nhgrif 哎呀,误解了。我不太确定那个函数的第二个参数是什么,也不知道如何使用它。浏览了一下代码,似乎有点明白了。我试了几个变体,但都没有成功。 - Parris
2
你应该发布一下你尝试过的代码。你可能就快成功了。 - nhgrif
1
请注意,如果您真的想要“每250毫秒一次”,那么您实际上正在寻找节流方法,而不是去抖动。如果用户至少每250毫秒输入一次,去抖动将永远不会实际执行。 - Senseful
显示剩余5条评论
16个回答

52

对于那些不想创建类/扩展的人,这里有一个选项:

在你的代码的某个地方:

var debounce_timer:Timer?

并且在你想要执行去抖动的地方:

debounce_timer?.invalidate()
debounce_timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: false) { _ in 
    print ("Debounce this...") 
}

2
这个页面上最简单的解决方案,为什么要让它变得更加复杂呢? - Jano
2
这是正确的答案。如果有人为一个非常简单的问题提供了两页答案,你应该始终持怀疑态度。 - user187676
这可能是有效的,但由于它将在默认运行循环上计划,除非您显式地将计时器添加到NSModalPanelRunLoopMode或事件跟踪运行循环,否则在macOS上对于其他运行循环(例如当对话框正在显示时)将无法工作。 - strangetimes

33

如果您喜欢保持代码简洁,这里有一个基于 GCD 的解决方案,可以使用熟悉的 GCD 语法实现您所需的功能: https://gist.github.com/staminajim/b5e89c6611eef81910502db2a01f1a83

DispatchQueue.main.asyncDeduped(target: self, after: 0.25) { [weak self] in
     self?.findPlaces()
}

在最后一次调用asyncDuped之后0.25秒,findPlaces()仅会被调用一次


1
这真是太聪明了 @staminajim,很棒! - Patrick

26

Swift 3 version

1. Basic debounce function

func debounce(interval: Int, queue: DispatchQueue, action: @escaping (() -> Void)) -> () -> Void {
    var lastFireTime = DispatchTime.now()
    let dispatchDelay = DispatchTimeInterval.milliseconds(interval)

    return {
        lastFireTime = DispatchTime.now()
        let dispatchTime: DispatchTime = DispatchTime.now() + dispatchDelay

        queue.asyncAfter(deadline: dispatchTime) {
            let when: DispatchTime = lastFireTime + dispatchDelay
            let now = DispatchTime.now()
            if now.rawValue >= when.rawValue {
                action()
            }
        }
    }
}

2. 参数化的防抖函数

有时候让防抖函数接收一个参数会很有用。

typealias Debounce<T> = (_ : T) -> Void

func debounce<T>(interval: Int, queue: DispatchQueue, action: @escaping Debounce<T>) -> Debounce<T> {
    var lastFireTime = DispatchTime.now()
    let dispatchDelay = DispatchTimeInterval.milliseconds(interval)

    return { param in
        lastFireTime = DispatchTime.now()
        let dispatchTime: DispatchTime = DispatchTime.now() + dispatchDelay

        queue.asyncAfter(deadline: dispatchTime) {
            let when: DispatchTime = lastFireTime + dispatchDelay
            let now = DispatchTime.now()

            if now.rawValue >= when.rawValue {
                action(param)
            }
        }
    }
}

3. 示例

以下示例展示了如何使用字符串参数来识别调用,以演示防抖动的工作原理。

let debouncedFunction = debounce(interval: 200, queue: DispatchQueue.main, action: { (identifier: String) in
    print("called: \(identifier)")
})

DispatchQueue.global(qos: .background).async {
    debouncedFunction("1")
    usleep(100 * 1000)
    debouncedFunction("2")
    usleep(100 * 1000)
    debouncedFunction("3")
    usleep(100 * 1000)
    debouncedFunction("4")
    usleep(300 * 1000) // waiting a bit longer than the interval
    debouncedFunction("5")
    usleep(100 * 1000)
    debouncedFunction("6")
    usleep(100 * 1000)
    debouncedFunction("7")
    usleep(300 * 1000) // waiting a bit longer than the interval
    debouncedFunction("8")
    usleep(100 * 1000)
    debouncedFunction("9")
    usleep(100 * 1000)
    debouncedFunction("10")
    usleep(100 * 1000)
    debouncedFunction("11")
    usleep(100 * 1000)
    debouncedFunction("12")
}

注意:函数usleep()仅用于演示目的,可能不是真实应用程序的最优解。

结果

当距离上次调用至少200ms的时间间隔时,总是会得到一个回调。

调用:4
调用:7
调用:12


1
现在看起来好多了。 - Parris
4
请查看 https://gist.github.com/simme/b78d10f0b29325743a18c905c5512788 获取正确的实现。 - Dane Jordan
2
很奇怪,但这不起作用。当我在短时间内输入字母时,即使我还没有完成所有文本输入,也会得到如下输出: 1, 12, 123, 1234, 12345, 123456, 1234567, 12345678, 123456789, 1234567890 函数间隔为2000毫秒,因此我输入字母太慢并不是问题。 - HammerSlavik
1
这个有问题,我在“搜索结果更新器”上使用了它。所有的进程确实都被延迟了,但是在一段时间之后,所有的进程都被执行了。这不是预期的结果(预期:只有最后一个工作被执行)。 - Farras Doko

19
尽管这里有几个很好的答案,但我想分享一下我最喜欢的(纯Swift)防抖动用户输入搜索的方法……
1)添加这个简单的类(Debounce.swift):
import Dispatch

class Debounce<T: Equatable> {

    private init() {}

    static func input(_ input: T,
                      comparedAgainst current: @escaping @autoclosure () -> (T),
                      perform: @escaping (T) -> ()) {

        DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
            if input == current() { perform(input) }
        }
    }
}

2) 可选择包括此单元测试 (DebounceTests.swift):

import XCTest

class DebounceTests: XCTestCase {

    func test_entering_text_delays_processing_until_settled() {
        let expect = expectation(description: "processing completed")
        var finalString: String = ""
        var timesCalled: Int = 0
        let process: (String) -> () = {
            finalString = $0
            timesCalled += 1
            expect.fulfill()
        }

        Debounce<String>.input("A", comparedAgainst: "AB", perform: process)
        Debounce<String>.input("AB", comparedAgainst: "ABCD", perform: process)
        Debounce<String>.input("ABCD", comparedAgainst: "ABC", perform: process)
        Debounce<String>.input("ABC", comparedAgainst: "ABC", perform: process)

        wait(for: [expect], timeout: 2.0)

        XCTAssertEqual(finalString, "ABC")
        XCTAssertEqual(timesCalled, 1)
    }
}

3) 在任何需要延迟处理的地方使用它(例如UISearchBarDelegate):

func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) {
    Debounce<String>.input(searchText, comparedAgainst: searchBar.text ?? "") {
        self.filterResults($0)
    }
}

基本前提是我们仅仅延迟了0.5秒处理输入文本。此时,我们将从事件中获取的字符串与搜索栏的当前值进行比较。如果它们匹配,我们就认为用户已经暂停输入文本,并继续进行过滤操作。
由于其通用性,它适用于任何类型的可比较值。
自从Swift 3版本以来,Dispatch模块已被包含在Swift核心库中,因此这个类也可以安全地用于非苹果平台。

不错,简洁明了的解决方案。 - possen
这个解决方案的好处在于它可以是静态的,即它不需要维护状态来执行逻辑,而是将这个责任委托给调用者。 - mdonati
我从这个解决方案中学到了Swift的新知识,非常感谢! - Leon

17
将此放置在文件的顶层,以免因Swift有趣的参数名称规则而使自己感到困惑。请注意,我已删除#,因此现在没有任何参数具有名称:
func debounce( delay:NSTimeInterval, queue:dispatch_queue_t, action: (()->()) ) -> ()->() {
    var lastFireTime:dispatch_time_t = 0
    let dispatchDelay = Int64(delay * Double(NSEC_PER_SEC))

    return {
        lastFireTime = dispatch_time(DISPATCH_TIME_NOW,0)
        dispatch_after(
            dispatch_time(
                DISPATCH_TIME_NOW,
                dispatchDelay
            ),
            queue) {
                let now = dispatch_time(DISPATCH_TIME_NOW,0)
                let when = dispatch_time(lastFireTime, dispatchDelay)
                if now >= when {
                    action()
                }
        }
    }
}

现在,在您实际的课堂上,您的代码将如下所示:

let searchDebounceInterval: NSTimeInterval = NSTimeInterval(0.25)
let q = dispatch_get_main_queue()
func findPlaces() {
    // ...
}
let debouncedFindPlaces = debounce(
        searchDebounceInterval,
        q,
        findPlaces
    )

现在,debouncedFindPlaces 是一个函数,你可以调用它,除非自上一次调用以来已经过去了 delay 毫秒,否则不会执行你的findPlaces 函数。


我无法弄清如何在类中使用它?由于它会一遍又一遍地重新创建函数,所以我无法在updateSearchResultsForSearchController方法中创建防抖函数。我该如何创建一个防抖方法?我是Swift的新手,也许我漏掉了什么。 - jeroen
你可以在owenoak的回答中找到类似的例子。简而言之:使用类似以下代码:lazy var debouncedFindPlaces: ()->() = debounce( NSTimeInterval(0.25), dispatch_get_main_queue(), self.findPlaces ) - Valentin Shergin
这在Swift4或5中不起作用。dispatch_queue_t已被删除且未被替换。相反,有一个名为DispatchQueue的东西,其操作方式完全不同。 - Shayne
1
@Shayne,实际上不是这样的,它完全相同。名称和OO结构已经更新,但在GCD底层没有改变,这个古老的答案可以逐字翻译成Swift 5。 - matt

6

首先,创建一个通用的Debouncer类:

//
//  Debouncer.swift
//
//  Created by Frédéric Adda

import UIKit
import Foundation

class Debouncer {

    // MARK: - Properties
    private let queue = DispatchQueue.main
    private var workItem = DispatchWorkItem(block: {})
    private var interval: TimeInterval

    // MARK: - Initializer
    init(seconds: TimeInterval) {
        self.interval = seconds
    }

    // MARK: - Debouncing function
    func debounce(action: @escaping (() -> Void)) {
        workItem.cancel()
        workItem = DispatchWorkItem(block: { action() })
        queue.asyncAfter(deadline: .now() + interval, execute: workItem)
    }
}

然后创建一个使用防抖机制的 UISearchBar 子类:

//
//  DebounceSearchBar.swift
//
//  Created by Frédéric ADDA on 28/06/2018.
//

import UIKit

/// Subclass of UISearchBar with a debouncer on text edit
class DebounceSearchBar: UISearchBar, UISearchBarDelegate {

    // MARK: - Properties

    /// Debounce engine
    private var debouncer: Debouncer?

    /// Debounce interval
    var debounceInterval: TimeInterval = 0 {
        didSet {
            guard debounceInterval > 0 else {
                self.debouncer = nil
                return
            }
            self.debouncer = Debouncer(seconds: debounceInterval)
        }
    }

    /// Event received when the search textField began editing
    var onSearchTextDidBeginEditing: (() -> Void)?

    /// Event received when the search textField content changes
    var onSearchTextUpdate: ((String) -> Void)?

    /// Event received when the search button is clicked
    var onSearchClicked: (() -> Void)?

    /// Event received when cancel is pressed
    var onCancel: (() -> Void)?

    // MARK: - Initializers
    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
        delegate = self
    }

    override init(frame: CGRect) {
        super.init(frame: frame)
        delegate = self
    }

    override func awakeFromNib() {
        super.awakeFromNib()
        delegate = self
    }

    // MARK: - UISearchBarDelegate
    func searchBarCancelButtonClicked(_ searchBar: UISearchBar) {
        onCancel?()
    }

    func searchBarSearchButtonClicked(_ searchBar: UISearchBar) {
        onSearchClicked?()
    }

    func searchBarTextDidBeginEditing(_ searchBar: UISearchBar) {
        onSearchTextDidBeginEditing?()
    }

    func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) {
        guard let debouncer = self.debouncer else {
            onSearchTextUpdate?(searchText)
            return
        }
        debouncer.debounce {
            DispatchQueue.main.async {
                self.onSearchTextUpdate?(self.text ?? "")
            }
        }
    }
}

请注意,此类已设置为UISearchBarDelegate。操作将作为闭包传递给此类。
最后,您可以像这样使用它:
class MyViewController: UIViewController {

    // Create the searchBar as a DebounceSearchBar
    // in code or as an IBOutlet
    private var searchBar: DebounceSearchBar?


    override func viewDidLoad() {
        super.viewDidLoad()

        self.searchBar = createSearchBar()
    }

    private func createSearchBar() -> DebounceSearchBar {
        let searchFrame = CGRect(x: 0, y: 0, width: 375, height: 44)
        let searchBar = DebounceSearchBar(frame: searchFrame)
        searchBar.debounceInterval = 0.5
        searchBar.onSearchTextUpdate = { [weak self] searchText in
            // call a function to look for contacts, like:
            // searchContacts(with: searchText)
        }
        searchBar.placeholder = "Enter name or email"
        return searchBar
    }
}

请注意,在这种情况下,DebounceSearchBar 已经是 searchBar 的代理。您不应该将此 UIViewController 子类设置为 searchBar 代理!也不要使用代理函数。请使用提供的闭包!

5
我使用了这个古老的Objective-C方法:
override func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) {
    // Debounce: wait until the user stops typing to send search requests      
    NSObject.cancelPreviousPerformRequests(withTarget: self) 
    perform(#selector(updateSearch(with:)), with: searchText, afterDelay: 0.5)
}

请注意,调用的方法 updateSearch 必须标记为 @objc!
@objc private func updateSearch(with text: String) {
    // Do stuff here   
}

这种方法的优点在于我可以传递参数(这里是搜索字符串)。与此处介绍的大多数防抖器不同,它可以实现这一点...

4
以下内容对我有效:
将以下内容添加到项目中的某个文件中(我维护一个名为“SwiftExtensions.swift”的文件用于此类事情):
// Encapsulate a callback in a way that we can use it with NSTimer.
class Callback {
    let handler:()->()
    init(_ handler:()->()) {
        self.handler = handler
    }
    @objc func go() {
        handler()
    }
}

// Return a function which debounces a callback, 
// to be called at most once within `delay` seconds.
// If called again within that time, cancels the original call and reschedules.
func debounce(delay:NSTimeInterval, action:()->()) -> ()->() {
    let callback = Callback(action)
    var timer: NSTimer?
    return {
        // if calling again, invalidate the last timer
        if let timer = timer {
            timer.invalidate()
        }
        timer = NSTimer(timeInterval: delay, target: callback, selector: "go", userInfo: nil, repeats: false)
        NSRunLoop.currentRunLoop().addTimer(timer!, forMode: NSDefaultRunLoopMode)
    }
}

然后在你的类中设置它:
class SomeClass {
    ...
    // set up the debounced save method
    private var lazy debouncedSave: () -> () = debounce(1, self.save)
    private func save() {
        // ... actual save code here ...
    }
    ...
    func doSomething() {
        ...
        debouncedSave()
    }
}

现在你可以反复调用 someClass.doSomething(),它只会每秒保存一次。

4

作为问题提供的一般解决方案并在几个答案中建立起来,存在逻辑错误,导致短抖动阈值的问题。

从所提供的实现开始:

typealias Debounce<T> = (T) -> Void

func debounce<T>(interval: Int, queue: DispatchQueue, action: @escaping (T) -> Void) -> Debounce<T> {
    var lastFireTime = DispatchTime.now()
    let dispatchDelay = DispatchTimeInterval.milliseconds(interval)

    return { param in
        lastFireTime = DispatchTime.now()
        let dispatchTime: DispatchTime = DispatchTime.now() + dispatchDelay

        queue.asyncAfter(deadline: dispatchTime) {
            let when: DispatchTime = lastFireTime + dispatchDelay
            let now = DispatchTime.now()

            if now.rawValue >= when.rawValue {
                action(param)
            }
        }
    }
}

通过每隔30毫秒进行测试,我们可以创建一个相对简单的示例,以展示其弱点。

let oldDebouncerDebouncedFunction = debounce(interval: 30, queue: .main, action: exampleFunction)

DispatchQueue.global(qos: .background).async {

    oldDebouncerDebouncedFunction("1")
    oldDebouncerDebouncedFunction("2")
    sleep(.seconds(2))
    oldDebouncerDebouncedFunction("3")
}

这将输出

调用:1
调用:2
调用:3

这显然是不正确的,因为第一次调用应该被防抖。使用更长的防抖阈值(例如300毫秒)可以解决该问题。问题的根本原因是错误地期望DispatchTime.now()的值等于传递给asyncAfter(deadline: DispatchTime)deadline的值。比较now.rawValue >= when.rawValue的意图实际上是将预期截止时间与“最近”的截止时间进行比较。对于较小的防抖阈值,asyncAfter的延迟成为了需要考虑的一个非常重要的问题。

但是很容易修复,并且代码可以更加简洁。通过仔细选择何时调用.now(),并确保将实际截止时间与最近调度的截止时间进行比较,我得到了这个解决方案。它对所有threshold值都是正确的。特别注意#1和#2,因为它们在语法上是相同的,但在调用多次工作被分配之前会有所不同。

typealias DebouncedFunction<T> = (T) -> Void

func makeDebouncedFunction<T>(threshold: DispatchTimeInterval = .milliseconds(30), queue: DispatchQueue = .main, action: @escaping (T) -> Void) -> DebouncedFunction<T> {

    // Debounced function's state, initial value doesn't matter
    // By declaring it outside of the returned function, it becomes state that persists across
    // calls to the returned function
    var lastCallTime: DispatchTime = .distantFuture

    return { param in

        lastCallTime = .now()
        let scheduledDeadline = lastCallTime + threshold // 1

        queue.asyncAfter(deadline: scheduledDeadline) {
            let latestDeadline = lastCallTime + threshold // 2

            // If there have been no other calls, these will be equal
            if scheduledDeadline == latestDeadline {
                action(param)
            }
        }
    }
}

实用工具

func exampleFunction(identifier: String) {
    print("called: \(identifier)")
}

func sleep(_ dispatchTimeInterval: DispatchTimeInterval) {
    switch dispatchTimeInterval {
    case .seconds(let seconds):
        Foundation.sleep(UInt32(seconds))
    case .milliseconds(let milliseconds):
        usleep(useconds_t(milliseconds * 1000))
    case .microseconds(let microseconds):
        usleep(useconds_t(microseconds))
    case .nanoseconds(let nanoseconds):
        let (sec, nsec) = nanoseconds.quotientAndRemainder(dividingBy: 1_000_000_000)
        var timeSpec = timespec(tv_sec: sec, tv_nsec: nsec)
        withUnsafePointer(to: &timeSpec) {
            _ = nanosleep($0, nil)
        }
    case .never:
        return
    }
}

希望这个答案能帮到遇到函数柯里化意外行为的其他人。


4

这里提供了完全符合Swift 5的友好且流畅的解决方案

比如在检测tableView滚动到底部时,您可以使用它。

NSObject.cancelPreviousPerformRequests(withTarget: self, 
                                       selector: #selector(didScrollToBottom), 
                                       object: nil)
perform(#selector(didScrollToBottom), with: nil, afterDelay: TimeInterval(0.1))

@objc private func didScrollToBottom() {
      print("finally called once!")
}

我非常确定这实际上并没有取消之前的请求。编辑:算了!超时时间只是非常短。将其延长到0.4秒对我有用 :) - yspreen

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接