Swift Concurrency Continuations: Getting Started
Apple greatly improved how to write asynchronous code in Swift with the introduction of Swift Concurrency and the async/await API. They also introduced the Continuation API, and you can use this in place of delegates and completion callbacks. You can greatly streamline your code by mastering and using this API.
You’ll learn all about the Continuation API in this tutorial. Specifically, you’ll update the tutorial app WhatsApp 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
Getting Started
Download the starter project by clicking the Download Materials button 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.
Here above is Zohar, 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.
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 UIKitUIImagePickerController
.
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:
- You indicate this method uses the async/await API by declaring
async
on its signature. - The
await
instruction is known as a “suspension point.” Here, you tell the system to suspend the method whenawait
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.
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 returningVoid
. -
resume(returning:)
resumes the execution returning the specified argument. -
resume(throwing:)
resumes the execution throwing an exception and is used forThrowingContinuation
only. -
resume(with:)
resumes the execution passing aResult
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.
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.
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:
- 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 functionpickImage()
isn’t throwing. - The
continuation
is saved in the class so you can resume it later, once the delegate returns. - Then, once the user selects an image, the
resume
is called, passing the image as argument. - 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:
- Remove the inheritance from
NSObject
on theContentViewModel
declaration. This isn’t required now thatImagePickerService
implementsUIImagePickerControllerDelegate
. - Delete the corresponding extension implementing
UIImagePickerControllerDelegate
andUINavigationControllerDelegate
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:
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:
- As in the previous case, the system blocks the execution on hitting the
await withCheckedThrowingContinuation
. - 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.
- 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:
- You now call the
ImageClassifierService.classifyImage(_:)
function asynchronously, meaning the execution will pause until the model has analyzed the image. - Once that happens, the function will resume using the continuation to the code below the
await.
- 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 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
andUnsafe
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!