Asynchronous programming with SwiftUI and Combine
The Future of Combine and async/await
Mobile applications have to deal with a constant flow of events: user input, network traffic, and callbacks from the operating system are all vying for your app’s attention. Building apps that feel snappy is a challenging task, as you have to efficiently handle all those events.
Combine and async/await are some fairly recent addition to the collection of frameworks and language features that aim at making this easier.
In this blog post, we will explore commonalities and differences of Combine and async/await, and I will show you how you can efficiently use both to call asynchronous APIs in your SwiftUI apps.
To better understand the respective characteristics, we will look at a couple of code snippets taken from a SwiftUI screen that allows users to search for books by title. This example builds upon a previous blog post I wrote about Cooperative Task Cancellation, and uses the Open Library API.
Fetching data using Combine
Many of Apple’s APIs are Combine-enabled, and
URLSession is one of them. To fetch data from a URL, we can call
dataTaskPublisher, and then use some of Combine’s operators to handle the response and transform it into a data model our application can work with. The following code snippet shows a typical Combine pipeline for fetching data from a remote API, mapping the result, extracting the information we need, and handling errors.
Error handling in this code snippet is rather basic. I’ve written about this topic more extensively in Error Handling with Combine and SwiftUI, and I recommend checking it out if your want to learn how to handle Combine errors and show them to the user in a meanigful way in SwiftUI apps.
For someone who is not familiar with Combine, it might not be immediately obvious how this code works, let alone being able to put together a pipeline like this. Getting into a functional reactive mindset probably is one of the biggest hurdles when learning Combine.
Fetching data using async/await
Let’s now look at how to implement the same method using
async/await. Apple has made sure that the most important asynchronous APIs can be called using
async/await. To fetch data from a URL, we can asynchronously call
await URLSession.shared.data(from: url). By wrapping this call inside a
try catch block, we can add the same kind of error handling we implemented in the previous code snippet and return an empty array in case an error occurred.
If you’re curious how Apple managed to provide async/await compatible versions of so many of their APIs, I recommend checking out Using async/await with Firebase, in which I explain how Concurrency Interoperability with Objective-C (SE-0297) works.
If you’ve got some experience writing and reading Swift code, you will be able to understand what this code does - even if you’ve got no prior experience with
async/await: all keywords related to
async/await blend in with the rest of the code, making it rather natural to read and understand. This is not least due to the fact that the Swift language team modelled Swift’s concurrency features similar to how error handling works using
Of course, to write code like this, you need a basic understanding of Swift’s concurrency features, so there definitely is a learning curve.
Is this the end of Combine?
Looking at these two code snippets, you might argue that the one making use of
async/await is easier to understand for developers who might not be familiar with neither Combine nor
async/wait, mostly due to the fact you can read if from top to bottom in a linear way.
On the contrary, to understand the Combine version of the code, you have to know what a publisher is, why some of the operations are nested (for example the code for mapping a book inside the
compactMap/map structure), and why on earth you need to call
eraseToAnyPublisher. This can look very confusing if you’re new to Combine.
Add to that the lack of sessions about Combine at WWDC 2021 - it really seemed like Apple lost their enthusiasm for functional reactive programming.
So - given both code snippets seem to do the same - is this the end of Combine?
Well, I don’t think so, and this has to do with the fact SwiftUI is tightly integrated with Combine. In fact, Combine makes a number of things in SwiftUI a lot easier with surprisingly little code.
Connecting the UI…
To better understand this, let’s look at how to call the above code snippets from SwiftUI. The following code shows a typical way to implement a search screen: we’ve got a
List view to display the results, and a
.searchable view modifier to set up the search field and connect it to the
searchTerm published property on a view model:
… to a Combine pipeline
By making the
searchTerm a published property on the view model, it becomes a Combine publisher, allowing us to use it as a starting point for a Combine pipeline. The view model’s initialiser is a good place to set up this pipeline:
Here, we subscribe to the
searchTerm publisher, and then use a couple of Combine operators to take the user’s input, call the remote API, receive the results and assign them to a published property that is connected to the UI:
debounceoperator will only pass on events after there has been a 0.8s pause between event. This way, we will only call the remote API when the user has finished typing or pauses for a brief moment.
- We use the
mapoperator to call the
searchBookspipeline (which itself is a publisher), and return its results into the pipeline.
- Even though we use the
debounceoperator to reduce the number of events, we might run into a situation where multiple network requests are in flight at the same time. As a consequence, the network responses might arrive out-of-ordfer. To prevent this, we use
switchToLatest()- this will switch to the latest output from the upstream publisher and discards any other previous events.
- To make sure we make changes to the UI only from the main thread, we call
- To assign the result of the pipeline (an array of
Bookinstances we receive from
searchBooks) to the published property
result, we would normally use the
assign(to:)subscriber, but as we also want to set the
false(to turn off the progress view on our UI), we need to use the
sinksubscriber, as this will allow us to perform multiple instructions.
- Using the
sinksubscriber also usually means we need to store the subscription in a
Notice how easy it is to handle challenging tasks like discarding out-of-order events or reducing the number of requests being sent by only sending requests when the user stops typing. As you will see in a moment, this is slightly more complicated when using
… to an async/await method
How would the same code look like when using
To call the
async/await based version of
searchBooks, we need to choose a slightly different approach. Instead of subscribing to the
$searchTerm publisher, we create an
async method named
executeQuery and create a
Task that calls
Task, we also handle the progress view’s state by updating the view model’s
isSearching published property according to the current state of the process.
In the Combine-based version of this part of the app, we used a combination of
switchToLatest to make sure we only receive results for the most recent user input. This is particularly important for network requests, as they might return out of order.
To achieve the same using
async/await, we need to use cooperative task cancellation: we keep a reference to the task in
searchTask (1) and cancel any potentially running task (2) before starting a new one (3). To learn more about cooperative task cancellation, check out this blog post.
searchBooks is marked as
async, the Swift runtime can decide to execute it on a non-main thread. However, in
executeQuery, we want to update the UI by setting published properties
result (5) and
isSearching (4, 6). To ensure it runs on the main thread, we have to mark it using the
@MainActor attribute (7).
As a final step, we need to make a small but important change to the UI: since we cannot subscribe an asynchronous method to a published property, we need to find another way to call
executeQuery for each character the user types into the search field.
It turns out that Apple added a suitable view modifier to the most recent version of SwiftUI -
onReceive(_ publisher:). This view modifier allows us to register a closure that will be called whenever the given publisher emits an event:
async/await requires more work on our part, and it is easy to get things like cooperative task cancellation wrong or forget an inportant step, like cancelling any tasks that might still be running. In terms of developer experience, Combine follows a much more declarative approach than
async/await: you tell the framework what to do, not how to do it.
Calling asynchronous code from Combine
In the previous section, I claimed that we cannot subscribe to a Combine publisher using
async/await. But is this actually true? Let’s see if we can implement a smart way to combine async/await and Combine.
The following snippet shows a view model that uses a Combine pipeline that calls an asynchronous version of the
This approach allows us to tap into the power of Combine to improve the user experience with just a few lines of code:
- By using the
debounceoperator (1), we can hold off on sending search requests over the network until the user has stopped typing for a second. This means we will consume less bandwidth (good for the user), and cause fewer API calls (good for us, esp. when calling APIs that might be billed).
- We can further reduce the number of requests by removing any duplicate API calls using the
There are also some advantages on the code level:
- By using the
handleEventsoperator (3, 4), we can extract the code for handling the progress view from the
sinkoperators. This also allows us to replace the
sink/storecombo by a much simpler and easier to use
- There is only one place (5) in which we assign the result of the pipeline to the
resultproperty, reducing the chances to introduce subtle programming errors
At the same time, we can use the advantages of
async/await when writing network access code: being able to read the code from top to bottom in a linear way makes it a lot easier to understand than code that makes use of callbacks or nested closures.
Let’s take a closer look at the code that allows us to call an asynchronous method from a Combine pipeline:
To call the asynchronous version of
searchBooks, we need to establish an asynchronous context. This is why we wrap the call in a
searchBook returns, we resolve the promise by sending the result as a
.success case value.
We can simplify this code by extracting the relevant part into an extension on
This allows us to call an asynchronous method using the following code:
The seeming lack of attention Apple paid to Combine at WWDC 2021 resulted in a lot of confusion and uncertainty in the community - should you invest into learning Combine in the light of all the attention Apple put on
To answer this question, we need to take a step back and understand the value propositions of Combine and
At a cursory glance, they seem to address the same use case: asynchronously calling APIs. However, when looking closer, it becomes clear that they are very different indeed:
Combine is a reactive framework, with the notion of a stream of events that you transform using operators before consuming them with a subscriber. This side-effect-free way of programming makes is easier to ensure your app is always in a consistent state. In fact, SwiftUI’s state management system makes heavy use of Combine - every
@Published property is - as the name implies - a publisher, making it easy to connect a Combine pipeline.
Async/await, on the other hand, aims at making asynchronous programming and handling concurrency easier to implement and reason about. While this makes it easier to create a linear control flow, it doesn’t offer the same guarantees about state as Combine does.
My recommendation is to use whichever of the two makes the most sense in any given situation. For any UI-related task, I personally prefer using Combine, as it gives us unprecedented power and flexibility when implementing otherwise difficult-to-implement aspects like debouncing user input, combining multiple input streams into one, and efficiently handling out-of-order execution of network requests.
Async/await is a great tool for implementing asynchronous calls - no matter if you’re calling a remote API such as a network service or a BaaS platform like Firebase.
And finally, as you saw in this blog post, combining async/await and Combine is possible, allowing you to mix and match the best aspects of both approaches.
Thanks for reading 🔥