import Foundation
class MyOperationQueue {
static let shared = MyOperationQueue()
private var queue: OperationQueue
init() {
self.queue = OperationQueue()
queue.name = "com.myqueue.name"
queue.maxConcurrentOperationCount = 1
queue.qualityOfService = .background
}
func requestDataOperation() {
queue.addOperation {
print("START NETWORK \(Date())")
NetworkService.shared.getData()
print("END NETWORK \(Date())")
}
}
func scheduleSleep() {
queue.cancelAllOperations()
queue.addOperation {
print("SLEEP START \(Date())")
Thread.sleep(forTimeInterval: 5)
print("SLEEP END \(Date())")
}
}
func cancelAll() {
queue.cancelAllOperations()
}
}
I put requestDataOperation
function inside a timer for every 10 seconds interval. And I have a button to call scheduleSleep
manually. I was expected to debounce the request for every 5 more seconds when I tapping the button.
But I am getting something like this:
START NETWORK
END NETWORK
SLEEP START 2021-03-11 11:13:40 +0000
SLEEP END 2021-03-11 11:13:45 +0000
SLEEP START 2021-03-11 11:13:45 +0000
SLEEP END 2021-03-11 11:13:50 +0000
START NETWORK
END NETWORK
How to add 5 more seconds since my last tapping and combine it together rather than split it into two operation? I call queue.cancelAllOperations
and start a new sleep operation but doesn't seem to work.
Expect result:
START NETWORK
END NETWORK
SLEEP START 2021-03-11 11:13:40 +0000
// <- the second tap when 2 seconds passed away
SLEEP END 2021-03-11 11:13:47 +0000 // 2+5
START NETWORK
END NETWORK
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
}
}