跳转至

Chapter 5: Intermediate async/await & CheckedContinuation

In the previous chapter, you worked through creating custom asynchronous sequences. At this point, you should already feel right at home when it comes to using AsyncSequence and AsyncStream.

You saw that wrapping existing APIs, like Timer and NotificationCenter, is very powerful, letting you reuse your tried-and-tested code in your modern async/await codebase.

In this chapter, you’ll continue working in the same direction. You’ll look into more ways to reuse existing code to the fullest by leveraging Swift’s superpowered concurrency features.

Introducing continuations

Two patterns form the cornerstone of asynchronous programming on Apple platforms: callbacks and the delegate pattern. With completion callbacks, you pass in a closure that executes when the work completes. With the delegate pattern, you create a delegate object, then call certain methods on it when work progresses or completes:

img

To encourage the new concurrency model’s adoption, Apple designed a minimal but powerful API that comes in handy when bridging existing code. It centers around the concept of a continuation.

A continuation is an object that tracks a program’s state at a given point. The Swift concurrency model assigns each asynchronous unit of work a continuation instead of creating an entire thread for it. This allows the concurrency model to scale your work more effectively based on the capabilities of the hardware. It creates only as many threads as there are available CPU cores, and it switches between continuations instead of between threads, making it more efficient.

You’re familiar with how an await call works: Your current code suspends execution and hands the thread and system resources over to the central handler, which decides what to do next.

When the awaited function completes, your original code resumes, as long as no higher priority tasks are pending. But how?

When the original code suspends, it creates a continuation that represents the entire captured state at the point of suspension. When it’s time to resume execution or throw, the concurrency system recreates the state from the continuation and the work… well, continues.

img

This all happens behind the scenes when you use async functions. You can also create continuations yourself, which you can use to extend existing code that uses callbacks or delegates. These APIs can benefit from using await as well.

Manually creating continuations allows you to migrate your existing code gradually to the new concurrency model.

Creating continuations manually

There are two continuation API variants:

  1. CheckedContinuation: A mechanism to resume a suspended execution or throw an error. It provides runtime checks for correct usage and logs any misuse.
  2. UnsafeContinuation: An alternative to CheckedContinuation, but without the safety checks. Use this when performance is essential and you don’t need the extra safety.

Note: The APIs are essentially identical, so you’ll only work with CheckedContinuation in this chapter. For any function mentioned in this chapter that has “checked” in its name, you can assume there’s an “unsafe” equivalent as well.

You don’t normally initialize a continuation yourself. Instead, you use one of two handy generic functions that take a closure. The closure provides a ready-to-use continuation as an input parameter:

  • withCheckedContinuation(_:): Wraps the closure and gives you a checked continuation back.
  • withCheckedThrowingContinuation(_:): Wraps a throwing closure. Use this when you need error handling.

You must resume the continuation once — and exactly once. Enforcing this rule is the difference between checked and unsafe continuations. You resume a continuation by using one of the following ways:

  • resume(): Resumes the suspended task without a value.
  • resume(returning:): Resumes the suspended task and returns the given value.
  • resume(throwing:): Resumes the suspended task, throwing the provided error.
  • resume(with:): Resumes with a Result containing a value or an error.

The methods above are the only ones you can call on a continuation, which is yet another easy-to-use, minimal type.

Next, you’ll wrap CLLocationManagerDelegate to learn how to quickly use continuations to reuse your existing code.

Wrapping the delegate pattern

In this chapter, you’ll continue working on the Blabber project, starting where you left off at the end of the last chapter. If you’ve worked through the challenges, just keep up the great work. Otherwise, you can start with this chapter’s starter project, which includes the solved challenge.

Start the book server now, if you haven’t already. Navigate to the server folder in the book materials-repository, 00-book-server, and enter swift run. The detailed steps are covered in Chapter 1, “Why Modern Swift Concurrency?”.

Some APIs use the delegate pattern to continuously “talk” to their delegate — for example, to send progress updates or notifications about app state changes. When you need to handle multiple values, you should use an AsyncStream to bridge the delegate pattern to newer code.

In other cases, like in this chapter, you’ll need to handle a single delegation callback or a completion — and that’s the perfect opportunity to use a continuation!

In the next few sections, you’ll focus on letting the users share their location in chat:

img

When you work with location data, you need to reach out to one of the oldest frameworks in iOS: CoreLocation.

Note: Offering apps that were capable of providing location-based services was one of the iPhone 2’s killer features — and one of the reasons why it became a huge success. CoreLocation is one of the frameworks that iOS 2 initially made available to third-party developers.

As a classic API, CoreLocation heavily relies on delegates, making it a perfect candidate for you to learn how to interoperate between async/await code and those older patterns.

