DEV Community

Cover image for Use Async / Await in SwiftUI
Vadim Atamanenko
Vadim Atamanenko

Posted on

Use Async / Await in SwiftUI

Convert your SwiftUI app to take advantage of Swift's new multithreading mechanism and see what's going on underneath the colorful shell.

Software version
Swift 5.5, iOS 15, Xcode 13

Swift 5.5 has a bright new threading framework to help you write safe code faster. To help everyone get started, Apple provided a bunch of videos and code examples at WWDC 2021. There is a summary of what they cover at the end of this tutorial.

Twitter has exploded, and the usual actors (actors - this concept will be discussed later in the article) have already published several instructions. This tutorial is similar to the micro version of Swift concurrency: Update a sample app from WWDC. You'll take small steps to transform a much simpler application to learn how async/await and actors help you write safer code. To help you decipher Xcode error messages and protect you from inevitable future API changes, you'll learn what's going on under the bright surface.

The note
You will need Xcode 13. This tutorial was written using beta 1. If you want to run it on an iOS device, Xcode 13 must be running iOS 15 beta. For your Mac, Big Sur will do. If you have a Mac [partition] running the Monterey beta, you can try running your code there if it doesn't work in Big Sur. You should be comfortable using SwiftUI, Swift, and Xcode to develop iOS apps.

Let's get started

Create a new Xcode project using the SwiftUI interface and name it WaitForIt.

Image description

In ContentView.swift, replace the body content 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)
Enter fullscreen mode Exit fullscreen mode

In Xcode 13 beta 1 you get this error:

Image description

Do not click any of the Fix **buttons! Go to the landing page and change the **Deployment Info from iOS 14.0 to iOS 15.0:

Image description

Return to ContentView.swift. If the error message is still present, press Command-B to build the project.

Launch Live Preview to see the image for the video “SwiftUI vs. UIKit”:

Image description

Okay, this was just a quick check to fix this Xcode crash and also show you the new SwiftUI AsyncImage view. Okay, right?

Before you get started on a real WaitForIt application, take a close look at how Swift's new threading fixes problems with the old GCD threading.

Old and new multithreading

The old GCD threading has several problems that make it difficult to write applications that use threading safely.

Multithreading Swift provides the necessary tools to split work into smaller tasks that can run concurrently. This allows tasks to wait for each other to complete and allows you to efficiently manage the overall progress of a task.

Pyramid of Doom (Pyramid of Doom)

Swift APIs like URLSession are asynchronous. Methods are automatically dispatched to a background queue and immediately return control to the calling code. The methods take a completion handler and call delegate methods. Finalization or delegation code that accesses UI elements must be sent to the main queue.

If a completion handler calls another asynchronous function, and that function has a completion handler, it's hard to see the exit in the resulting pyramid of doom. This makes it difficult to check the correctness of the code. For example, this code example from Meet async/await in Swift at WWDC loads data, creates an image from the data, and then displays a thumbnail of the image. Error handling is special because completion handlers cannot 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()
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

Data Races

When multiple tasks can read or write object data, data races are possible. A data race occurs when one task pauses while another task writes and exits, then the sleeping task resumes and overwrites what was written by the previous task. This creates inconsistent results.

In an app using legacy multithreading, Xcode can detect data races if you enable the Thread Sanitizer runtime diagnostic in your app's Runscheme. You can then implement a sequential queue to prevent concurrent access.

Swift's new multithreading model provides the Actor protocol to prevent concurrent access to object data. Subjects also allow you to structure your application 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 (stream explosion/starvation)

In GCD, the basic unit of work is the thread. If your code queues a lot of read/write tasks, most of them should sleep while they wait. This means that their threads are blocked, so the system creates more threads for the next tasks. If each task also puts a completion handler on a different queue, this creates even more threads. Each blocked thread holds the stack and kernel data structures so that it can be resumed. A blocked thread may be holding resources that another thread needs, so the thread is blocked.

