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.