reCAPTCHA WAF Session Token
Programming Languages

Swift Concurrency Continuations: Getting Started

With the introduction of Swift Concurrency and the async/await API, Apple greatly improved the process of writing asynchronous code in Swift. They also introduced the Continuation API, which you can use in place of delegates and completion callbacks. Learning and using these APIs greatly streamlines your code.

You’ll learn all about the Continuation API in this tutorial. Specifically, you’ll update the tutorial app, WhatsThat, to use the Continuation API instead of legacy patterns. You’ll learn the following along the way:

  • What the Continuation API is and how it works.
  • How to wrap a delegate-based API component and provide an async interface for it.
  • How to provide an async API via an extension for components that use completion callbacks.
  • How to use the async API in place of legacy patterns.
Note:
Although not strictly required for this tutorial, confidence with the Swift async/await API will help you better understand how the API works under the hood. Our book, Modern Concurrency in Swift, is a great place to start.

Getting Started

Download the starter project by clicking Download Materials at the top or bottom of this tutorial.

Open WhatsThat from the starter folder, and build and run.

WhatsThat is an image-classifier app. You pick an image, and it provides an image description in return.

WhatsThat Image Classification

Here above is Zohar, a beloved Brittany Spaniel — according to the classifier model :]

The app uses one of the standard CoreML neural models to determine the image’s main subject. However, the model’s determination could be incorrect, so it also gives a detection accuracy percentage. The higher the percentage, the more likely the model believes its prediction is accurate.

Note:
Image classification is a huge topic, but you don’t need to fully understand it for this tutorial. If want to learn more, refer to Create ML Tutorial: Getting Started.

You can either use the default images, or you can drag and drop your own photos into the simulator’s Photos app. Either way, you’ll see the available images in WhatsThat’s image picker.

Take a look at the project file hierarchy, and you’ll find these core files:

  • AppMain.swift launches the SwiftUI interface.
  • Screen is a group containing three SwiftUI views.
  • ContentView.swift contains the main app screen.
  • ImageView.swift defines the image view used in the main screen.
  • ImagePickerView.swift is a SwiftUI wrapper around a UIKit UIImagePickerController.

The Continuation API

As a brief refresher, Swift Concurrency allows you to add async to a method signature and call await to handle asynchronous code. For example, you can write an asynchronous networking method like this:


// 1
private func fetchData(url: URL) async throws -> Data {

  // 2
  let (data, response) = try await URLSession.shared.data(from: url)

  // 3
  guard let response = response as? HTTPURLResponse, response.isOk else {
    throw URLError(.badServerResponse)
  }
  return data
}

Here’s how this works:

  1. You indicate this method uses the async/await API by declaring async on its signature.
  2. The await instruction is known as a “suspension point.” Here, you tell the system to suspend the method when await is encountered and begin downloading data on a different thread.

