© Andrés Ibañez Kautsch 2023
A. I. KautschModern Concurrency on Apple Platformshttps://doi.org/10.1007/978-1-4842-8695-1_3

3. Continuations

Andrés Ibañez Kautsch1  
(1)
La Paz, Bolivia
 

In the last chapter, we learned about the async and await keywords to get started with the new concurrency system in Swift. We migrated a project that was using closure-based calls into async/await code. We did so by rewriting the original implementations of the methods to adapt them to the new system. But in the real world, you may not have the luxury of writing such implementations from scratch due to time constraints, or worse, technical difficulties. For these scenarios, we have Continuations, which will allow to “wrap” existing closure-based calls into async/await – and even delegate-based ones!

Continuations are useful, because not only will you be able to provide async/await versions of your own closure-based code , but also for third-party libraries and frameworks. This will help you create a consistent codebase that only uses async/await instead of closure-based calls when it makes sense to do so.

Note

Not all closure-based code is necessarily so because it deals with concurrency and/or multithreading. There are multiple calls in both Swift and Apple’s SDKs that take closures and have nothing to do with concurrency. For example, the popular methods of collections, filter, map, and reduce, take closures to operate on their elements. Keep this in mind because it’s very hard to have projects that don’t have closures of any kind, and that’s fine. async/await aims to eliminate closure-based calls for concurrency-related tasks only.

Understanding Continuations

We have briefly touched the concepts of Continuations in previous chapters, and we will work through this chapter keeping that same meaning.

In short, a Continuation is whatever happens after an async call is finished. When we are using async/await , a continuation is simply everything below an await call. If you are using closure-based code , the continuation is the code written within your completion handlers. And if you are using delegate-based code , the continuation would be whatever methods can be called after an action is done, such as the original UIImagePickerController’s imagePickerController(didFinishPickingMediaWithInfo:) method.

The new concurrency system allows us to “convert” a continuation in the shape of closure-based code and even delegate-based code into async/await.

To better understand this short – but important – concept, we will go back to the original version of the Social Media App. We will use the version that doesn’t have async/await to illustrate this concept.

Converting closure-based calls into async/await

Open the Shared ➤ API ➤ UserAPI.swift file. Take a look at the fetchUserInfo(completionHandler:) method. For your convenience, Listing 3-1 shows the original implementation.
func fetchUserInfo(
    completionHandler: @escaping (_ userInfo: UserInfo?, _ error: Error?) -> Void
) {
    let url = URL(string: "https://www.andyibanez.com/fairesepages.github.io/books/async-await/user_profile.json")!
    let session = URLSession.shared
    let dataTask = session.dataTask(with: url) { data, response, error in
        if let error = error {
            completionHandler(nil, error)
        } else if let data = data {
            do {
                let userInfo = try JSONDecoder().decode(UserInfo.self, from: data)
                completionHandler(userInfo, nil)
            } catch {
                completionHandler(nil, error)
            }
        }
    }
    dataTask.resume()
}
Listing 3-1

Original implementation for fetchUserInfo(completionHandler:)

You can provide an async/await version for this method without reimplementing it from scratch like we did in Chapter 2. The disadvantage of doing this is that you won’t be able to delete this original implementation. The advantage is that keeping the original implementation is not so bad, because you will always have the closure-based call for codebases that don’t want to adopt async/await yet. Listing 3-2 shows we can provide an async/await version of this method without writing it again.
func fechUserInfo() async throws -> UserInfo {
    // (1)
    try await withCheckedThrowingContinuation { continuation in
        // (2)
        fetchUserInfo { userInfo, error in
            // (3)
            if let userInfo = userInfo {
                continuation.resume(returning: userInfo)
            } else if let error = error {
                continuation.resume(throwing: error)
            } else {
                // Throw a generic error.
                let nsError = NSError(domain: "com.socialmedia.app", code: 400)
                continuation.resume(throwing: nsError)
            }
        }
    }
}
Listing 3-2

Using withCheckedThrowingContinuation

Listing 3-2 essentially wraps closure-based code within a Checked Continuation. withCheckedThrowingContinuation – and other variants we will explore in a bit – is a method that provides a closure with a parameter. The parameter it gives you is the actual continuation, and you are responsible for resuming it when your asynchronous call with the closure is finished. In Listing 3-1, the continuation is of type CheckedContinuation<T, E>, meaning that we can resume the continuation with either an object or an error. This makes perfect sense because anything with a network call always has a chance of failing.

