DEV Community

Matteo Manferdini
Matteo Manferdini

Posted on • Edited on • Originally published at matteomanferdini.com

Async/await in Swift and SwiftUI

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

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

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:

  1. Receiving input events.
  2. Executing code (possibly yours).
  3. 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
}
Enter fullscreen mode Exit fullscreen mode

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

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

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()) ?? []
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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()) ?? []
                }
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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

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

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

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

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.

P.S. You can find the full version of this post here.

Top comments (0)