Async/await in Swift and SwiftUI
Matteo Manferdini
Posted on September 12, 2023
This article first appeared on matteomanferdini.com
Chapter 1: What is async and await in Swift?
Async/await is a mechanism used to create and execute asynchronous functions in Swift.
- async indicates that a function or method is asynchronous and can pause its execution to wait for the completion of another process.
- await marks a suspension point in your code where execution may wait for the result of an async function or method.
How to write an async function
To declare an asynchronous function in Swift, write the async
keyword after the function name and before its return type.
import Foundation
func fetchImageData() async throws -> Data {
let data = // ... Download the image data ...
return data
}
Whenever one of your functions calls another asynchronous method, it must also be declared as async
.
How to use await in Swift
You place the await
keyword wherever you need to call an async
function. It creates a suspension point where the execution of your code may pause until the asynchronous function or method returns.
As an illustration, let’s download an image using a URL from the Dog API.
func fetchImageData() async throws -> Data {
let url = URL(string: "https://images.dog.ceo/breeds/mountain-swiss/n02107574_1387.jpg")!
let (data, _) = try await URLSession.shared.data(from: url)
return data
}
Chapter 2: Why do we need asynchronous functions in Swift and iOS apps?
An iOS app idly waits for input, such as the user tapping a button or data arriving from the network. When such an input arrives, it triggers an event in the app that causes your code to run. After that, the user interface must be updated.
Blocking the main run loop for too long makes your app unresponsive
When an iOS app runs, it consistently cycles through a run loop consisting of three phases:
- Receiving input events.
- Executing code (possibly yours).
- Updating the UI.
The cycle runs so swiftly that the app appears to respond instantly to user input. However if some code takes too long to execute, it delays the subsequent UI update and input phases. Your app may feel sluggish, freeze briefly, and lose input.
Asynchronous functions can perform long tasks without blocking the app’s main run loop
When you call an asynchronous function, your code gets suspended, leaving the app's main run loop free. Meanwhile, the work performed by the asynchronous function runs "in the background".
The code running in the background can take as much time as it needs without impacting the app's main run loop. When it finishes and returns a result, the system resumes your code where it left off and continues executing it.
Chapter 3: Structured and unstructured concurrency
A task is a unit of work that can be run asynchronously.
Tasks can be arranged hierarchically, allowing you to run several tasks in parallel. This approach is called structured concurrency. The easiest way to create child tasks is by using async let
Swift also allows you to explicitly create and manage tasks. This approach, in turn, is called unstructured concurrency.
Calling async functions sequentially
We can create an async function to retrieve the URL for a random dog image and then download its data.
struct Dog: Identifiable, Codable {
let message: String
let status: String
var id: String { message }
var url: URL { URL(string: message)! }
}
func fetchDog() async throws -> Dog {
let dogURL = URL(string: "https://dog.ceo/api/breeds/image/random")!
let (data, _) = try await URLSession.shared.data(from: dogURL)
return try JSONDecoder().decode(Dog.self, from: data)
}
func fetchImageData() async throws -> Data {
let url = try await fetchDog().url
let (data, _) = try await URLSession.shared.data(from: url)
return data
}
The fetchImageData()
function makes two asynchronous calls sequentially.
Running several asynchronous functions in parallel using structured concurrency
You can run a discrete number of asynchronous functions simultaneously by using async
in front of let
when declaring a constant.
func fetchThreeDogs() async throws -> [Dog] {
async let first = fetchDog()
async let second = fetchDog()
async let third = fetchDog()
return try await [first, second, third]
}
The async let
keywords do not create a suspension point like await
. We only use await
at the end of the function when we need the content of all three constants to create the final array.
Creating unstructured tasks to call async methods from synchronous code
To call async methods from synchronous code we run async
functions inside a Task
.
Task {
let data = try await fetchThreeDogs()
}
The code surrounding a task remains synchronous, so the main execution is not suspended.
The Task
type allows you to run async functions inside a Swift playground or a command-line Swift program. Far more common is calling async methods from SwiftUI.
Chapter 4 Using async/await in SwiftUI
While it's not considered good practice to place async methods inside a SwiftUI view directly, many of these functions must still be triggered from SwiftUI code.
Calling async methods when a SwiftUI view appears on screen
SwiftUI specifically provides the task(priority:_:)
modifier for this purpose.
struct ContentView: View {
@State private var dogs: [Dog] = []
var body: some View {
List(dogs) { dog in
// ...
}
.task {
dogs = (try? await fetchThreeDogs()) ?? []
}
}
}
The task modifier keeps track of the task it creates and automatically cancels it when the view disappears from the screen.
Calling an async method when the user pulls to refresh or taps on a button
Create a Task
in the trailing closure of a Button
, or the trailing closure of the refreshable(action:)
view modifier.
struct ContentView: View {
@State private var dogs: [Dog] = []
var body: some View {
List(dogs) { dog in
// ...
}
.refreshable {
Task {
dogs = (try? await fetchThreeDogs()) ?? []
}
}
.toolbar {
Button("Reload") {
Task {
dogs = (try? await fetchThreeDogs()) ?? []
}
}
}
}
}
Chapter 5: Async/await vs. completion closures
Concurrency with async/await should be your primary choice for any new project. However, you might have an existing project using the old callback-based asynchronous approach.
Using callback-based asynchronous functions with completion closures
Writing our fetchDog()
function using that approach is much more complicated than using async/await.
struct HTTPError: Error {
let statusCode: Int
}
func fetchDog(completion: @escaping (Result<Dog, Error>) -> Void) {
let dogURL = URL(string: "https://dog.ceo/api/breeds/image/random")!
let task = URLSession.shared.dataTask(with: dogURL) { data, response, error in
if let error = error {
completion(.failure(error))
return
}
if let response = (response as? HTTPURLResponse), response.statusCode != 200 {
completion(.failure(HTTPError(statusCode: response.statusCode)))
return
}
do {
let dog = try JSONDecoder().decode(Dog.self, from: data!)
completion(.success(dog))
} catch {
completion(.failure(error))
}
}
task.resume()
}
Replacing completion closures with async/await
You can use continuations to wrap your old callback-based asynchronous functions and provide an async alternative instead of rewriting them all from scratch.
Xcode also helps you in the process. ⌥-click on the function name, and you will find three options in the Refactor contextual menu.
Convert Function to Async
func fetchDog() async throws -> Dog {
let dogURL = URL(string: "https://dog.ceo/api/breeds/image/random")!
return try await withCheckedThrowingContinuation { continuation in
let task = URLSession.shared.dataTask(with: dogURL) { data, response, error in
if let error = error {
continuation.resume(with: .failure(error))
return
}
if let response = (response as? HTTPURLResponse), response.statusCode != 200 {
continuation.resume(with: .failure(HTTPError(statusCode: response.statusCode)))
return
}
do {
let dog = try JSONDecoder().decode(Dog.self, from: data!)
continuation.resume(with: .success(dog))
} catch {
continuation.resume(with: .failure(error))
}
}
task.resume()
}
}
This option is helpful if you prefer to immediately replace all instances of the old function with the new Swift concurrency approach.
Add Async Alternative
@available(*, renamed: "fetchDog()")
func fetchDog(completion: @escaping (Result<Dog, Error>) -> Void) {
Task {
do {
let result = try await fetchDog()
completion(.success(result))
} catch {
completion(.failure(error))
}
}
}
func fetchDog() async throws -> Dog {
let dogURL = URL(string: "https://dog.ceo/api/breeds/image/random")!
return try await withCheckedThrowingContinuation { continuation in
// ...
}
}
Select this option to retain all calls to the old version while using the new async
version in your new code.
Add Async Wrapper
@available(*, renamed: "fetchDog()")
func fetchDog(completion: @escaping (Result<Dog, Error>) -> Void) {
let dogURL = URL(string: "https://dog.ceo/api/breeds/image/random")!
let task = URLSession.shared.dataTask(with: dogURL) { data, response, error in
// ...
}
task.resume()
}
func fetchDog() async throws -> Dog {
return try await withCheckedThrowingContinuation { continuation in
fetchDog() { result in
continuation.resume(with: result)
}
}
}
Utilize this option if you wish to maintain your existing code unchanged. It will keep using the old implementation while you incorporate Swift concurrency for new code.
Posted on September 12, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.