Let’s walk through Listing 3-2 step by step:
  • (1) will call withCheckedThrowingContinuation . This is an async function that can throw. For this reason, we call it with try await, and the function signature is similar to the async version of this code we saw in Chapter 2. When calling this method, we need to pass it a closure that will provide us with a continuation object we need to resume after it’s done.

  • (2) will call the closure-based version of fetchUserInfo(completionHandler:) as you would call it in any other context.

  • (3) is where the magic happens. If fetchUserInfo(completionHandler:) finishes successfully and we have a UserInfo object, we will resume the continuation by calling continuation.resume(returning: userInfo). If we get an error, we will instead call continuation.resume(throwing: error). When calling the continuation.resume(returning:) method, it will cause the caller of the async version of fetchUserInfo() to return the resulting object, and calling continuation.resume(throwing:) will cause it to throw an error instead. In the end, the caller of the method in Listing 3-2 will behave the same as the caller in Listing 2-10 all the way back in Chapter 2. Keep in mind that when using continuations, you must call the continuation at some point exactly once. You cannot forget to call it, and you cannot call it more than once. If you forget to call it, you will be in a deadlocked state.

Alongside withCheckedThrowingContinuation , we have three additional methods we can use to create this type of continuations:
  • withCheckedContinuation

  • withUnsafeThrowingContinuation

  • withUnsafeContinuation

Use withCheckedContinuation when the original closure-based code does not give you back an error of any kind.

The unsafe variants – withUnsafeThrowingContinuation and withUnsafeContinuation – are like their Checked counterparts. The main difference is, when working with Checked Continuations , Swift will help protect you by logging errors when any violations are encountered – calling continuations more than once or forgetting to call them completely. Unsafe continuations do not have these features, but they could be slightly faster. In general, I do not recommend you use Unsafe continuations , but be aware that you can use them interchangeably in most situations.

Converting delegate-based code into async/await

Converting delegate-based code into async/await is a more involved process since these types of APIs may call different delegate methods depending on what kind of events are happening. This will create naturally “jumpy” code. While the calls may happen in the same thread, they can be unpredictable because of the way they call their methods. For example, the ContactsUI framework offers an object, CNContactPickerViewController , which notifies you when the user chooses a contact in one method, and if they cancel the selection, a different method will be called.

For this section, download the “Chapter 3 - Contact Picker (Base Project)” project.

The ContactPicker Project

This project is very simple (Figure 3-1). It consists of a top label with a static prompt “Select a Contact”. Underneath it, we have a button, “Select Contact” that, when tapped, will present a CNContactPickerViewController that our users can use to select a contact. Once a contact is selected, a new label will appear underneath the button with a black text showing the contact’s name. If they cancel the operation, the label will be red instead, and it will show the text “Cancelled”.

A screenshot depicts the contact picker, which includes the labels Wi Fi, full battery, and time 9:06, as well as the format, select a contact, below select contact button, and John Appleseed.

Figure 3-1

The ContactPicker app

Open the ViewController.swift file . The core of this example lies in the methods in Listing 3-3.
func contactPickerDidCancel(_ picker: CNContactPickerViewController) {
    selectedContactLabel.text = "Cancelled"
    selectedContactLabel.textColor = .red
    selectedContactLabel.isHidden = false
}
func contactPicker(_ picker: CNContactPickerViewController, didSelect contact: CNContact) {
    let nameFormatter = CNContactFormatter()
    nameFormatter.style = .fullName
    let contactName = nameFormatter.string(from: contact)
    selectedContactLabel.text = contactName
    selectedContactLabel.textColor = .black
    selectedContactLabel.isHidden = false
}
Listing 3-3

CNContactPickerViewController delegate methods

When our “Select Contact” button is tapped, the promptContact() method will be called. This will create, configure, and show a CNContactPickerDelegate. If the user cancels the operation, the contactPickerDidCancel(_) method will be called, setting the label to red with the text “Cancelled”. If the user selects a contact, the contactPicker(_:didSelect) method will be called instead, setting the label text to the contact name and its color to black. This is a simple example, but it illustrates the complexity of delegate-based calls.

