skip to Main Content

I am trying to execute a series of tasks with a delay. To experiment that, I created a simple app with two labels and a button to perform hit test.

enter image description here

Upon hitting the "Hit me" button, yellow label must update the count instantly, where the red label must update the same count with a delay of 3 seconds. Below is my code.

class ViewController: UIViewController {

    @IBOutlet weak var hitMeButton: UIButton!
    @IBOutlet weak var actualCountLabel: UILabel!
    @IBOutlet weak var processedCountLabel: UILabel!
    
    private let delay: UInt32 = 3
    
    private var queuedHits: [Int] = [] {
        didSet {
            for hit in self.queuedHits {
                // First item to be updated instantly as the queue might be applied only to the items behind
                self.processedHits.append(hit)
                // Applying delay to the iteration for the rest of items
                sleep(self.delay)
            }
            self.queuedHits.removeAll()
        }
    }
    
    private var actualHits: [Int] = [] {
        didSet {
            self.actualCountLabel.text = "(self.actualHits.count)"
            if let last = self.actualHits.last {
                self.queuedHits.append(last)
            }
        }
    }
    
    private var processedHits: [Int] = [] {
        didSet {
            self.processedCountLabel.text = "(self.processedHits.count)"
        }
    }
    
    override func viewDidLoad() {
        super.viewDidLoad()
        setupInitialValues()
    }
    
    private func setupInitialValues() {
        actualCountLabel.text = "0"
        processedCountLabel.text = "0"
    }

    @IBAction func didHitButton(_ sender: UIButton) {
        actualHits.append(1)
    }
}

Here I use sleep() function to delay the execution. When I perform the hit test, I see that the entire app is frozen and both the labels update their count after 3 seconds. sleep() function does not satisfy my need here. What would be the proper way to do it?

My need is to iterate through the queuedHits with a delay and without blocking the main thread.

2

Answers


  1. you can use the DispatchQueue "asyncAfter" class to schedule the updates with a delay

    private let delay: TimeInterval = 3

      for (index, hit) in self.queuedHits.enumerated() {
                    DispatchQueue.main.asyncAfter(deadline: .now() + (delay * Double(index))) {
                        self.processedHits.append(hit)
                    }
                }
    

    Using "sleep" in the main thread can block the UI and make your app Hang

    Login or Signup to reply.
  2. You should avoid traditional sleep calls, as those block the calling thread. While you could consider asyncAfter, that can run into timer coalescing problems.

    I would suggest using a Timer. The trick is that because we are no longer blocking, you need to be prepared for queuedHits to be mutated while the previous iteration may still be underway. So, if previous iteration is not complete, let the previous timer simply proceed. Only if there is no existing timer would you create a new one. And, needless to say, when there are no more queuedHits, you would stop the timer. E.g.:

    private weak var timer: Timer?
    private let delay: TimeInterval = 3
    
    private var queuedHits: [Int] = [] {
        didSet {
            startTimerIfNecessary()
        }
    }
    
    func startTimerIfNecessary() {
        guard timer == nil else { return }
    
        timer = .scheduledTimer(withTimeInterval: delay, repeats: true) { [weak self] timer in
            guard let self else {
                timer.invalidate()
                return
            }
    
            if queuedHits.isEmpty {
                timer.invalidate()
            } else {
                let hit = queuedHits.removeFirst()
                processedHits.append(hit)
            }
        }
        timer?.fire()
    }
    

    It, admittedly, can sometimes be hard to reason about asynchronous processes, so we often reach for asyncawait. Specifically, we might use for AsyncChannel of Apple’s Swift Async Algorithms.

    So we set up a routine to monitor the channel for new values and then Task.sleep (which, unlike legacy sleep functions, does not block the thread):

    private let delay: TimeInterval = 3
    private let channel = AsyncChannel<Int>()
    
    func monitorChannel() async throws {
        for await hit in channel {
            processedHits.append(hit)
            try await Task.sleep(for: .seconds(delay))
        }
    }
    

    And when the view appears, we would start monitoring the channel, and when it disappears, cancel that task:

    private var task: Task<Void, Error>?
    
    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)
        task = Task { try await monitorChannel() }
    }
    
    override func viewDidDisappear(_ animated: Bool) {
        super.viewDidDisappear(animated)
        task?.cancel()
    }
    

    So, pulling that together:

    import AsyncAlgorithms
    
    class ViewController: UIViewController {
    
        @IBOutlet weak var hitMeButton: UIButton!
        @IBOutlet weak var actualCountLabel: UILabel!
        @IBOutlet weak var processedCountLabel: UILabel!
    
        private var actualHits: [Int] = [] {
            didSet {
                actualCountLabel.text = "(actualHits.count)"
            }
        }
    
        private var processedHits: [Int] = [] {
            didSet {
                processedCountLabel.text = "(processedHits.count)"
            }
        }
    
        override func viewDidLoad() {
            super.viewDidLoad()
            setupInitialValues()
        }
    
    
        private let delay: TimeInterval = 3
        private var task: Task<Void, Error>?
        private let channel = AsyncChannel<Int>()
    
        override func viewDidAppear(_ animated: Bool) {
            super.viewDidAppear(animated)
            task = Task { try await monitorChannel() }
        }
    
        override func viewDidDisappear(_ animated: Bool) {
            super.viewDidDisappear(animated)
            task?.cancel()
        }
    }
    
    private extension ViewController {
        @IBAction func didHitButton(_ sender: UIButton) {
            Task {
                actualHits.append(1)
                await channel.send(1)
            }
        }
    
        func setupInitialValues() {
            actualCountLabel.text = "0"
            processedCountLabel.text = "0"
        }
    
        func monitorChannel() async throws {
            for await hit in channel {
                processedHits.append(hit)
                try await Task.sleep(for: .seconds(delay))
            }
        }
    }
    
    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search