Change completion for async Task which is "in-progress" (Swift Concurrency)?

99 Views Asked by At

I need to handle an asynchronous task result. The problem is if I call it when it is in progress I need to update completion.

In GCD it will look somehow like that:

func someFunc(completion: (() -> ())?) {
        if isLoading {
            if let completion {
                delayedCompletion = completion
            }
            return
        }
        isLoading = true
        delayedCompletion = completion

        //example of some async task
        DispatchQueue.main.async { [weak self] in
            self?.delayedContinuation?()
            self?.delayedContinuation = nil
        }
    }

But how to do that with async/await? Tried to write code:

func someFunc() async {
        if isLoading {
            return await withCheckedContinuation { [weak self] checkedContinuation in
                self?.delayedContinuation = checkedContinuation
            }
        }
        isLoading = true
        return await withCheckedContinuation { [weak self] checkedContinuation in
            self?.delayedContinuation = checkedContinuation
            Task { @MainActor [weak self] in
                self?.delayedContinuation?.resume()
            }
        }
    }

Is it correct or are there other ways to add a varying completion block?

2

There are 2 best solutions below

5
Rob On BEST ANSWER

There are a few basic patterns:

  1. Await prior task.

    actor AdManager {
        var inProgressTask: Task<Void, Error>? // if you don’t `try` anything inside the `Task {…}`, this property would be `Task<Void, Never>?` 
    
        func nextAdWithWait() async throws {
            if let inProgressTask {
                try await inProgressTask.value
                return
            }
    
            let task = Task {
                defer { inProgressTask = nil }
    
                try await fetchAndPresentAd()
            }
            inProgressTask = task
    
            // note, because this is unstructured concurrency, we want to manually handle cancelation
    
            try await withTaskCancellationHandler {
                try await task.value
            } onCancel: {
                task.cancel()
            }
        }
    }
    
  2. Cancel prior task and launch a new one.

    func nextAdWithCancelPrevious() async throws {
        inProgressTask?.cancel()
    
        let task = Task {
            defer { inProgressTask = nil }
    
            try await fetchAndPresentAd()
        }
        inProgressTask = task
    
        try await withTaskCancellationHandler {
            try await task.value
        } onCancel: {
            task.cancel()
        }
    }
    

Having shown a couple of basic patterns, you likely want to fetch ads and present them in the UI, so you want to decouple the fetching from the presentation in the UI.

One might generate an asynchronous sequence of the ads from some “ad manager” and yield values as ads are fetched. So the UI can initiate the “periodically fetch ads” and then process them as they come in.

actor AdManager {
    /// Generate sequence of ads

    func ads(durationBetweenAds: Duration = .seconds(60)) -> AsyncStream<Ad> {
        AsyncStream { continuation in
            let task = Task {
                defer { continuation.finish() }
                
                while !Task.isCancelled {
                    if let ad = await nextAd() {
                        continuation.yield(ad)
                        try? await Task.sleep(for: durationBetweenAds)
                    } else {
                        try? await Task.sleep(for: .seconds(10)) // in case ad server is down or broken, don't flood it with requests (but, at the same time, maybe not wait a full minute before you try again)
                    }
                }
            }
            
            continuation.onTermination = { _ in
                task.cancel()
            }
        }
    }
    
    func nextAd() async -> AdManager.Ad? {…}
}

extension AdManager {
    /// model structure for a given ad … perhaps your ad platform already has a model object for this
    
    struct Ad {…}
}

Then the UI can monitor this asynchronous sequence. E.g., in UIKit:

class ViewController: UIViewController {
    private var adsTask: Task<Void, Never>?
    
    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)
        adsTask = Task { await showAds() }
    }    
    
    override func viewDidDisappear(_ animated: Bool) {
        super.viewDidDisappear(animated)
        adsTask?.cancel()
    }
    
    func showAds() async {
        let adManager = await AdManager()
        let ads = await adManager.ads()
        
        for await ad in ads {
            await showAdInUI(ad)
        }
    }

    func showAdInUI(_ ad: AdManager.Ad) async {…}
}

In SwiftUI, you don’t need this unstructured concurrency. Just directly await the showAds function in a .task view modifier, and it will start it when the view appears and will cancel it automatically when the view disappears. But, in UIKit, we need to manually handle cancelation, like above.

Now, you haven’t shared your ad framework, so many of the details above may vary. But don’t get lost in the details. The basic idea in Swift concurrency is that you likely want an asynchronous sequence of ads, and then let the UI iterate through this sequence and present them as they come in. This is the natural Swift concurrency pattern for consuming an asynchronous sequence of events.

0
Roy Rodney On

As the comments already mentioned, all you need to do is store a reference to the Task you are using which you can then await on multiple lines of code.

However, to guarantee proper access to the Task property, I prefer to store it in a dedicate Actor:

/// SingleTaskProxy allows free async calls to a single task.
/// If the task is in progress, all callers with be waiting the same task instance.
/// If no task if currently running, a new one will be created.
actor SingleTaskProxy<T: Sendable> {
    init(_ taskClosure: @escaping @Sendable () async throws -> T) {
        self.taskClosure = taskClosure
    }
    
    private var taskClosure: @Sendable () async throws -> T
    private var singleTask: Task<T, Error>?
    
    var value: T {
        get async throws {
            defer { singleTask = nil }
            
            if singleTask == nil {
                print("creating new task for type \(T.self)")
                singleTask = Task(operation: taskClosure)
            }
            
            return try await singleTask!.value
        }
    }
}

Example usage:

class FooManager<Foo: Sendable> {
    private let fooProxy: SingleTaskProxy<Foo>
    
    init(loadFoo: @Sendable @escaping () async throws -> Foo) {
        self.fooProxy = SingleTaskProxy(loadFoo)
    }
    
    func loadFoo() async throws -> Foo {
        try await fooProxy.value
    }
}