This is an explosion of threads: the system is overloaded with many times more threads than there are cores to process them. The scheduler has to allocate time to hundreds of threads, which results in a lot of context switches. All this slows down your application and may even starve some threads so they never run.

Tasks and continuations

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

The system knows that these tasks are related, so it can manage due dates, priority, and cancellation flags for all tasks in a task tree or group. This makes it easier to check for and respond to cancellation status, thus avoiding issue leaks. If it is important to respond immediately to cancellation, you can write a function with a cancellation handler.

If a task is suspended, it releases its thread and saves its state in the continuation. Threads switch between continuations instead of context switching.

Image description

Threads switch between continuations.

The note
This image is taken from the WWDC session of Swift concurrency: Behind the scenes
The awaitkeyword marks the point of suspension, and the async frame on the heap stores the information it needs when it resumes.

Ideally, the number of threads never exceeds the number of cores. There is a shared pool of threads and a runtime contract whereby each thread will do its job. Your code maintains this contract by using await, actors, and task groups to make dependencies visible to the compiler.

Joke Service

Enough theory! It's time to convert simple loading to using async/await.

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

JokeService is an ObservableObjectthat sends a request to an API that returns a random Chuck Norris joke. I adapted this code from the sample application in Combine: Asynchronous Programming with Swift. The request element specifies the category of developers, so all the jokes have a technical flavor. Warning: Some of these jokes are a little cruel.

The JokeServicepublishes the joke and its isFetchingstatus. Its fetchJoke() method uses the standard URLSession.shared.dataTask with a completion handler. If something goes wrong, it prints an error message with either a dataTaskerror or an "Unknown error". In the case of "Unknown error", it does not provide information on whether the problem was in the data or in the decoder.

Minimal error handling

Reliable error handling is one of the main reasons for using async/await. The data task completion handler cannot throw errors, so if it calls a generating function such as JSONDecoder().decode(_:from:), it must handle any errors that are thrown.

Usually they take the easy way out and just ignore the error. This is what the starter file does:

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

Previous versions of Xcode offer this as a fix if you just write try and don't wrap it in do/catch. This means: just assign nilif the function throws an error.

Delete ? to see what's going on:

Image description

Xcode is now taking a tougher stance: no more helpful suggestions for simple fixes.

But ? still works here so bring it back.

Show me a joke!

To get the joke, open ContentView.swift and replace the content of the ContentViewwith 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() }
          }
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Launch Live Preview and click the button. This has a nice effect with opacity and ProgressView() to indicate that a fetch is in progress.

Image description

Concurrent Binding (parallel binding)

Okay, the old way works, so now you're going to convert it to the new one.

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

Add this code below isFetching = true

async let (data, response) = URLSession.shared.data(from: url)
Enter fullscreen mode Exit fullscreen mode

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

Where is the error that dataTask(with:) throws? You'll find out soon - stay tuned!

The following errors and suggested fixes appear:

Image description

The errors are similar: you can't call an async function within a synchronous function. You must tell the compiler that fetchJoke() is asynchronous.

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

