iosswiftoperationoperationqueue

Subclassing OperationQueue adding sleep period


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

Solution

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