Swift Concurrency Continuations: Getting Started

Continuations are a powerful part of Swift Concurrency that helps you to convert asynchronous code using delegates and callbacks into code that uses async/await calls, which is exactly what you will do in this article! By Alessandro Di Nepi.

Leave a rating/review
Download materials
Save for later
Share
You are currently viewing page 2 of 3 of this article. Click here to view the first page.

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.