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
Sequential Binding
Instead, you’ll use the other binding: sequential binding.
Replace the two lines with this:
let (data, response) = try? await URLSession.shared.data(from: url)
Unlike async let
, calling data(from:)
this way doesn’t create a child task. It runs sequentially as a job in the fetchJoke()
task. While it’s waiting for the server response, this job suspends itself, releasing the task’s thread.
But there’s a problem:
You try the lazy way, but Xcode won’t have it this time, not even if you use nil coalescing to specify a nil tuple:
let (data, response) =
try? await URLSession.shared.data(from: url) ?? (nil, nil)
Nope, you’ll have to do the right thing. First, delete the ?
and ?? (nil, nil)
:
let (data, response) = try await URLSession.shared.data(from: url)
Error Handling Options
You have two options for handling errors thrown by data(from:)
. The first is to bite the bullet and handle it right away with do/catch:
do {
let (data, response) = try await URLSession.shared.data(from: url)
} catch {
print(error.localizedDescription)
}
The easier(?) option is to make fetchJoke()
throw:
func fetchJoke() async throws {
These keywords must appear in this order — throws async
doesn’t work:
Now fetchJoke()
just passes the error up to whatever calls fetchJoke()
. That’s the button in ContentView
, where Xcode is already complaining about fetchJoke()
being asynchronous:
Now what do you do? You can’t mark anything in ContentView
as async
.
Creating an Unstructured Task
Fortunately, you can create an asynchronous task in the button action. Replace Button { jokeService.fetchJoke() } label: {
with this:
Button {
async {
try? await jokeService.fetchJoke()
}
} label: {
You create an asynchronous task with async { }
. Because it’s asynchronous, you have to await
its completion. Because it throws, you have to try
to catch any errors. Xcode lets you use try?
here, or you can write a do/catch statement.
Task { ... }
in a future beta.
This is an unstructured task because it’s not part of a task tree. The async let
task you created in fetchJokes()
is a child task of the task that’s running fetchJokes()
. A child task is bound to the scope of its parent task: The fetchJokes()
task cannot finish until its child tasks have finished.
An unstructured task inherits the actor, priority and local values of its origin, but isn’t bound by its scope. Cancelling its originating task doesn’t signal the unstructured task, and the originating task can finish even if the unstructured task has not finished.
Creating an unstructured task in a non-asynchronous context feels just like DispatchQueue.global().async
with a little less typing. But there’s a big difference: It runs on the MainActor
thread with userInteractive
priority, when the main thread won’t be blocked.
You can specify a lower priority with asyncDetached
:
But it will still run on the main thread. More about this later.
Decoding the Joke
Back to JokeService.swift, to finish writing fetchJoke()
. If you thought making it throw was the easier option, see what you think after this section.
Because fetchJoke()
throws, it passes any error thrown by data(from:)
to the calling function. You might as well take advantage of this mechanism and throw other errors that can happen.
Errors thrown by a throwing function must conform to the Error
protocol, so add this code above the JokeService
extension:
enum DownloadError: Error {
case statusNotOk
case decoderError
}
You create an enumeration for possible errors fetchJoke()
can throw.
Then add this code to fetchJoke()
:
guard
let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 200 // 1
else {
throw DownloadError.statusNotOk
}
guard let decodedResponse = try? JSONDecoder()
.decode(Joke.self, from: data) // 2
else { throw DownloadError.decoderError }
joke = decodedResponse.value // 3
Using guard
lets you throw your specific errors to the calling function.
- You check the response status code and throw
statusNotOk
if it isn’t 200. - You decode the response and throw
decoderError
if something goes wrong. - You assign the decoded value to
joke
.
data(from:)
, instead of throwing it.
Now, where to set isFetching
to false
? This Published
value controls the button’s ProgressView
, so you want to set it even if fetchJoke()
throws an error. Throwing an error exits fetchJokes()
, so you still need to set isFetching
in a defer
statement, before any possible early exit.
Add this line right below isFetching = true
:
defer { isFetching = false }
MainActor
If Xcode has trained you well, you might be feeling a little uneasy. Published
values update SwiftUI views, so you can’t set Published
values from a background thread. To set the Published
values isFetching
and joke
, the dataTask(with:)
completion handler dispatched to the main queue. But your new code doesn’t bother to do this. Will you get purple main thread errors when you run the app?
Try it. Build and run in a simulator. Nope, no main thread errors. Why not?
Because you used async { }
to create the fetchJoke()
task in the button action, it’s already running on the MainActor
thread, with UI priority.
Actor
is the Swift concurrency mechanism for making an object thread-safe. Like Class
, it’s a named reference type. Its synchronization mechanism isolates its shared mutable state and guarantees no concurrent access to this state.
MainActor
is a special Actor
that represents the main thread. You can think of it as using only DispatchQueue.main
. SwiftUI views all run on the MainActor
thread, and so does the unstructured task you created.
To see this, place a breakpoint anywhere in fetchJoke()
. Build and run, then tap the button.
Yes, fetchJoke()
is running on the main thread.
What if you lower the priority? In ContentView.swift, in the button action, change async {
to this:
asyncDetached(priority: .default) {
Task.detached
in a future beta.
Build and run again. Tap the button:
You lowered priority
to default
, but this doesn’t move the task to a background queue. The task still runs on the main thread!
MainActor
thread. A future Xcode beta might enforce this.
Change the code back to async {
.
To move the asynchronous work off the main thread, you need to create an actor
that isn’t MainActor
.