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.
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Contents
Swift Concurrency Continuations: Getting Started
25 mins
- Getting Started
- The Continuation API
- Suspending The Execution
- Resuming The Execution
- Replacing Delegate-Based APIs with Continuation
- Hello Image Picker Service
- Using Image Picker Service
- Continuation Checks
- Replacing Callback-Based APIs with Continuation
- Using Async ClassifyImage
- Dealing With Continuation Checks ... Again?
- One Final Fix
- Where to Go From Here?
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.
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.