func fetchJoke() async {
Enter fullscreen mode Exit fullscreen mode

Like throws, the asynckeyword appears between the closing brace and the opening curly brace. You will interrupt throwsagain soon.

Back to async let: this is one way to assign the result of data(from:) to the tuple (data, response). This is called concurrent binding because the parent task continues executing after the child task is created to run data(from:) on a different thread. The child task inherits the priority and local values ​​of the parent task. When the parent task needs to use dataor response, it suspends itself (releases its thread) until the child task completes.

Image description

Parent and child tasks run at the same time.

async wait

The verb for asyncis await, just like the verb for throwsis try. You try the throwing function and wait for the async function.

Add this line of code:

await (data, response)
Enter fullscreen mode Exit fullscreen mode

Image description

And there's a missing error here that dataTask(with:) passes to its completion handler: data(from:) throws it. So, you should use try await:

try! await (data, response)
Enter fullscreen mode Exit fullscreen mode

The note
The keywords must be in that order, *not *await try.

You're not really going to use this code, so don't bother catching bugs. It's just a chance to see what's going on.

What is happening is amazing:

Image description

An immutable value can only be initialized once.

This is surprising because the Explore structured concurrency in Swift video says, “And don't worry. Reading the result value again will not recalculate its value."

The note
Looks like it's a tuple error. You can wait for data or a response, but not both.

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

Image description

Hm! Think back to your early days learning Swift, when Xcode kept saying, "You can't do that here." Maybe it's a beta bug. It doesn't matter in this case, because between calling data(from:) and processing what it returns, there is no other code to execute.

Sequential Binding (serial binding)

Instead, you will use another binding: sequential binding.

Replace two lines with this piece of code:

let (data, response) = try? await URLSession.shared.data(from: url)

Unlike async let, calling data(from:) in this way does not create a child task. It is executed sequentially as a job in the fetchJoke() task. While it waits for a response from the server, this job suspends itself, releasing the task thread.

Image description

The data(from:) tasks are suspended.

But there's a problem:

Image description

Xcode refuses to understand try? here.

You'll try the lazy way, but this time it won't work in Xcode, even if you use nil union to specify a nil tuple:

let (data, response) = 
  try? await URLSession.shared.data(from: url) ?? (nil, nil)
Enter fullscreen mode Exit fullscreen mode

No, you have to do the right thing. First, remove the ? and ?? (nil, nil):

let (data, response) = try await URLSession.shared.data(from: url)
Enter fullscreen mode Exit fullscreen mode

Error handling options

You have two options for handling errors caused by data(from:). The first is to bite the bullet and deal with it right away with do/catch:

do {
  let (data, response) = try await URLSession.shared.data(from: url)
} catch {
  print(error.localizedDescription)
}
Enter fullscreen mode Exit fullscreen mode

An easier(?) option is to make fetchJoke() throw:

func fetchJoke() async throws {
Enter fullscreen mode Exit fullscreen mode

These keywords must be in the order listed - throws async does not work:

Image description

asyncmust come before throws.

Now fetchJoke() simply propagates the error to everyone who calls fetchJoke(). This is the button in the ContentView, where Xcode is already complaining about fetchJoke() being asynchronous:

Image description

fetchJoke() is asyncand returns: Do something!

So what's now? You cannot mark anything in a ContentViewas async.

Create an unstructured task

Luckily, you can create an asynchronous task in a button action. Replace Button { jokeService.fetchJoke() } label: { with this piece of code:

Button {
  async {
    try? await jokeService.fetchJoke()
  }
} label: {
Enter fullscreen mode Exit fullscreen mode

You create an asynchronous task with async { }. Since it is asynchronous, you need to wait for it to complete. Because it throws, you should try to catch any errors. Xcode allows you to use try? here, or you can write a do/catch statement.

The note
The syntax for creating a task will change to Task { ... } in a future beta release.

This is an unstructured task because it is not part of the task tree. The async let task that you created in fetchJokes() is a child task of the task that fetchJokes() runs on. A child task is bound to the scope of its parent task: the fetchJokes() task cannot complete until its child tasks have completed.

An unstructured task inherits the actor, priority, and local values ​​of its origin, but is not limited in scope. Canceling the original task does not signal an unstructured task, and the original task can be completed even if the raw task is not completed.

Creating an unstructured task in a non-async context looks just like DispatchQueue.global().async, only with less input. But there is a big difference: it runs on the MainActorthread with userInteractivepriority when the main thread is not blocked.

You can specify a lower priority with asyncDetached:

Image description

Specify a priority for an individual task.

But it will still run on the main thread. More on this later.

Joke decoding

Return to JokeService.swift to finish writing fetchJoke(). If you thought throwing was the easier option, let's see what you think after this section.

Since fetchJoke() throws, it passes on any error thrown by data(from:) to the calling function. You can also take advantage of this mechanism and throw other errors that may occur.

Errors thrown by the calling function must comply with the Errorprotocol, so add this code above the JokeServiceextension:

enum DownloadError: Error {
  case statusNotOk
  case decoderError
}
Enter fullscreen mode Exit fullscreen mode

You are creating an enumeration of the possible errors that fetchJoke() might throw.

Then add this piece of 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
Enter fullscreen mode Exit fullscreen mode

Using guardallows you to pass your specific errors to the calling function.

  1. You check the response status code and throw statusNotOkif it's not 200.

  2. You decode the response and throw a decoderErrorif something goes wrong.

  3. You assign the decoded jokevalue.

The note
You always have the option of catching an error, including those caused by data(from:), instead of throwing one.

Now where to set isFetchingto false? This Publishedvalue controls the button's ProgressView, so you want to set it even if fetchJoke()throws an error. Throwing an error causes fetchJokes() to exit, so you still need to set isFetchingin the defer statement before any possible early exit.

Add this line right below isFetching = true;

defer { isFetching = false }
Enter fullscreen mode Exit fullscreen mode

MainActor

If Xcode has tweaked you a lot, you might feel a little uncomfortable. Publishedvalues ​​update SwiftUI views, so you can't set Publishedvalues ​​from a background thread. To set Publishedto isFetchingand joke, a dataTask(with:) completion handler is dispatched to the main queue. But your new code doesn't bother to do that. Will you get main thread errors when you run the application?

Try it. Build and run in the simulator. No, there are no mainstream errors. Why not?

Because you used async { } to create the fetchJoke() task in the button action, it's already running on the MainActorthread with UI priority.

Actor is a Swift multithreading mechanism that allows you to make an object thread-safe (thread-safe). Like Class, it is a named reference type. Its synchronization mechanism isolates its shared mutable state and does not guarantee concurrent access to that state.

MainActor is a special Actor that represents the main thread. You can think of this as just using DispatchQueue.main. All SwiftUI views run on the MainActorthread, just like the flat task you created.

To see this, place a breakpoint anywhere in fetchJoke(). Make a build and run, then click the button.

Image description

fetchJoke()runs on the main thread.

Yes, fetchJoke() runs on the main thread.

What if you lower the priority? In ContentView.swift in button action change async { to this:

asyncDetached(priority: .default) {
Enter fullscreen mode Exit fullscreen mode

The note
The syntax for this parameter will change to Task.detached in a future beta release.

Build and run. Click on the button:

Image description

fetchJoke() is still running on the main thread.

You lowered the priority to the default, but this does not move the task to the background queue. The task is still running on the main thread!

The note
It looks like it's a coincidence. The video Explore structured concurrency in Swift says that a detached task doesn't inherit anything from its source, so it shouldn't inherit from the MainActor thread. A future beta release of Xcode may provide this.

Change the code back to async {.

To move asynchronous work from the main thread, you need to create an actorthat is not MainActor.

Actor

Actors allow you to structure your application into actors on background threads and actors on the main thread, just like you now create a model, view and view model files. The code in actor (lowercase, not MainActor) is running on a background thread. So you just need to move the async part of fetchJoke() to a separate actor.

In JokeService.swift, remove the breakpoint and add this piece of code above the JokeService:

private actor JokeServiceStore {
  private var loadedJoke = Joke(value: "")

  func load() async throws -> Joke {
  }
}
Enter fullscreen mode Exit fullscreen mode

You create an actorwith a Jokevariable and initialize it to an empty string, then write a load() stub where you move your loading code. This method resets loadedJokeand also returns Joke, so you don't need the Joke property for this simple example, but you probably need it for more complex data.

Then create a JokeServiceStoreobject in the JokeService(in a class, not an extension):

private let store = JokeServiceStore()
Enter fullscreen mode Exit fullscreen mode

Now move the urlcode from JokeServiceto 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
}
Enter fullscreen mode Exit fullscreen mode

Then move the loading code from fetchJoke() to load() leaving only two isFetchinglines 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
Enter fullscreen mode Exit fullscreen mode

JokeServiceStorehas a Jokeproperty, not a Stringproperty, so replace the last line with the following code:

loadedJoke = decodedResponse
return loadedJoke
Enter fullscreen mode Exit fullscreen mode

Instead of retrieving just the value from the decodedResponse, you set the Jokeproperty and also return that Jokeinstance.

Now call load() in fetchJoke():

let loadedJoke = try await store.load()
joke = loadedJoke.value
Enter fullscreen mode Exit fullscreen mode

Build and run.

A joke appears, but you have purple warnings:

Image description

Posting changes from background threads is not allowed.

Add a breakpoint inside load() and in fetchJoke() in isFetching = true, let loadedJoke = ... and joke = loadedJoke.value;

Image description

Setting breakpoints.

Click the button again, then watch the threads by clicking Continue program execution after each dot:

Image description

fetchJoke() runs on the main thread but goes to a background thread.

The first two lines of fetchJoke() are executed on the main thread because view is calling it. The load() is then run on a background thread, as it should be. But when execution returns to fetchJoke() it is still on a background thread. You need to do something to make it run on the main thread.

@MainActor

The code that sets the Publishedvalue must run on the MainActorthread. When fetchJoke() did all the work and you called it from a Button in an unstructured task, fetchJoke() inherited MainActorfrom Buttonand all of its code was running on the MainActorthread.

Now fetchJoke() calls load() which runs on a background thread. fetchJoke()still runs on the main thread, but when load() completes, fetchJoke() continues to run on the background thread.

fetchJoke() should not rely on MainActorbeing inherited from Button. You can mark a class or function with the @MainActor attribute to indicate that it should run on the MainActorthread.

The note
If you mark a class as @MainActor, any calls from outside MainActormust be await, even if it's a method call that exits immediately. A method that does not refer to any mutable state may drop the MainActorwith the nonisolatedkeyword.

Add this line above func fetchJoke() throws {

@MainActor
Enter fullscreen mode Exit fullscreen mode

Compile and run again and hit breakpoints:

Image description

fetchJoke()runs on the main thread after load() completes.

The first three points are the same as before, but now fetchJoke()runs on the main thread after load() completes.

Image description

When fetchJoke() calls load() , it pauses, freeing up the main thread for UI tasks. When the download completes, fetchJoke() runs again on the main thread, where Publishedis allowed to be set.

Your work is done here! Try converting your own SwiftUI projects: take it slow, make small changes, and try to keep the app buildable after each change.

Optional: Asynchronous View Modifiers

SwiftUI now has (at least) two view modifiers that expect their action to call an async function.

Create a new SwiftUI View file named RefreshableView.swift and replace the contents of RefreshableView with the following:

@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()
  }
}
Enter fullscreen mode Exit fullscreen mode
  1. This view is a Listbecause refreshable(action:) only works on scrollable views.

  2. The task modifier performs its action when the view appears. The type of its action parameter is action - @escaping() async -> Void. It creates a task to run the action so you don't need it.

  3. The type of the actionparameter of the refreshablemodifier is the same. It must be asynchronous. When applied to a scrollable view, the user can drag down to refresh its content, and it displays a refresh indicator until the asynchronous task completes.

Launch Live Preview. The joke comes up:

Image description

The joke appears when the view is loaded.

The note
It's actually an O(N) complexity algorithm.

Pull down to get another joke. You may need to pull down quite far.

If you want to run this version in the simulator or device, open WaitForItApp.swift and change ContentView() to RefreshableView() in the WindowGroupclosure.

In this tutorial, you converted a simple SwiftUI app from the old GCD threading implementation to the new Swift threading implementation using async/await, unstructured task, actor, and @MainActor.

Top comments (1)

Collapse
 
sokol8 profile image
Kostiantyn Sokolinskyi

The article says that "In GCD, the basic unit of work is the thread."
Isn't it task on a queue, not thread, that is the basic unit of work?

I was always under impression that GCD was created to abstract NSThread and move work to tasks on queues away from using threads directly.