The main type you usually deal with in the CoreLocation framework is CLLocationManager. When you ask this type to start location updates, it repeatedly calls its delegate with the current device location:

img

In Blabber, you don’t want to share the user location continuously, but only once — when the user taps the location button. Still, the location manager doesn’t provide a callback API that lets you get just a single location. You’ll need to create your own delegate type and code the logic to stop updates after the first location comes through.

Open BlabberModel.swift and scroll to shareLocation(). This method is already wired to the location button in the chat screen:

img

Managing the authorizations

You’ll get started by creating a location manager and verifying that the user has authorized the app to use the device location data. At this point, users who are running the app for the first time will see the standard system dialogue that asks them to grant authorization:

img

Before dealing with any CoreLocation-specific work, add the following code that creates a continuation:

let location: CLLocation = try await 
withCheckedThrowingContinuation { [weak self] continuation in

}

withCheckedThrowingContinuation(_:) takes a throwing closure, suspends the current task, then executes the closure. You should call your asynchronous code from within the closure, then resume the continuation argument when you’re done. In this case, you’ll resume with an error or a location.

You can pass continuation around like any other variable, storing it in your model or passing it over to other functions. Wherever it ends up, calling one of its resume(...) methods will always resume the execution at the original call site.

Also, you might have noticed that the function contains “checked” in its name. That indicates the runtime checks if you use the continuation safely.

Build and run the project. Tap the location button, and then tap Allow While Using App in the privacy dialogue:

img

Log in and tap the location button. You won’t see an error onscreen. However, look at the Xcode console output, and you’ll see the following, between other logs:

SWIFT TASK CONTINUATION MISUSE: shareLocation() leaked its continuation!

The runtime detected that you never used continuation and that the variable was released at the end of the closure. Long story short, your code at try await withCheckedThrowingContinuation(...) will never successfully resume from its suspension point.

As mentioned earlier, you must call a resume(...) method exactly once from each code path.

Next, you’ll fix this by integrating your continuation with a newly minted delegate.

Handling the location errors

Open Utility/ChatLocationDelegate.swift, where you’ll find the placeholder type ChatLocationDelegate. Notice that all the CLLocationManagerDelegate requirements are optional, so the file compiles without any of CLLocationManagerDelegate’s methods.

You’ll add two methods to handle location updates and location errors.

First of all, inside the class definition, add a new type alias for a throwing continuation that returns a location:

typealias LocationContinuation = CheckedContinuation<CLLocation, Error>

That alias name will make your code a little less verbose.

Since your delegate holds on to the continuation until it receives a location, you need to store it in a property. You’ll also add a CLLocationManager to feed your proxy delegate with any updates. Add both of these properties, like so:

private var continuation: LocationContinuation?
private let manager = CLLocationManager()

You also need a new initializer so you can inject the continuation and also ask the location manager for the needed permissions. Add that next:

init(continuation: LocationContinuation) {
  self.continuation = continuation
  super.init()
  manager.delegate = self
  manager.requestWhenInUseAuthorization()
}

First of all, note the use of super.init(); this lets you set self as the location manager’s delegate before requesting authorization.

Then, you call requestWhenInUseAuthorization() to show the system privacy dialogue, which sets the authorization status on the manager object. If the user has already granted permissions, the method does nothing. You’ll deal with the various authorization values later in the chapter.

Note: You’ll need to grant location permissions to continue with this chapter. If you denied location usage by mistake, or you wanted to test what happens if you rejected the permissions, don’t worry — just delete the app from the iOS Simulator. The next time you run the project, you’ll get the authorization dialogue again.

The first delegate method you need is the one that gets called when the location permissions update. This happens when the permissions have been granted and immediately after the location manager is created. Add the following delegate method to ChatLocationDelegate:

func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
  switch manager.authorizationStatus {
  case .notDetermined:
    break
  case .authorizedAlways, .authorizedWhenInUse:
    manager.startUpdatingLocation()
  default:
    continuation?.resume(
      throwing: "The app isn't authorized to use location data"
    )
    continuation = nil
  }
}

If the user hasn’t responded to the permissions request, which would happen the first time they run the app, you do nothing. If they’ve granted the permissions, you tell the location manager to start getting location data. Otherwise, you’ll resume the continuation with an error.

After resuming, you destroy the continuation because doing anything else with it is illegal.

Next, add the delegate method that’s called when the user’s location updates:

func locationManager(
  _ manager: CLLocationManager,
  didUpdateLocations locations: [CLLocation]
) {
  guard let location = locations.first else { return }
  continuation?.resume(returning: location)
  continuation = nil
}

The locations argument contains a list of CLLocation values. Here, it’s safe to take the first one and pass it on to your own code.