Swift stores the state of the current function in a heap, creating a “continuation.” Here, once URLSession finishes downloading the data, the continuation is resumed, and the execution continues from where it was stopped.

  • Lastly, you validate the response and return a Data type as promised by the method signature.
  • When working with async/await, the system automatically manages continuations for you. Because Swift, and UIKit in particular, heavily use delegates and completion callbacks, Apple introduced the Continuation API to help you transition existing code using an async interface. Let’s go over how this works in detail.

    Suspending The Execution

    SE-0300: Continuations for interfacing async tasks with synchronous code defines four different functions to suspend the execution and create a continuation.

    • withCheckedContinuation(_:)
    • withCheckedThrowingContinuation(_:)
    • withUnsafeContinuation(_:)
    • withUnsafeThrowingContinuation(_:)

    As you can see, the framework provides two variants of APIs of the same functions.

    • with*Continuation provides a non-throwing context continuation
    • with*ThrowingContinuation also allows throwing exceptions in the continuations

    The difference between Checked and Unsafe lies in how the API verifies proper use of the resume function. You’ll learn about this later, so keep reading… ;]

    Resuming The Execution

    To resume the execution, you’re supposed to call the continuation provided by the function above once, and only once, by using one of the following continuation functions:

    • resume() resumes the execution without returning a result, e.g. for an async function returning Void.
    • resume(returning:) resumes the execution returning the specified argument.
    • resume(throwing:) resumes the execution throwing an exception and is used for ThrowingContinuation only.
    • resume(with:) resumes the execution passing a Result object.

    Okay, that’s enough for theory! Let’s jump right into using the Continuation API.

    Replacing Delegate-Based APIs with Continuation

    You’ll first wrap a delegate-based API and provide an async interface for it.

    Look at the UIImagePickerController component from Apple. To cope with the asynchronicity of the interface, you set a delegate, present the image picker and then wait for the user to pick an image or cancel. When the user selects an image, the framework informs the app via its delegate callback.

    Delegate Based Communication

    Even though Apple now provides the PhotosPickerUI SwiftUI component, providing an async interface to UIImagePickerController is still relevant. For example, you may need to support an older iOS or may have customized the flow with a specific picker design you want to maintain.

    The idea is to add a wrapper object that implements the UIImagePickerController delegate interface on one side and presents the async API to external callers.

    Refactoring delegate based components with continuation

    Hello Image Picker Service

    Add a new file to the Services group and name it ImagePickerService.swift.

    Replace the content of ImagePickerService.swift with this:

    
    import OSLog
    import UIKit.UIImage
    
    class ImagePickerService: NSObject {
      private var continuation: CheckedContinuation<UIImage?, Never>?
    
      func pickImage() async -> UIImage? {
        // 1
        return await withCheckedContinuation { continuation in
          if self.continuation == nil {
            // 2
            self.continuation = continuation
          }
        }
      }
    }
    
    // MARK: - Image Picker Delegate
    extension ImagePickerService: UIImagePickerControllerDelegate, UINavigationControllerDelegate {
      func imagePickerController(
        _ picker: UIImagePickerController,
        didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]
      ) {
        Logger.main.debug("User picked photo")
        // 3
        continuation?.resume(returning: info[.originalImage] as? UIImage)
      }
    
      func imagePickerControllerDidCancel(_ picker: UIImagePickerController) {
        Logger.main.debug("User canceled picking up photo")
        // 4
        continuation?.resume(returning: UIImage())
      }
    }
    

    First, you’ll notice the pickImage() function is async because it needs to wait for users to select an image, and once they do, return it.

    Next are these four points of interest:

    1. On hitting withCheckedContinuation the execution is suspended, and a continuation is created and passed to the completion handler. In this scenario, you use the non-throwing variant because the async function pickImage() isn’t throwing.
    2. The continuation is saved in the class so you can resume it later, once the delegate returns.
    3. Then, once the user selects an image, the resume is called, passing the image as argument.
    4. If the user cancels picking an image, you return an empty image — at least for now.

    Once the execution is resumed, the image returned from the continuation is returned to the caller of the pickImage() function.

    Using Image Picker Service

    Open ContentViewModel.swift, and modify it as follows:

    1. Remove the inheritance from NSObject on the ContentViewModel declaration. This isn’t required now that ImagePickerService implements UIImagePickerControllerDelegate.
    2. Delete the corresponding extension implementing UIImagePickerControllerDelegate and UINavigationControllerDelegate functions, you can find it under // MARK: - Image Picker Delegate. Again, these aren't required anymore for the same reason.

    Then, add a property for the new service named imagePickerService under your noImageCaption and imageClassifierService variables. You’ll end up with these three variables in the top of ContentViewModel:

    
    private static let noImageCaption = "Select an image to classify"
    private lazy var imageClassifierService = try? ImageClassifierService()
    lazy var imagePickerService = ImagePickerService()
    

    Finally, replace the previous implementation of pickImage() with this one:

    
    @MainActor
    func pickImage() {
      presentImagePicker = true
    
      Task(priority: .userInitiated) {
        let image = await imagePickerService.pickImage()
        presentImagePicker = false
    
        if let image {
          self.image = image
          classifyImage(image)
        }
      }
    }
    

    As pickImage() is a synchronous function, you must use a Task to wrap the asynchronous content. Because you’re dealing with UI here, you create the task with a userInitiated priority.

    The @MainActor attribute is also required because you’re updating the UI, self.image here.

    After all the changes, your ContentViewModel should look like this:

    
    class ContentViewModel: ObservableObject {
      private static let noImageCaption = "Select an image to classify"
      private lazy var imageClassifierService = try? ImageClassifierService()
      lazy var imagePickerService = ImagePickerService()
    
      @Published var presentImagePicker = false
      @Published private(set) var image: UIImage?
      @Published private(set) var caption = noImageCaption
    
      @MainActor
      func pickImage() {
        presentImagePicker = true
    
        Task(priority: .userInitiated) {
          let image = await imagePickerService.pickImage()
          presentImagePicker = false
    
          if let image {
            self.image = image
            classifyImage(image)
          }
        }
      }
    
      private func classifyImage(_ image: UIImage) {
        caption = "Classifying..."
        guard let imageClassifierService else {
          Logger.main.error("Image classification service missing!")
          caption = "Error initializing Neural Model"
          return
        }
    
        DispatchQueue.global(qos: .userInteractive).async {
          imageClassifierService.classifyImage(image) { result in
            let caption: String
            switch result {
            case .success(let classification):
              let description = classification.description
              Logger.main.debug("Image classification result: \(description)")
              caption = description
            case .failure(let error):
              Logger.main.error(
                "Image classification failed with: \(error.localizedDescription)"
              )
              caption = "Image classification error"
            }
    
            DispatchQueue.main.async {
              self.caption = caption
            }
          }
        }
      }
    }
    

    Finally, you need to change the UIImagePickerController‘s delegate in ContentView.swift to point to the new delegate.

    To do so, replace the .sheet with this:

    
    .sheet(isPresented: $contentViewModel.presentImagePicker) {
      ImagePickerView(delegate: contentViewModel.imagePickerService)
    }
    

    Build and run. You should see the image picker working as before, but it now uses a modern syntax that’s easier to read.

    Continuation Checks

    Sadly, there is an error in the code above!

    Open the Xcode Debug pane window and run the app.

    Now, pick an image, and you should see the corresponding classification. When you tap Pick Image again to pick another image, Xcode gives the following error:

    Continuation Leak For Reuse

    Swift prints this error because the app is reusing a continuation already used for the first image, and the standard explicitly forbids this! Remember, you must use a continuation once, and only once.

    When using the Checked continuation, the compiler adds code to enforce this rule. When using the Unsafe APIs and you call the resume more than once, however, the app will crash! If you forget to call it at all, the function never resumes.

    Although there shouldn’t be a noticeable overhead when using the Checked API, it’s worth the price for the added safety. As a default, prefer to use the Checked API. If you want to get rid of the runtime checks, use the Checked continuation during development and then switch to the Unsafe when shipping the app.

    Open ImagePickerService.swift, and you’ll see the pickImage now looks like this:

    
    func pickImage() async -> UIImage? {
      return await withCheckedContinuation { continuation in
        if self.continuation == nil {
          self.continuation = continuation
        }
      }
    }
    

    You need to make two changes to fix the error herein.

    First, always assign the passed continuation, so you need to remove the if statement, resulting in this:

    
    func pickImage() async -> UIImage? {
      await withCheckedContinuation { continuation in
        self.continuation = continuation
      }
    }
    

    Second, set the set the continuation to nil after using it:

    
    func imagePickerController(
      _ picker: UIImagePickerController,
      didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]
    ) {
      Logger.main.debug("User picked photo")
      continuation?.resume(returning: info[.originalImage] as? UIImage)
      // Reset continuation to nil
      continuation = nil
    }
    
    func imagePickerControllerDidCancel(_ picker: UIImagePickerController) {
      Logger.main.debug("User canceled picking up photo")
      continuation?.resume(returning: UIImage())
      // Reset continuation to nil
      continuation = nil
    }
    

    Build and run and verify that you can pick as many images as you like without hitting any continuation-leak error.

    Replacing Callback-Based APIs with Continuation

    Time to move on and modernize the remaining part of ContentViewModel by replacing the completion handler in the classifyImage(:) function with a sleeker async call.

    As you did for refactoring UIImagePickerController, you’ll create a wrapper component that wraps the ImageClassifierService and exposes an async API to ContentViewModel.

    In this case, though, you can also extend the ImageClassifier itself with an async extension.

    Open ImageClassifierService.swift and add the following code at the end:

    
    // MARK: - Async/Await API
    extension ImageClassifierService {
      func classifyImage(_ image: UIImage) async throws -> ImageClassifierService.Classification {
        // 1
        return try await withCheckedThrowingContinuation { continuation in
          // 2
          classifyImage(image) { result in
            // 3
            if case let .success(classification) = result {
              continuation.resume(returning: classification)
              return
            }
          }
        }
      }
    }
    

    Here’s a rundown of the code:

    1. As in the previous case, the system blocks the execution on hitting the await withCheckedThrowingContinuation.
    2. You don’t need to store the continuation as in the previous case because you’ll use it in the completion handler. Just call the old callback-based API and wait for the result.
    3. Once the component invokes the completion callback, you call continuation.resume<(returning:) passing back the classification received.

    Adding an extension to the old interface allows use of the two APIs simultaneously. For example, you can start writing new code using the async/await API without having to rewrite existing code that still uses the completion callback API.

    You use a Throwing continuation to reflect that the ImageClassifierService can throw an exception if something goes wrong.

    Using Async ClassifyImage

    Now that ImageClassifierService supports async/await, it’s time to replace the old implementation and simplify the code. Open ContentViewModel.swift and change the classifyImage(_:) function to this:

    
    @MainActor
    private func classifyImage(_ image: UIImage) async {
      guard let imageClassifierService else {
        Logger.main.error("Image classification service missing!")
        caption = "Error initializing Neural Model"
        return
      }
    
      do {
        // 1
        let classification = try await imageClassifierService.classifyImage(image)
        // 2
        let classificationDescription = classification.description
        Logger.main.debug(
          "Image classification result: \(classificationDescription)"
        )
        // 3
        caption = classificationDescription
      } catch let error {
        Logger.main.error(
          "Image classification failed with: \(error.localizedDescription)"
        )
        caption = "Image classification error"
      }
    }
    

    Here’s what’s going on:

    1. You now call the ImageClassifierService.classifyImage(_:) function asynchronously, meaning the execution will pause until the model has analyzed the image.
    2. Once that happens, the function will resume using the continuation to the code below the await.
    3. When you have a classification, you can use that to update caption with the classification result.

    Note: In a real app, you’d also want to intercept any throwing exceptions at this level and update the image caption with an error message if the classification fails.

    There’s one final change before you’re ready to test the new code. Since classifyImage(_:) is now an async function, you need to call it using await.

    Still in ContentViewModel.swift, in the pickImage function, add the await keyword before calling the classifyImage(_:) function.

    
    @MainActor
    func pickImage() {
      presentImagePicker = true
    
      Task(priority: .userInitiated) {
        let image = await imagePickerService.pickImage()
        presentImagePicker = false
    
        if let image {
          self.image = image
          await classifyImage(image)
        }
      }
    }
    

    Because you’re already in a Task context, you can call the async function directly.

    Now build and run, try picking an image one more time, and verify that everything works as before.

    Dealing With Continuation Checks … Again?

    You’re almost there, but a few things remain to take care of. :]

    Open the Xcode debug area to see the app’s logs, run and tap Pick Image; this time, however, tap Cancel and see what happens in the logs window.

    Continuation Leak For Missed Call

    Continuation checks? Again? Didn’t you fix this already?

    Well, that was a different scenario. Here’s what’s happening this time.

    Once you tap Cancel, ImagePickerService returns an empty UIImage, which causes CoreML to throw an exception, not managed in ImageClassificationService.

    Contrary to the previous case, this continuation’s resume is never called, and the code therefore never returns.

    To fix this, head back to the ImageClassifierService and modify the async wrapper to manage the case where the model throws an exception. To do so, you must check whether the results returned in the completion handler are valid.

    Open the ImageClassifierService.swift file and replace the existing code of your async throwing classifyImage(_:) (the one in the extension) with this:

    
    func classifyImage(_ image: UIImage) async throws -> ImageClassifierService.Classification {
      return try await withCheckedThrowingContinuation { continuation in
        classifyImage(image) { result in
          switch result {
          case .success(let classification):
            continuation.resume(returning: classification)
          case .failure(let error):
            continuation.resume(throwing: error)
          }
        }
      }
    }
    

    Here you use the additional continuation method resume(throwing:) that throws an exception in the calling method, passing the specified error.

    Because the case of returning a Result type is common, Swift also provides a dedicated, more compact instruction, resume(with:) allowing you to reduce what’s detailed above to this instead:

    
    func classifyImage(_ image: UIImage) async throws -> ImageClassifierService.Classification {
      return try await withCheckedThrowingContinuation { continuation in
        classifyImage(image) { result in
          continuation.resume(with: result)
        }
      }
    }
    

    Gotta love it! Now, build and run and retry the flow where the user cancels picking an image. This time, no warnings will be in the console.

    One Final Fix

    Although the warning about lacking continuation is gone, some UI weirdness remains. Run the app, pick an image, then try picking another one and tap Cancel on this second image.

    As you see, the previous image is deleted, while you might prefer to maintain it if the user already selected one.

    The final fix consists of changing the ImagePickerService imagePickerControllerDidCancel(:) delegate method to return nil instead of an empty image.

    Open the file ImagePickerService.swift and make the following change.

    
    func imagePickerControllerDidCancel(_ picker: UIImagePickerController) {
      Logger.main.debug("User canceled picking an image")
      continuation?.resume(returning: nil)
      continuation = nil
    }

    With this last modification, if the user cancels picking up an image, the pickImage() function of ImagePickerService returns nil, meaning ContentViewModel will skip setting the image and calling classifyImage(_:) at all.

    Build and run one last time and verify the bug is gone.

    Where to Go From Here?

    Well done! You streamlined your code and now have a consistent code style in ContentViewModel.

    You started with a ContentViewModel that contained different code styles and had to conform to NSObject due to delegate requirements. Little by little, you refactored this to have a modern and easier-to-follow implementation using the async/await Continuation API.

    Specifically, you:

    • Replaced the delegate-based component with an object that wraps the delegate and exposes an async function.
    • Made an async extension for completion handler-based component to allow a gradual rewrite of existing parts of the app.
    • Learned the differences between using Checked and Unsafe continuations and how to handle the corresponding check errors.
    • Were introduced to the types of continuation functions, including async and async throwing.
    • Finally, you saw how to resume the execution using the resume instructions and return a value from a continuation context.

    It was a fun run, yet as always, this is just the beginning of the journey. :]

    To learn more about the Continuation API and the details of the Swift Concurrency APIs, look at the Modern Concurrency in Swift book.

    You can download the complete project using the Download Materials button at the top or bottom of this tutorial.

    We hope you enjoyed this tutorial. If you have any questions, suggestions, comments or feedback, please join the forum discussion below!


    Source link

    Leave a Reply

    Your email address will not be published. Required fields are marked *

    Back to top button
    WP Twitter Auto Publish Powered By : XYZScripts.com
    SiteLock Consent Preferences

    Adblock Detected

    Please consider supporting us by disabling your ad blocker