Delegate-based APIs can be very messy, especially if they have a lot of possible method calls. We can create a more streamlined API for our callers by creating a wrapping object around CNContactPickerViewControllerDelegate and its methods.

Wrapping delegate-based calls in async/await alternatives

We will now proceed to create such a wrapper. We will modify the “Chapter 3 - ContactPicker (Base Project)” project. You can download the “Chapter 3 - ContactPicker (Async-Await)” project to see the final result.

Our task is to replace the current implementation of promptContact() , shown in Listing 3-4, with the new implementation in Listing 3-5, and getting rid of all the contact selection logic in ViewController along the way, such as the delegate conformance and delegate methods.
func promptContact() {
    let picker = CNContactPickerViewController()
    picker.delegate = self
    picker.displayedPropertyKeys = [CNContactGivenNameKey, CNContactNamePrefixKey, CNContactNameSuffixKey]
    present(picker, animated: true)
}
Listing 3-4

Original implementation for promptContact()

func promptContact() {
    async {
        let contactPicker = ContactPicker(viewController: self)
        if let contact = await contactPicker.pickContact() {
            // Contact selected…
        } else {
            // Selection Cancelled…
        }
    }
}
Listing 3-5

A new implementation for promptContact()

In Listing 3-5, we safely unwrap an optional, because if the user tapped Cancel instead of selecting a contact, we will have no contact information to show. You could alternatively throw an error when the user cancels the selection, but because it’s the only possible failure reason in this project, we will not use errors.

We will start by creating a new object. Create a new file, ContactPicker.swift, and add the code in Listing 3-6.
import Foundation
import ContactsUI
class ContactPicker: NSObject, CNContactPickerDelegate {
}
Listing 3-6

Starting ContactPicker

We will use this object to wrap all the logic of the contact picker. It will be responsible for showing the contact picker UI and returning the result to us.

Next, we need the basic properties alongside a typealias we will use to refer to the continuation type, as shown in Listing 3-7.
private typealias ContactCheckedContinuation = CheckedContinuation<CNContact?, Never>
private unowned var viewController: UIViewController
private var contactContinuation: ContactCheckedContinuation?
private var picker: CNContactPickerViewController
Listing 3-7

Basic properties

The viewController property will present the UI. We need to store the picker to show it when we call promptPicker(). Finally, because there are two different delegate methods , we need to store the checked continuation , so we can reference it when either delegate method is called.

The initializer should take the view controller that will present the picker. Listing 3-8 has the implementation for the initializer.
init(viewController: UIViewController) {
    self.viewController = viewController
    picker = CNContactPickerViewController()
    super.init()
    picker.delegate = self
}
Listing 3-8

The initializer for ContactPicker

The initializer takes a single property (the ViewController responsible from showing the picker). The picker is internal, so there’s no need to expose it. We will initialize the continuation when promptContact() is tapped.

promptContact() has a very interesting implementation. Listing 3-9 shows what we need to get this method to work.
@MainActor
func pickContact() async -> CNContact? {
    viewController.present(picker, animated: true)
    return await withCheckedContinuation({ (continuation: ContactCheckedContinuation) in
        self.contactContinuation = continuation
    })
}
Listing 3-9

The async version of pickContact()

We are marking this method with the @MainActor and not the whole class. This is because the ContactPicker class could be doing all sorts of different things in different threads, but we only care about the MainActor in this method because it interacts with the UI by presenting the contact picker modally.

The body of withCheckedContinuation does nothing but assign the continuation to our contactContinuation property. Again, we need to store the continuation somehow because we can resume it from two different delegate methods.

Listing 3-10 will implement the delegate methods we need: contactPicker(_:didSelect) and contactPickerDidCancel(_) .
@MainActor
func contactPicker(_ picker: CNContactPickerViewController, didSelect contact: CNContact) {
    contactContinuation?.resume(returning: contact)
    contactContinuation = nil
    picker.dismiss(animated: true, completion: nil)
}
@MainActor
func contactPickerDidCancel(_ picker: CNContactPickerViewController) {
    contactContinuation?.resume(returning: nil)
    contactContinuation = nil
}
Listing 3-10

Resuming the continuation from two different delegate methods