Additionally, you call continuation?.resume(returning:) to resume the original code execution and return the first location from the suspension point:

img

Finally, just like before, you set the continuation property to nil.

Next, add error handling via locationManager(_:didFailWithError:):

func locationManager(
  _ manager: CLLocationManager, 
  didFailWithError error: Error
) {  
  continuation?.resume(throwing: error)
  continuation = nil
}

If the manager fails to fetch the device location, it calls this method on its delegate so you can update your app accordingly.

You use continuation?.resume(throwing:) to resume your original code at the suspension point and throw the given error:

img

At the end, as you did before, you set continuation to nil to release the continuation you just used.

You now have the complete workflow in place: Once you set up the location manager with the delegate, it will try to fetch the current location and will call one of the methods you’ve wired to use the injected continuation:

img

Additionally, when the continuation resumes, you reset continuation so you can’t use it more than once.

This concludes the setup. Now, it’s time to start the updates and set the whole machinery in motion.

Using your delegate

Inside the closure of withCheckedThrowingContinuation(_:), insert the following:

self?.delegate = ChatLocationDelegate(continuation: continuation)

You just created a ChatLocationDelegate and injected the continuation you got from withCheckedThrowingContinuation(_:) to it. You store the resulting delegate to a predefined delegate property to make sure it isn’t immediately released from memory.

After that, what happens is:

  1. The manager calls the change authorization delegate method when it initializes.
  2. After the user grants permissions, the manager fetches the device location.
  3. The manager calls the delegate with an array of CLLocations.
  4. The delegate calls continuation and resumes by returning the first available CLLocation.
  5. The original call site let location: CLLocation = try await withCheckedThrowingContinuation ... resumes execution, letting you use the returned location value.

To test the result, append the following code at the very bottom of the function, after withCheckedThrowingContinuation:

print(location.description)

When the process completes, you’ll see the location object printed in Xcode’s console.

Build and run. Tap the location button to give the new feature a try.

There could be two outcomes of this. You will either see a location printed in the output console if you’ve used Xcode to simulate location data in the past. Alternatively, you will see an error like so:

img

By the way — how cool is that?

You didn’t write any special code to handle the error — you pipe in the error from your delegate, then your continuation re-throws it. Finally, you catch the error seamlessly in the button action. For the last mile, the starter SwiftUI code updates lastErrorMessage on the chat view, which pops the alert box onscreen.

If you haven’t tested location-aware apps in Xcode before, you need to enable location data in the iOS Simulator.

At the bottom of the code editor in Xcode, click the location button and pick one of the default locations in the list:

img

Once you’re feeding location data successfully into the iOS Simulator, you’ll see the location icon fill with color:

img

Log in again and tap the location button. This time, you’ll see the coordinates of your selected location in the console:

<+19.01761470,+72.85616440> +/- 5.00m (speed -1.00 mps / course -1.00) ...

Great work so far! You’ve gone through setting up a continuation and creating a proxy delegate. Now, you can apply this approach in basically any scenario.

To exercise this routine one more time, you’ll look into wrapping up a callback-based API next.

Wrapping callback APIs with continuation

In the system frameworks that Apple introduced after iOS 4, most asynchronous APIs are callback-based.

That means that when you call a given method, you provide a parameter with a closure that executes asynchronously when the method finishes its work.

For example, if your app wants to request authorization to provide parental controls, you need to call AuthorizationCenter.requestAuthorization(completionHandler:) from Apple’s FamilyControls framework, like so:

AuthorizationCenter.shared
  .requestAuthorization { result in

  }

Calling this API displays the system UI that asks for authorization, if necessary. After an arbitrary amount of time, depending on the user’s actions, it calls back to your closure. It returns the authorization status via the result closure argument.

Having a single closure is arguably a little easier to wrap with a continuation than creating a separate delegate type, as you did earlier in the chapter.

In this section, you’ll continue working on BlabberModel.shareLocation() by wrapping a custom callback-based API that turns a location into a human-readable address.

The Blabber starter project includes a custom type called AddressEncoder. It converts a location to a human-readable address via a classic callback API: AddressEncoder.addressFor(location:completion:).

In this section, you’ll work through calling that API and using a continuation to make it fit seamlessly with the rest of your asynchronous code.

Creating the closure

Open BlabberModel.swift and scroll back to the method called shareLocation(), where you added your delegate wrapping code.

To make the new call to AddressEncoder, add this code at the bottom of shareLocation():

let address: String = try await 
withCheckedThrowingContinuation { continuation in

}

You start this section the same way that you approached wrapping CLLocationManager’s delegate — by calling withCheckedThrowingContinuation(_:) to create a closure with a continuation to control asynchronous execution.

This time, you’ll return a String when you resume. That string will be the human-friendly address for the location coordinates you already have.

