If you want some operation to be delayed for a certain amount of time, I would not create a “queue” class, but rather I would just define an Operation
that simply will not be isReady
until that time has passed (e.g., five seconds later). That eliminates the need for separate “sleep operations”.
E.g.,
class DelayedOperation: Operation {
private var enoughTimePassed = false
@Atomic private var timer: DispatchSourceTimer?
@Atomic private var block: (() -> Void)?
override var isReady: Bool { enoughTimePassed && super.isReady } // this operation won't run until (a) enough time has passed; and (b) any dependencies or the like are satisfied
init(timeInterval: TimeInterval = 5, block: @escaping () -> Void) {
self.block = block
super.init()
startReadyTimer(with: timeInterval)
}
override func main() {
block?()
block = nil
}
override func cancel() {
// Just because operation is canceled, it doesn’t mean it always be immediately deallocated.
// So, let’s be careful and release our `block` and cancel the `timer`.
block = nil
timer = nil
super.cancel()
}
func startReadyTimer(with timeInterval: TimeInterval = 5) {
timer = DispatchSource.makeTimerSource() // GCD timer
timer?.setEventHandler { [weak self] in
guard let self else { return }
self.willChangeValue(forKey: #keyPath(isReady)) // make sure to do necessary `isReady` KVO notification
self.enoughTimePassed = true
self.didChangeValue(forKey: #keyPath(isReady))
}
timer?.schedule(deadline: .now() + timeInterval)
timer?.resume()
}
}
And I eliminate races on timer
and block
with this property wrapper:
@propertyWrapper
struct Atomic<Value> {
private var value: Value
private let lock = NSLock()
init(wrappedValue: Value) {
value = wrappedValue
}
var wrappedValue: Value {
get { lock.synchronized { value } }
set { lock.synchronized { value = newValue } }
}
}
extension NSLocking {
func synchronized<T>(block: () throws -> T) rethrows -> T {
lock()
defer { unlock() }
return try block()
}
}
Anyway, having defined that DelayedOperation
, then you can do something like
logger.debug("creating operation")
let operation = DelayedOperation {
logger.debug("some task")
}
queue.addOperation(operation)
And it will delay running that task (in this case, just logging “some task” message) for five seconds. If you want to reset the timer, just call that method on the operation subclass:
operation.resetTimer()
For example, here I created the task, added it to the queue, reset it three times at two second intervals, and the block actually runs five seconds after the last reset:
2021-09-30 01:13:12.727038-0700 MyApp[7882:228747] [ViewController] creating operation
2021-09-30 01:13:14.728953-0700 MyApp[7882:228747] [ViewController] delaying operation
2021-09-30 01:13:16.728942-0700 MyApp[7882:228747] [ViewController] delaying operation
2021-09-30 01:13:18.729079-0700 MyApp[7882:228747] [ViewController] delaying operation
2021-09-30 01:13:23.731010-0700 MyApp[7882:228829] [ViewController] some task
Now, if you're using operations for network requests, then you are presumably already implemented your own asynchronous Operation
subclass that does the necessary KVO for isFinished
, isExecuting
, etc., so you may choose marry the above isReady
logic with that existing Operation
subclass.
But the idea is one can completely lose the "sleep" operation with an asynchronous pattern. If you did want a dedicate sleep operation, you could still use the above pattern (but make it an asynchronous operation rather than blocking a thread with sleep
).
All of this having been said, if I personally wanted to debounce a network request, I would not integrate this into the operation or operation queue. I would just do that debouncing at the time that I started the request:
weak var timer: Timer?
func debouncedRequest(in timeInterval: TimeInterval = 5) {
timer?.invalidate()
timer = .scheduledTimer(withTimeInterval: timeInterval, repeats: false) { _ in
// initiate request here
}
}