These methods are very straightforward. Both methods will resume the continuation, either passing a contact when selecting it, or passing in nil if no contact is selected (the selection has been cancelled). Assigning the contactContinuation property to nil after resuming it will ensure that we cannot call the continuation more than once.

We have finished implementing the ContactPicker class, so now we need to go back to ViewController. You can remove the CNContactPickerDelegate conformance and its associated delegate methods . You can also replace the promptContact() implementation with the code in Listing 3-11.
func promptContact() {
    Task {
        let contactPicker = ContactPicker(viewController: self)
        if let contact = await contactPicker.pickContact() {
            let nameFormatter = CNContactFormatter()
            nameFormatter.style = .fullName
            let contactName = nameFormatter.string(from: contact)
            selectedContactLabel.text = contactName
            selectedContactLabel.textColor = .black
            selectedContactLabel.isHidden = false
        } else {
            selectedContactLabel.text = "Cancelled"
            selectedContactLabel.textColor = .red
            selectedContactLabel.isHidden = false
        }
    }
}
Listing 3-11

The final implementation for pickContact()

ViewController is now significantly smaller. Tapping the Choose Contact button should behave exactly as it did before we changed it all to use async/await.

Note

While it is wonderful that we managed to offer an async/await version for a delegate-based call, you should consider if it’s really necessary to do so. Sometimes, the complexity of the delegate-based calls (CoreBluetooth, Core Location) will not be worthy to offer async/await versions for them. With that said, it’s a good exercise to work on to better understand how continuations work.

Supporting async/await in iOS 13 and 14

We mentioned this before, but it’s worth mentioning it again: While with Xcode 13.3 you can use async/await , Apple doesn’t offer any APIs that use them unless you compile with the iOS 15 SDK or above.

If you open the async/await version of our Social Media App from Chapter 2 (feel free to download the “Chapter 2 - Social Media App (Async-Await)(iOS 14)” if you don’t have it), and change the deployment target version to iOS 13 or iOS 14 , you will see the project doesn’t compile anymore. Most of the errors have to do with the missing async methods that do not exist. Figure 3-2 shows the errors that Xcode shows.

A screenshot represents the chapter two social media app of i O S with four issues, where swift compiler error, data, Asynclmage, and bordered prominent are only available in i O S 15.0 or newer.

Figure 3-2

We can’t use async/await with iOS 14 and lower

Xcode’s suggestion – to use the availability checks directly – sounds good in theory, but if you let Xcode fix it for you, the project will be in a worse state than it is now. Because it will wrap all our async calls in availability checks, the project will do nothing in iOS 13 and iOS 14 after you get it to run.

The solution is to use the availability check , but in extensions for URLSession and LAContext. We will fix this project without touching UserAPI and UserProfileViewModel. We will need to fix some SwiftUI issues later, but we don’t have to touch the logic code, making it easy to add async/await code without breaking any functionality. You can download the complete and fixed project as well, called “Chapter 2 - Social Media App (Async-Await)(iOS 14)(Fixed)”.

Start by creating a folder or group inside the “Shared” folder, and name it “Extensions”. Then inside that group, create a new file called “LAContext+Extensions.swift”. Its contents will look like Listing 3-12.
extension LAContext {
    @available(iOS, introduced: 13, deprecated: 15, message: "This method is no longer necessary. Use the API available in iOS 15.")
    func evaluatePolicy(_ policy: LAPolicy, localizedReason: String) async throws -> Bool {
        try await withCheckedThrowingContinuation({ continuation in
            self.evaluatePolicy(policy, localizedReason: localizedReason) { success, error in
                if let error = error {
                    continuation.resume(throwing: error)
                } else {
                    continuation.resume(returning: success)
                }
            }
        })
    }
}
Listing 3-12

Backporting evaluatePolicy(_:localizedReason) async to iOS 14

The @available check will ensure that this piece of code can only be run on iOS 13 and iOS 14 . Older versions of iOS do not support async/await, so we will just exclude them altogether. When the deployment target is iOS 15, we will get a warning that reads “This method is no longer necessary. Use the API available in iOS 15.” When we start targeting iOS 15, we will be able to completely remove this extension, and the code will just work without having to do any further changes.

The continuation here allows us to smoothly convert a closure-based call into an async/await call that already exists in the SDK, but when targeting iOS 15 and up only. Instead of returning an error, we convert the method into a throwing one.

