iosswiftgrand-central-dispatchdebouncingdispatch-queue

In Swift, how to send network requests upon tap such that each request has a few seconds delay in between?


I have created this simple example to show the issue.

I have 4 buttons for upvoting, favouriting, flagging, and hiding. User can tap any button whenever they like and as many times as they want. However, the network request which is sent after the tap must always have a minimum delay of 2 seconds in between. Also, all requests must be sent in order.

For example, suppose a user taps "upvote" 0.1 seconds after tapping "flag". The flagging request should be sent immediately after tapping "flag", and 2 seconds after receiving the response from the flag request, the upvote request should be sent.

The following seems to work but sometimes, it doesn't obey the 2 second delay and sends multiple requests at the same time without any delay in between. I am having a hard time figuring out what's causing this. Is there a better way to do this? Or what's the problem with this?

import UIKit
import SnapKit

extension UIControl {
    func addAction(for controlEvents: UIControl.Event = .touchUpInside, _ closure: @escaping()->()) {
        addAction(UIAction { (action: UIAction) in closure() }, for: controlEvents)
    }
}

let networkDelay = TimeInterval(2)
var requestWorkItems = [DispatchWorkItem]()
var lastRequestCompletionTime: Date?

class ViewController: UIViewController {

    override func viewDidLoad() {
        super.viewDidLoad()
        
        let stack = UIStackView()
        stack.axis = .vertical
        view.addSubview(stack)
        stack.snp.makeConstraints { make in
            make.center.equalToSuperview()
        }
        
        ["UPVOTE","FAVORITE","FLAG","HIDE"].forEach { title in
            let button = UIButton()
            button.setTitle(title, for: .normal)
            button.addAction { [weak self] in
                
                let urlString = "https://example.com/api?action=\(title)"
                
                self?.scheduleNetworkRequest(urlString: urlString)
            }
            button.titleLabel?.font = UIFont.systemFont(ofSize: 30, weight: .bold)
            button.snp.makeConstraints { make in
                make.height.equalTo(100)
            }
            stack.addArrangedSubview(button)
        }
    
    }
    
    func scheduleNetworkRequest(urlString : String) {
        let workItem = DispatchWorkItem { [weak self] in
            self?.sendNetworkRequest(urlString: urlString)
        }

        requestWorkItems.append(workItem)
            
        if Date().timeIntervalSince(lastRequestCompletionTime ?? .distantPast) > networkDelay {
            scheduleNextWork(delay: 0)
        }
    }
    
    func scheduleNextWork(delay : TimeInterval) {
        DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
            if let workItem = requestWorkItems.first {
                requestWorkItems.removeFirst()
                print("Tasks remaining: \(requestWorkItems.count)")
                workItem.perform()
            }
        }
    }

    func sendNetworkRequest(urlString : String) {
        guard let url = URL(string: urlString) else { return }
        
        let task = URLSession.shared.dataTask(with: url) { [weak self] data, response, error in
            if let error = error {
                print("Error: \(error)")
            } else if let _ = data {
                print("Completed: \(urlString) at: \(Date())")
            }
            lastRequestCompletionTime = Date()
            self?.scheduleNextWork(delay: networkDelay)
        }
        task.resume()
    }
    
}

Solution

  • I would use an AsyncStream to record the URLs, and use a Task to consume the stream, waiting 2 seconds in between.

    // add these properties to the view controller
    var task: Task<Void, Error>?
    let (stream, continuation) = AsyncStream.makeStream(of: URL.self)
    

    In the button's action, add a URL to the continuation:

    button.addAction { [weak self] in
        self?.continuation.yield(URL(string: "https://example.com/api?action=\(title)")!)
    }
    

    and initialise task in viewDidLoad like this:

    task = Task {
        for await url in stream {
            do {
                print("Sending request to \(url)")
                let (data, response) = try await URLSession.shared.data(from: url)
                // do something with data & response
                try await Task.sleep(for: .seconds(2))
            } catch let error as CancellationError {
                throw error
            } catch {
                // handle network errors...
            }
        }
    }
    

    Lastly, call task?.cancel() in deinit.