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

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.

Spoiler: It feels like candy-coated GCD, so knowing GCD can help. Refresh your knowledge with our course iOS Concurrency with GCD and Operations or book Concurrency by Tutorials.
Note: You’ll need Xcode 13. This tutorial was written using beta 1. If you want to run this on an iOS device, it must be running iOS 15 beta. For your Mac, Big Sur is OK. If you have a Mac [partition] running the Monterey beta, you could try running your code there in case it’s not working on Big Sur. You should be comfortable with using SwiftUI, Swift and Xcode to develop iOS apps.

Getting Started

Create a new Xcode project that uses SwiftUI interface and name it WaitForIt.

Create a new project named WaitForIt.

Create a new project named 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:

Availability error

Availability 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:

Set Deployment Info to iOS 15.0.

Set Deployment Info 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:

AsyncImage

AsyncImage

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.