How to use async/await with iOS 13 and iOS 14

Apple released the beta version of Xcode 13.2. Besides the latest versions of the SDKs, it also brings backward compatibility for Swift's new concurrency system. Until now, we could use async/await only with iOS 15, macOS Monterey, watchOS 8, and tvOS 15. Xcode 13.2 extends supported systems way back to iOS 13, macOS Catalina, watchOS 6, and tvOS 13. This is huge for developers as we still need to support earlier versions of the system. Let's take a look at how to use it in practice.

Using async/await in your own code

If you plan to use Swift's concurrency system in your own code, you can do this the same way you would develop for iOS 15.
Imagine we are working on an app that displays Git repositories. Each repository is represented with the following structure:

struct Repository: Identifiable, Codable {
    let id: Int
    let name: String
    let url: String?
}

We can write a data provider that loads repositories from local storage:

@MainActor
final class LocalRepositoriesProvider: ObservableObject {
    @Published var isLoading: Bool = false
    @Published var error: Error?
    @Published var repositories: [Repository] = []
    
    func loadRepositories() async {
        error = nil
        isLoading = true
        
        do {
            try await Task.sleep(nanoseconds: 2000000000) // wait 2 seconds
            
            repositories = [
                Repository(id: 0, name: "match", url: "~/repos/match"),
                Repository(id: 1, name: "SwiftPackageWithGithubActionsAsCI", url: "~/repos/SwiftPackageWithGithubActionsAsCI"),
                Repository(id: 2, name: "NikeClockIcon", url: "~/repos/NikeClockIcon"),
                Repository(id: 3, name: "halloween_2018_watch_face", url: "~/repos/halloween_2018_watch_face"),
                Repository(id: 4, name: "tatooine", url: "~/repos/tatooine")
            ]
        }
        catch {
            self.error = error
        }
        
        isLoading = false
    }
}

In the code above, the LocalRepositoriesProvider is a standard ObservableObject that we can use in the SwiftUI view. The most important part is the async loadRepositories() method. In this method, we wait for 2 seconds using the Task.sleep(nanoseconds:) method, then we return some mocked data.

One other thing worth noting is that LocalRepositoriesProvider is marked with @MainActor annotation. Although we want to load repositories in the background, we still want to interact (trigger loading, read received data) only on the main thread.

Now, we can use our provider in the SwiftUI view:

struct RepositoriesView: View {
    @StateObject var currentDataProvider = LocalRepositoriesProvider()
    
    var body: some View {
        VStack {
            if let error = currentDataProvider.error {
                Text(error.localizedDescription)
                    .padding(10)
                    .frame(maxWidth: .infinity, minHeight: 40)
                    .background(Color.red)
                    .foregroundColor(.white)
            }
            
            if currentDataProvider.isLoading {
                Text("Loading...")
                    .frame(maxWidth: .infinity, minHeight: 40)
                    .background(Color.clear)
            } else {
                List(currentDataProvider.repositories) {
                    Text($0.name)
                        .frame(minHeight: 30, alignment: .leading)
                }
            }
            
            Spacer()
            
            Button("Load repos") {
                Task {
                    await currentDataProvider.loadRepositories()
                }
            }
        }
        .navigationBarTitle("Repositories", displayMode: .large)
    }
}

We access the provider's properties as usual, but calling async methods is a little bit different. We load repositories when user taps on the button. Because buttons in SwiftUI are not async, we have to wrap the call to loadRepositotories() method Task.init(priority:operation:).

And that's it! Now, we have async/await and other elements of Swift's concurrency system working with iOS 13.

Using async/await with Apple's SDKs

Although we can use Swift's concurrency system with earlier versions of the operating systems, Apple did not provide any async APIs for its SDKs.
Those are available only for the latest version of the SDKs. So, for example, we cannot use async methods for URLSession. Fortunately, we can quite easily write such method ourself:

extension URLSession {
    @available(iOS, deprecated: 15.0, message: "This extension is no longer necessary. Use API built into SDK")
    func data(from url: URL) async throws -> (Data, URLResponse) {
        try await withCheckedThrowingContinuation { continuation in
            let task = self.dataTask(with: url) { data, response, error in
                guard let data = data, let response = response else {
                    let error = error ?? URLError(.badServerResponse)
                    return continuation.resume(throwing: error)
                }
                
                continuation.resume(returning: (data, response))
            }
            
            task.resume()
        }
    }
}

In the extension above, we define an async method that wraps the standard callback API. To convert the callback into async API, we use the withCheckedThrowingContinuation(function:_:). This function suspends the current task and awaits the callback to either return data or throw an error.

Now, we can write GitHubRepositoriesProvider that fetches Git repositories from GitHub, using extension we just wrote:

@MainActor
final class GitHubRepositoriesProvider: ObservableObject {
    private let remoteUrl = URL(string: "https://api.github.com/users/mtynior/repos")!
    
    @Published var isLoading: Bool = false
    @Published var error: Error?
    @Published var repositories: [Repository] = []
    
    func loadRepositories() async {
        error = nil
        isLoading = true
        
        do {
            let (data, _) = try await URLSession.shared.data(from: remoteUrl)
            repositories = try JSONDecoder().decode([Repository].self, from: data)
        }
        catch {
            self.error = error
        }
        
        isLoading = false
    }
}

And that's how we can convert any callback API to the async one.

As you can see that using async/await with earlier versions of iOS (or any other supported Apple's operating system) is really easy. Development is not always the same as for iOS 15, but with little effort, we can make APIs almost indistinguishable.

If you are curious, I published the sample project on my GitHub:

mtynior/ios13AsyncAwait
Example project showing how to use async/await with iOS 13 - mtynior/ios13AsyncAwait