Now, insert this code inside the closure:

AddressEncoder.addressFor(location: location) { address, error in

}

Here, you call addressFor(location:completion:). In the completion callback, you receive an optional address and an optional error.

This is, unfortunately, a common pattern in Swift APIs, especially before the official introduction of the Result type.

This pattern opens the code for undesired scenarios — for example, when the closure receives both a nil result and a nil error…

You’ll have to make the best of the situation and try resuming with the correct behavior for each callback outcome. Add this switch inside the callback closure from above:

switch (address, error) {
case (nil, let error?):
  continuation.resume(throwing: error)
case (let address?, nil):
  continuation.resume(returning: address)
}

You switch over address and error:

  • When you get an error, you pipe it through to the continuation via continuation.resume(throwing:).
  • On the other hand, if you get an address back, you return it via continuation.resume(returning:).

So far, so good — but the compiler now complains that you need to handle all the possible combinations.

Add two more cases inside the switch statement to handle any unexpected callback input:

case (nil, nil):
  continuation.resume(throwing: "Address encoding failed")
case let (address?, error?):
  continuation.resume(returning: address)
  print(error)
}
  • If you get nil for both the address and the error, that’s clearly some kind of unknown error, so you throw a generic error: Address encoding failed.
  • If you get both an address and an error, you return the address — but also print the error so that the message remains in the app’s log.

That clears the compiler error, and you cover all the bases when it comes to unexpected callbacks from AddressEncoder.

Note: If you already peeked into the source code of AddressEncoder, you know that it will never call the completion closure with incorrect parameters. However, you can’t do that for APIs where you don’t have access to the source code. That’s why it’s important to handle invalid API usage defensively.

It’s time for the final line in shareLocation(). After your new withCheckedThrowingContinuation, append:

try await say("📍 \(address)")

Once you have the address as a string, you call BlabberModel.say(_:) to share it in chat.

Build and run one more time. Enable location simulation in Xcode and tap the location button in the app:

img

With that last addition to the Blabber app, you’ve covered most of the continuation APIs, and you’ve used continuations to bridge delegates and callbacks. Doing this will allow your async/await code to work alongside your existing codebase, not against it.

You’ll continue working with Blabber in the next chapter, where you’ll learn more about debugging and testing your asynchronous code.

If you’d like to work through one more exercise, stay around for this chapter’s optional challenge.

Challenges

Challenge: Build a command-line version of Blabber

This is an optional challenge that you can try on your own to exercise some of the concepts of the last few chapters.

In this challenge, you’ll build the Clipper app: a CLI (Command Line Interface) version of Blabber. It lets you grab a user name and chat with friends from a Terminal window.

In the introduction section of Chapter 1, “Why Modern Swift Concurrency?”, you covered platform restrictions for Swift concurrency features. As a reminder, you’re building a macOS app here. That means that if you’re using Xcode 13.2 or newer, you can run this challenge on macOS 10.15 or later. If you’re using an earlier version of Xcode 13, you have to be running macOS 12.

This project’s twist is that, as a command-line app, it’s an exercise in creating a complete chat app in about 30 lines of code.

When you’ve successfully completed the challenge, you’ll be able to open multiple Terminal windows and chat between your alter egos.

img

Open the starter challenge project for this chapter by double-clicking Package.swift. The Clipper app consists of a single source file called main.swift.

Inside, you’ll find:

  • A long-living URLSession for live updates called liveURLSession.
  • One Task that accesses the /cli/chat server endpoint. Its job is to print the chat messages.
  • A second Task that iterates over the standard user input and sends any messages the user enters to the server.

Believe it or not — that’s the complete chat app!

You can try completing the code on your own; if you prefer the guided tour, however, follow these steps:

  1. Inside the do block in the first Task, get a bytes stream of the url address defined in the task. Then, iterate over the lines, like you did in previous chapters, and print each line. Use liveURLSession so the request doesn’t time out.

That’s all! To test the chat, follow these steps:

  • Start the book server.
  • Open multiple Terminal windows.
  • In each window, change the current directory to the Clipper folder — the folder containing Package.swift.
  • In each window, type swift run Clipper [username] and replace [username] with the chat name you’d like to use.
  • You’ll see a message from the server confirming you’re connected: [username connected].
  • Now, you can chat by entering messages in each Terminal.

Key points

  • You bridge older asynchronous design patterns to async/await by using CheckedContinuation or its unsafe counterpart, UnsafeCheckedContinuation.
  • For each of your code paths, you need to call one of the continuation’s resume(...) methods exactly once to either return a value or throw an error.
  • You get a continuation by calling either withCheckedContinuation(_:) or withCheckedThrowingContinuation(_:).