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
Swift 5.5 has a shiny new structured concurrency framework to help you write safe code more swiftly. To help everyone get started, Apple provided a bunch of videos and sample code at WWDC 2021. There’s a quick run-down on what they cover at the end of this tutorial.
The Twitterverse exploded and the usual actors (;]) have already published several how-tos. This tutorial is like a micro version of Swift concurrency: Update a sample app from WWDC. You’ll take baby steps to convert a much simpler app to learn how async/await and actors help you write safer code. To help you decipher Xcode’s error messages and future-proof you against the inevitable future API changes, you’ll explore what’s going on beneath the shiny surface.
Getting Started
Create a new Xcode project that uses SwiftUI interface and name it WaitForIt.
In ContentView.swift, replace the body
contents with this code:
AsyncImage(url: URL(string: "https://files.betamax.raywenderlich.com/attachments/collections/194/e12e2e16-8e69-432c-9956-b0e40eb76660.png")) { image in
image.resizable()
} placeholder: {
Color.red
}
.frame(width: 128, height: 128)
In Xcode 13 beta 1, you get this error:
Don’t click any of the Fix buttons! Go to the target page and change Deployment Info from iOS 14.0 to iOS 15.0:
Go back to ContentView.swift. If the error message is still there, press Command-B to build the project.
Run Live Preview to see the image for the “SwiftUI vs. UIKit” video:
OK, that was just a quick check to fix that Xcode glitch and also to show you SwiftUI’s new AsyncImage
view. Good, isn’t it? :]
Before you get to work on the real WaitForIt app, take a high level look at how the new Swift concurrency fixes problems with the old GCD concurrency.
Old and New Concurrency
The old GCD concurrency has several problems that make it hard to write apps that safely use concurrency.
Swift concurrency provides the necessary tools to carve work up into smaller tasks that can run concurrently. This lets tasks wait for each other to complete and allows you to effectively manage the overall progress of a task.
Pyramid of Doom
Swift APIs like URLSession are asynchronous. Methods automatically dispatch to a background queue and immediately return control to the calling code. Methods take a completion handler and call delegate methods. Completion or delegate code that accesses UI elements must be dispatched to the main queue.
If a completion handler calls another asynchronous function, and this function has a completion handler, it’s hard to see the happy path in the resulting pyramid of doom. This makes it hard to check the code is correct. For example, this sample code from WWDC’s Meet async/await in Swift downloads data, creates an image from the data, then renders a thumbnail from the image. Error handling is ad hoc because completion handlers can’t throw errors.
func fetchThumbnail(
for id: String,
completion: @escaping (UIImage?, Error?) -> Void
) {
let request = thumbnailURLRequest(for: id)
let task = URLSession.shared
.dataTask(with: request) { data, response, error in
if let error = error {
completion(nil, error)
} else if (response as? HTTPURLResponse)?.statusCode != 200 {
completion(nil, FetchError.badID)
} else {
guard let image = UIImage(data: data!) else {
completion(nil, FetchError.badImage)
return
}
image.prepareThumbnail(of: CGSize(width: 40, height: 40)) { thumbnail in
guard let thumbnail = thumbnail else {
completion(nil, FetchError.badImage)
return
}
completion(thumbnail, nil)
}
}
}
task.resume()
}
The sequence of operations is much easier to see with async/await, and you can take advantage of Swift’s robust error handling mechanism:
func fetchThumbnail(for id: String) async throws -> UIImage {
let request = thumbnailURLRequest(for: id)
let (data, response) = try await URLSession.shared.data(for: request)
guard (response as? HTTPURLResponse)?.statusCode == 200 else {
throw FetchError.badID
}
let maybeImage = UIImage(data: data)
guard let thumbnail = await maybeImage?.thumbnail else {
throw FetchError.badImage
}
return thumbnail
}
Data Races
When multiple tasks can read or write an object’s data, data races are possible. A data race occurs when one task sleeps while another task writes and exits, then the sleeping task resumes and overwrites what the previous task wrote. This creates inconsistent results.
In an app using the old concurrency, Xcode can detect data races if you enable the runtime Thread Sanitizer diagnostic in your app’s Run scheme. Then you can implement a serial queue to prevent concurrent access.
The new Swift concurrency model provides the Actor
protocol to prevent concurrent access to an object’s data. Actors also enable you to structure your app into code that runs on the main thread and code that runs on background threads, so the compiler can help you prevent concurrent access.
Thread Explosion / Starvation
In GCD, the main unit of work is a thread. If your code queues up a lot of read/write tasks on a serial queue, most of them must sleep while they wait. This means their threads are blocked, so the system creates more threads for the next tasks. If each task also queues a completion handler on another queue, that creates even more threads. Every blocked thread holds onto a stack and kernel data structures so it can resume. A blocked thread may be holding resources that another thread needs, so that thread blocks.
This is a thread explosion: The system is overcommitted with many times more threads than it has cores to process them. The scheduler must allocate time to hundreds of threads, resulting in a lot of context switching. All of this slows down your app and can even starve some threads, so they never make any progress.
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.
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
.
Actor
Actors enable you to structure your app into actors on background threads and actors on the main thread, just as you now create model, view and view model files. Code in an actor
(lower case, not MainActor
) runs on a background thread. So you just need to move the asynchronous part of fetchJoke()
into a separate actor
.
In JokeService.swift, delete the breakpoint and add this code above JokeService
:
private actor JokeServiceStore {
private var loadedJoke = Joke(value: "")
func load() async throws -> Joke {
}
}
You create an actor
with a Joke
variable and initialize it with an empty String
, then write the stub of load()
, where you’ll move the download code. This method resets loadedJoke
and also returns the Joke
, so you don’t really need a Joke
property for this simple example, but you probably will for more complex data.
Next, create a JokeServiceStore
object in JokeService
(in the class, not the extension):
private let store = JokeServiceStore()
Now move the url
code from JokeService
into JokeServiceStore
:
private var url: URL {
urlComponents.url!
}
private var urlComponents: URLComponents {
var components = URLComponents()
components.scheme = "https"
components.host = "api.chucknorris.io"
components.path = "/jokes/random"
components.setQueryItems(with: ["category": "dev"])
return components
}
Then move the download code from fetchJoke()
to load()
, leaving only the two isFetching
lines in fetchJoke()
:
// move this code from fetchJoke() to load()
let (data, response) = try await URLSession.shared.data(from: url)
guard
let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 200
else {
throw DownloadError.statusNotOk
}
guard let decodedResponse = try? JSONDecoder().decode(Joke.self, from: data)
else { throw DownloadError.decoderError }
joke = decodedResponse.value
JokeServiceStore
has a Joke
property, not a String
property, so replace the last line with this code:
loadedJoke = decodedResponse
return loadedJoke
Instead of extracting just the value
from decodedResponse
, you set the Joke
property and also return this Joke
instance.
Now call load()
in fetchJoke()
:
let loadedJoke = try await store.load()
joke = loadedJoke.value
Build and run. Tap the button.
A joke appears, but you have purple warnings:
Add breakpoints inside load()
and in fetchJoke()
at isFetching = true
, let loadedJoke = ...
and joke = loadedJoke.value
:
Tap the button again, then watch the threads while you click Continue program execution after each breakpoint:
The first two lines of fetchJoke()
run on the main thread because a view calls it. Then load()
runs on a background thread, as it should. But when execution returns to fetchJoke()
, it’s still on a background thread. You need to do something to make it run on the main thread.
@MainActor
Code that sets a Published
value has to run on the MainActor
thread. When fetchJoke()
did all the work, and you called it from Button
in an unstructured task, fetchJoke()
inherited MainActor
from Button
, and all of its code ran on the MainActor
thread.
Now fetchJoke()
calls load()
, which runs on a background thread. fetchJoke()
still starts on the main thread but, when load()
finishes, fetchJoke()
continues running on a background thread.
fetchJoke()
doesn’t have to rely on inheriting MainActor
from Button
. You can mark a class or a function with the @MainActor
attribute to say that it must be executed on the MainActor
thread.
@MainActor
, any calls from outside MainActor
must await
, even when calling a method that completes its work immediately. A method that doesn’t reference any mutable state can opt out of MainActor
with the keyword nonisolated
.
Add this line above func fetchJoke() throws {
@MainActor
Build and run again and click through the breakpoints:
The first three breakpoints are the same as before, but now fetchJoke()
runs on the main thread after load()
finishes.
When fetchJoke()
calls load()
, it suspends itself, releasing the main thread to run UI jobs. When load finishes, fetchJoke()
again runs on the main thread, where it’s allowed to set the Published
values.
Your work here is done! Try converting your own SwiftUI projects: Take it slow, make small changes and try to keep the app buildable after each change.
One More Thing: Asynchronous View Modifiers
SwiftUI now has (at least) two view modifiers that expect their action
to call an asynchronous function.
Create a new SwiftUI View file named RefreshableView.swift and replace the contents of RefreshableView
with this:
@StateObject var jokeService = JokeService()
var body: some View {
List {
Text("Chuck Norris Joke")
.font(.largeTitle)
.listRowSeparator(.hidden)
Text(jokeService.joke)
.multilineTextAlignment(.center)
.lineLimit(nil)
.lineSpacing(5.0)
.padding()
.font(.title)
}
.task {
try? await jokeService.fetchJoke()
}
.refreshable {
try? await jokeService.fetchJoke()
}
}
- This view is a
List
becauserefreshable(action:)
only works with scrollable views. - The
task
modifier performs its action when the view appears. Itsaction
parameter’s type is@escaping () async -> Void
. It creates a task to run the action so you don’t need to. - The
refreshable
modifier’saction
parameter type is the same. It must be asynchronous. When applied to a scrollable view, the user can pull down to refresh its content, and it displays a refresh indicator until the asynchronous task completes.
Run Live Preview. A joke appears:
O(N)
. ;]
Pull down to fetch another joke. You might have to pull down quite far.
If you want to run this version in a simulator or on a device, open WaitForItApp.swift and change ContentView()
to RefreshableView()
in the WindowGroup
closure.
Where to Go From Here?
Download the final project using the Download Materials button at the top or bottom of the tutorial.
In this tutorial, you converted a simple SwiftUI app from the old GCD concurrency to the new Swift concurrency, using async/await, throwing, an unstructured task, actor
and @MainActor
.
WWDC 2021 Videos
To learn more about the fundamental concepts of Swift concurrency, watch these videos first:
-
Meet async/await in Swift: This session introduces
try await
,get async
, unstructured task and continuations. - Explore structured concurrency in Swift: This session covers concurrent and sequential binding, cancellation, task groups, unstructured and detached tasks. There’s a handy Flavors of task table near the end.
-
Protect mutable state with Swift actors: This session covers data races, actor, protocol conformance in extensions with nonisolated declarations, detached tasks,
Sendable
, conformance andMainActor
.
These sessions are SwiftUI-specific:
- Demystify SwiftUI: Get inspired to check all your SwiftUI code for inefficiencies. Lots of cute dog and cat photos.
- Discover concurrency in SwiftUI: This session provides valuable tips for using structured concurrency in a SwiftUI app (SpacePhoto).
These talks cover a few specific purposes:
- Meet AsyncSequence
-
Use async/await with URLSession: This session covers
guard let
andthrow
, upload and download methods, cancellation,AsyncSequence
andAuthenticationDelegate
. Lots of cute dog images. - Bring Core Data concurrency to Swift and SwiftUI
Set aside a good chunk of uninterrupted time for these deeper dives, or watch them in 10-15 minute chunks:
- Swift concurrency: Update a sample app: This session is solid gold! The presenter Ben Cohen is the Review Manager of the Swift Evolution proposal SE-0304 Structured Concurrency.
- Swift concurrency: Behind the scenes: This session goes in depth about thread explosion, the cooperative thread pool, continuations, async frames.
Melbourne Cocoaheads
And finally, two notable contributions from my colleagues at Melbourne Cocoaheads.
- How to test Swift async/await code with XCTest by Giovanni Lodi. This is a companion piece to his presentation (starts at 46:32).
- CombineAsyncually by Rob Amos. This is the companion repo for his presentation (right after Gio’s, at 1:17:27), where he demonstrates how you can bridge the new async/await functionality with Combine.
I hope you enjoyed this tutorial! Swift concurrency is a huge game-changer and it’s still evolving. There’s a lot to learn, so take some time now to explore it, even if you can’t really use it for a few years.
If you have any comments or questions, feel free to join in the forum discussion below!