async/await in SwiftUI

Convert a SwiftUI app to use the new Swift concurrency and find out what’s going on beneath the shiny surface. By Audrey Tam.

4.6 (16) · 1 Review

Download materials
Save for later
Share
You are currently viewing page 2 of 5 of this article. Click here to view the first page.

Tasks and Continuations

In Swift concurrency, the main unit of work is a task. A task executes jobs sequentially. To achieve concurrency, a task can create child tasks. Or you can create tasks in a task group.

The system knows these tasks are related so it can manage deadlines, priority and cancellation flags for all tasks in the task tree or group. This makes it easier for you to check and react to cancellation status, thus avoiding task leaks. If it’s important to react immediately to cancellation, you can write a function with a cancellation handler.

If a task suspends, it releases its thread and stores its state in a continuation. Threads switch between continuations instead of context switching.

Threads switch between continuations.

Threads switch between continuations.
Note: This image is from the WWDC session Swift concurrency: Behind the scenes

The keyword await marks a suspension point, and an async frame on the heap stores information that it needs when it resumes.

Ideally, the number of threads never exceeds the number of cores. There is a cooperative thread pool and a runtime contract that every thread will make progress. Your code maintains this contract by using await, actors and task groups to make dependencies visible to the compiler.

JokeService

Enough theory! Time to convert a simple download to use async/await.

The starter folder contains JokeService.swift. Add this file to WaitForIt.

JokeService is an ObservableObject that sends a request to an API that returns a random Chuck Norris joke. I’ve adapted this code from a sample app in Combine: Asynchronous Programming with Swift. The query item specifies the dev category, so all the jokes have a techie flavor. Warning: Some of these jokes are a little violent.

JokeService publishes a joke and its isFetching status. Its fetchJoke() method uses the standard URLSession.shared.dataTask with completion handler. If anything goes wrong, it prints an error message with either the dataTask error or “Unknown error”. If the latter, it provides no information on whether the problem was in the data or in the decoder.

Minimal Error Handling

Robust error handling is one of the main reasons for async/await. The data task completion handler can’t throw errors so, if it calls a throwing function like JSONDecoder().decode(_:from:), it has to handle any thrown errors.

It’s common to take the easy way out and just ignore the error. That’s what the starter file does:

if let decodedResponse = try? JSONDecoder().decode(Joke.self, from: data)

Previous Xcode versions suggest this as a fix if you write just try and don’t enclose it in a do/catch. It means: Simply assign nil if the function throws an error.

Delete ? to see what happens:

Translation: You can’t throw from here!

Translation: You can't throw from here!

Xcode now takes a harder line: No more helpful suggestions of easy fixes.

But ? still works here, so put it back.

Show Me a Joke!

To fetch a joke, open ContentView.swift and replace the contents of ContentView with this:

@StateObject var jokeService = JokeService()

var body: some View {
  ZStack {
    Text(jokeService.joke)
      .multilineTextAlignment(.center)
      .padding(.horizontal)
    VStack {
      Spacer()
      Button { jokeService.fetchJoke() } label: {
        Text("Fetch a joke")
          .padding(.bottom)
          .opacity(jokeService.isFetching ? 0 : 1)
          .overlay {
            if jokeService.isFetching { ProgressView() }
          }
      }
    }
  }
}

Run Live Preview and tap the button. It has a nice effect with opacity and ProgressView() to indicate a fetch is in progress.

A Chuck Norris joke

A Chuck Norris joke

Concurrent Binding

OK, the old way works, so now you’ll convert it to the new way.

Comment out URLSession down to and including .resume().

Add this code below isFetching = true:

async let (data, response) = URLSession.shared.data(from: url)

The new URLSession method data(from:) is asynchronous, so you use async let to assign its return value to the tuple (data, response). These are the same data and response that dataTask(with:) provides to its completion handler, but data(from:) returns them directly to the calling function.

Where’s the error that dataTask(with:) provides? You’ll find out soon — wait for it! ;]

These errors and suggested fixes appear:

You can’t call async from a non-async function.

You can't call async from a non-async function.

The errors are similar: You can’t call an asynchronous function in a synchronous function. You have to tell the compiler fetchJoke() is asynchronous.

Both fixes are the same, so click either one. This gives you:

func fetchJoke() async {

Like throws, the async keyword appears between the closing parenthesis and the opening brace. You’ll soon catch up with throws again.

Back to async let: This is one way to assign the result of data(from:) to the (data, response) tuple. It’s called a concurrent binding because the parent task continues execution after creating a child task to run data(from:) on another thread. The child task inherits its parent task’s priority and local values. When the parent task needs to use data or response, it suspends itself (releases its thread) until the child task completes.

The parent and child tasks run concurrently.

The parent and child tasks run concurrently.

Awaiting async

The verb for async is await in the same way the verb for throws is try. You try a throwing function and you await an async function.

Add this line of code:

await (data, response)

data(from:) throws.

data(from:) throws.

And there’s the missing error that dataTask(with:) provides to its completion handler: data(from:) throws it. So you must try await:

try! await (data, response)
Note: The keywords must be in this order, not await try.

You’re not really going to use this code, so you don’t bother to catch any thrown errors. This is just a chance to see what happens.

What happens is surprising:

Immutable value may only be initialized once.

Immutable value may only be initialized once.

It’s surprising because the Explore structured concurrency in Swift video says “And don’t worry. Reading the value of result again will not recompute its value.”

Note: This seems to be a tuple bug. You can await data or response, but not both.

Go ahead and accept the suggested fix to change let to var:

async can only be used with let declarations.

async can only be used with let declarations.

Hmph! Flashback to the early days of learning how to use Swift optionals with Xcode constantly saying “You can’t do that here”. Maybe it’s a beta bug. It doesn’t matter in this case because there’s no other code to execute between calling data(from:) and processing what it returns.