Search code examples
swiftgrand-central-dispatchcompletionswift-concurrency

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


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?


Solution

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