How can I debounce a method call?

前端 未结 13 1453
醉梦人生
醉梦人生 2020-11-30 01:18

I\'m trying to use a UISearchView to query google places. In doing so, on text change calls for my UISearchBar, I\'m making a request to google pla

相关标签:
13条回答
  • 2020-11-30 01:39

    If you like to keep things clean, here's a GCD based solution that can do what you need using familiar GCD based syntax: https://gist.github.com/staminajim/b5e89c6611eef81910502db2a01f1a83

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

    findPlaces() will only get called one time, 0.25 seconds after the last call to asyncDuped.

    0 讨论(0)
  • 2020-11-30 01:39

    I used this good old Objective-C inspired method:

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

    Note that the called method updateSearch must be marked @objc !

    @objc private func updateSearch(with text: String) {
        // Do stuff here   
    }
    

    The big advantage of this method is that I can pass parameters (here: the search string). With most of Debouncers presented here, that is not the case ...

    0 讨论(0)
  • 2020-11-30 01:43

    Despite several great answers here, I thought I'd share my favorite (pure Swift) approach for debouncing user entered searches...

    1) Add this simple class (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) Optionally include this unit test (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) Use it wherever you want to delay processing (e.g. UISearchBarDelegate):

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

    Basic premise is that we are just delaying the processing of the input text by 0.5 seconds. At that time, we compare the string we got from the event with the current value of the search bar. If they match, we assume that the user has paused entering text, and we proceed with the filtering operation.

    As it is generic, it works with any type of equatable value.

    Since the Dispatch module has been included in the Swift core library since version 3, this class is safe to use with non-Apple platforms as well.

    0 讨论(0)
  • 2020-11-30 01:43

    The general solution as provided by the question and built upon in several of the answers, has a logic mistake that causes problems with short debounce thresholds.

    Starting with the provided implementation:

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

    Testing with an interval of 30 milliseconds, we can create a relatively trivial example that demonstrates the weakness.

    let oldDebouncerDebouncedFunction = debounce(interval: 30, queue: .main, action: exampleFunction)
    
    DispatchQueue.global(qos: .background).async {
    
        oldDebouncerDebouncedFunction("1")
        oldDebouncerDebouncedFunction("2")
        sleep(.seconds(2))
        oldDebouncerDebouncedFunction("3")
    }
    

    This prints

    called: 1
    called: 2
    called: 3

    This is clearly incorrect, because the first call should be debounced. Using a longer debounce threshold (such as 300 milliseconds) will fix the problem. The root of the problem is a false expectation that the value of DispatchTime.now() will be equal to the deadline passed to asyncAfter(deadline: DispatchTime). The intention of the comparison now.rawValue >= when.rawValue is to actually compare the expected deadline to the "most recent" deadline. With small debounce thresholds, the latency of asyncAfter becomes a very important problem to think about.

    It's easy to fix though, and the code can be made more concise on top of it. By carefully choosing when to call .now(), and ensuring the comparison of the actual deadline with most recently scheduled deadline, I arrived at this solution. Which is correct for all values of threshold. Pay special attention to #1 and #2 as they are the same syntactically, but will be different if multiple calls are made before the work is dispatched.

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

    Utilities

    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
        }
    }
    

    Hopefully, this answer will help someone else that has encountered unexpected behavior with the function currying solution.

    0 讨论(0)
  • 2020-11-30 01:45

    Put this at the top level of your file so as not to confuse yourself with Swift's funny parameter name rules. Notice that I've deleted the # so that now none of the parameters have names:

    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()
                    }
            }
        }
    }
    

    Now, in your actual class, your code will look like this:

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

    Now debouncedFindPlaces is a function which you can call, and your findPlaces won't be executed unless delay has passed since the last time you called it.

    0 讨论(0)
  • 2020-11-30 01:46

    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. Parameterized debounce function

    Sometimes it's useful to be have the debounce function take a parameter.

    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. Example

    In the following example you can see, how the debouncing works, using a string parameter to identify the calls.

    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")
    }
    

    Note: The usleep() function is only used for demo purposes and may not be the most elegant solution for a real app.

    Result

    You always get a callback, when there is an interval of at least 200ms since the last call.

    called: 4
    called: 7
    called: 12

    0 讨论(0)
提交回复
热议问题