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.
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
async/await in SwiftUI
35 mins
- Getting Started
- Old and New Concurrency
- Pyramid of Doom
- Data Races
- Thread Explosion / Starvation
- Tasks and Continuations
- JokeService
- Minimal Error Handling
- Show Me a Joke!
- Concurrent Binding
- Awaiting async
- Sequential Binding
- Error Handling Options
- Creating an Unstructured Task
- Decoding the Joke
- MainActor
- Actor
- @MainActor
- One More Thing: Asynchronous View Modifiers
- Where to Go From Here?
- WWDC 2021 Videos
- Melbourne Cocoaheads
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.
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:
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.
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:
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.
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)
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)
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:
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.”
data
or response
, but not both.
Go ahead and accept the suggested fix to change let
to var
:
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.