In Figure 3-3, we see the warning in action, prompting us to update the code when running on a simulator lower than iOS 15 . You may need to clean the build folder and compile again to get the warning to show up:

A screenshot depicts three issues discovered in the social media app in I O S, where deprecation, and evaluate policy were deprecated in I O S 15, warning is to use the A P I available in I O S 15.

Figure 3-3

The warning in the @availabile check , prompting us to update the code

Next, we need to provide the data(from:) method in URLSession. Go to the “Shared” ➤ “Extensions” directory, and create a file called “URLSession+Extensions.swift”, with the code in Listing 3-13.
extension URLSession {
    @available(iOS, introduced: 13, deprecated: 15, message: "This method is no longer necessary. Use the API available in iOS 15.")
    func data(from url: URL) async throws -> (Data, URLResponse) {
        try await withCheckedThrowingContinuation({ continuation in
            self.dataTask(with: url) { data, response, error in
                if let error = error {
                    continuation.resume(throwing: error)
                } else {
                       continuation.resume(returning: (data!, response!))
                }
            }
            .resume()
        })
    }
}
Listing 3-13

Backporting the data(from:) method to iOS 13 and iOS 14

There’s not much difference with the code in Listing 3-13. Perhaps the most interesting part here is that the return type is a tuple of type (Data, Response), but in general, exposing this method to iOS 14 and 13 was not complicated. Do note that we are force-unwrapping the returning data and response properties. The reason we do so is because if you look at the API’s original async signature, they are not marked as optionals in the tuple, therefore it is safe to assume that if we do not get an error, these two variables are going to have non-nil values. If you doubt this, feel free to mark the tuple elements as optionals.

If you try to compile the project now, we still get two errors, but these errors have nothing to do with async/await anymore. The errors we need to fix are in UserProfileView , so open the “UserProfileView.swift” file.

The first error complains that there’s no AsyncImage in an iOS version lower than 15. We could provide our own implementation for a network image, but that goes out of the scope of this book. Instead of doing that, we will provide a simple blue circle when running on an iOS version lower than 15. The fix for this error is in Listing 3-14.
if #available(iOS 15.0, *) {
    AsyncImage(url: userInfo.avatarUrl)
        .frame(width: 150, height: 150)
        .aspectRatio(contentMode: .fit)
        .clipShape(Circle())
} else {
    Circle()
        .frame(width: 150, height: 150)
        .foregroundColor(.white)
}
Listing 3-14

Creating an alternative view in case the device is not on iOS 15

Finally, the final error we need to fix has to do with prominent styles for buttons being introduced in iOS 15 . The easiest way to fix is to remove the .buttonStyle(.borderedProminent) line from UserProfileView.

And that’s it! You have successfully ported two async calls that didn’t exist in iOS 14 and that are native to iOS 15 , and if you try the compile the app now, it should work without an issue. If you want to test the iOS 14 and iOS 13 compatibility, make sure you run it in a simulator with one of those iOS versions. If you have none, you can go to the “Window” menu on Xcode, followed by “Organizer”, and you can add new simulators with different iOS versions there.

Note that unfortunately you cannot automatically backport all async/await code to iOS 14 and lower for free. You need to manually create extensions and write the continuation method by hand. Keep that in mind if you have a lot of closure-based code you would like to backport.

Summary

In this chapter, we explored what continuations are, and we learned how we can use them to convert closure-based code into async/await code without having to write new implementations for the methods they intend to replace from scratch. We also learned how we can provide an async API for those calls that use delegates by creating wrapper objects around the delegate methods . Finally, we learned how we can use continuations to backport async/await code to iOS 14 and iOS 13 – we can’t go lower because this feature is only available on iOS 13, 14, and 15.

Exercises

Coding Exercise
Download the “Chapter 2 Exercise” project (the same base project we used in Chapter 2).
  1. 1.

    Provide async/await versions for the requestPermissions and requestData functions using checked continuations. You should not remove the original implementations.

     
  2. 2.

    Change the implementation of ContentViewViewModel.swift so it uses the async/await versions of those methods instead of the closure-based ones.

    The solutions can be found in the “Chapter 2 Exercise - Solved Continuations” project.

     
..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset
18.221.27.155