Calling async APIs from a synchronous context
Many of Apple’s existing asynchronous APIs have now been adopted to make full use of the new async/await pattern that’s being introduced in Swift 5.5. For example, earlier today, we explored how URLSession
can now be called using this new pattern, but one thing that can initially be quite confusing is how to call these new async
-marked APIs from a synchronous context.
Synchronous views, asynchronous APIs
For example, let’s say that we’ve built the following async/await-powered WWDCItemsLoader
, which uses this very website’s JSON API to load a list of the most recently published items:
struct WWDCItemsLoader {
var url = URL(string: "https://wwdcbysundell.com/api/items.json")!
var session = URLSession.shared
func load() async throws -> [WWDCItem] {
let (data, _) = try await session.data(from: url)
let decoder = JSONDecoder()
let response = try decoder.decode(WWDCItem.Response.self, from: data)
return response.items
}
}
Now, let’s say that we wanted to render the items loaded by the above WWDCItemsLoader
within a SwiftUI view. An initial idea on how to do that might be to do something like this:
struct WWDCItemsList: View {
var loader: WWDCItemsLoader
@State private var loadingState = LoadingState<[WWDCItem]>.idle
var body: some View {
switch loadingState {
case .idle:
Color.clear.onAppear(perform: loadItems)
case .loading:
ProgressView()
case .loaded(let items):
List(items) { item in
// Rendering each item
...
}
case .failed(let error):
ErrorView(error: error, reloadHandler: loadItems)
}
}
private func loadItems() async {
loadingState = .loading
do {
let items = try await loader.load()
loadingState = .loaded(items)
} catch {
loadingState = .failed(error)
}
}
}
Above we’ve borrowed the LoadingState
enum from the Swift by Sundell article “Handling loading states within SwiftUI views”.
However, while we’re definitely on the right track, the above code doesn’t currently compile. That’s because our view’s loadItems
method is currently marked as async
(to enable it to call our loader’s load
method, which is in turn also async
), which means that we can’t just call it synchronously from within our view’s body
.
What we essentially need to do here is to somehow bridge the gap between the asynchronous mechanisms that async/await provides, and the very synchronous nature of a SwiftUI view’s body
.
That bridge is another variant of the async
keyword, which gives us a closure in which we can freely await the results of our asynchronous calls, like this:
struct WWDCItemsList: View {
...
private func loadItems() {
loadingState = .loading
async {
do {
let items = try await loader.loadItems()
loadingState = .loaded(items)
} catch {
loadingState = .failed(error)
}
}
}
}
Note that our loadItems
method itself is no longer marked as async
, since it now executes its own, top-level code in a completely synchronous manner.
Under the hood, using the above async
mechanism will create a new task which will perform our work asynchronously without blocking our app’s main thread of execution.
Beyond views
Of course, the above pattern can not only be used within SwiftUI views — it can be used within any situation in which we need to bridge the gap between a synchronous execution context and the world of async/await, for example if we were to move all of our LoadingState
logic into something like a view model:
class WWDCItemsViewModel: ObservableObject {
@Published private(set) var state = LoadingState<[WWDCItem]>.idle
private let loader: WWDCItemsLoader
init(loader: WWDCItemsLoader) {
self.loader = loader
}
func load() {
state = .loading
async {
do {
let items = try await loader.loadItems()
state = .loaded(items)
} catch {
state = .failed(error)
}
}
}
}
However, when running the above code, we’ll encounter an interesting (and perhaps somewhat surprising) issue. We’ll now get one of Xcode’s purple warnings saying that we’re not allowed to publish changes to our view model’s state
property from a background thread. That’s because our loader.loadItems
call will actually return its value on a background queue, since it’s calling URLSession
under the hood.
Main queue dispatching
It’s important to remember that async/await isn’t a multi-threading orchestration tool, it simply provides a built-in, language-level way for us to await the results of asynchronous calls. To ensure that our state does, in fact, get updated on the main queue, we could either go the old fashioned route and use DispatchQueue.main.async
:
class WWDCItemsViewModel: ObservableObject {
...
func load() {
state = .loading
async {
do {
let items = try await loader.loadItems()
DispatchQueue.main.async {
self.state = .loaded(items)
}
} catch {
DispatchQueue.main.async {
self.state = .failed(error)
}
}
}
}
}
The above certainly works, but we’ve arguably lost a bit of the “elegance” that async/await gives us, since we now have to manually perform our main queue dispatch within both of our two code branches.
Thankfully, there’s a better way, and that’s to use one of Swift’s new structured concurrency features — actors. Now, I’m not going to go into a lot of detail about actors right here, because that’ll be a topic for another article later this week, but in this particular context, we can mark our WWDCItemsViewModel
class as “belonging” to our app’s main actor. That way, all of the async tasks that it creates will automatically be managed on the main queue, which’ll make sure that our state will never get updated on a background queue. To make that change happen, all that we have to do is to annotate our class with the @MainActor
attribute — like this:
@MainActor
class WWDCItemsViewModel: ObservableObject {
...
func load() {
state = .loading
async {
do {
let items = try await loader.loadItems()
state = .loaded(items)
} catch {
state = .failed(error)
}
}
}
}
And just like that, our purple warning is gone, and our asynchronous code is now executed and handled in a correct manner from a threading point of view.
Conclusion
The closure-based async
mechanism provides an easy way to kick off async/await-based operations within synchronous contexts, and works anywhere — in SwiftUI views, UIKit-based view controllers, or completely custom objects. We do, however, still need to keep thread-safety in mind, especially when interacting with main queue-only APIs.
Of course, concurrency is a gigantic topic, and Swift 5.5 is full of new tools that let us write concurrent and asynchronous code is brand new ways. To learn more about some of the concepts discussed in this article, I recommend watching the following two sessions:
Thanks for reading!
Automatically build, test and distribute your app on every Pull Request, and use a powerful new suite of add-ons to visualize your test results, to ship your app with ease, and to add crash and performance monitoring to your project. Get started for free.