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 try catch
.
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:
- The
debounce
operator 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
map
operator to call thesearchBooks
pipeline (which itself is a publisher), and return its results into the pipeline. - Even though we use the
debounce
operator 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 useswitchToLatest()
- 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
receive(on: DispatchQueue.main)
. - To assign the result of the pipeline (an array of
Book
instances we receive fromsearchBooks
) to the published propertyresult
, we would normally use theassign(to:)
subscriber, but as we also want to set theisSearching
property tofalse
(to turn off the progress view on our UI), we need to use thesink
subscriber, as this will allow us to perform multiple instructions. - Using the
sink
subscriber also usually means we need to store the subscription in aCancellable
or aSet
ofAnyCancellables
.
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 async/await
.
… to an async/await method
How would the same code look like when using async/await
?
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 searchBooks
:
Inside the 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 map
and 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.
Since 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:
Overall, using 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 searchBooks
method:
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
debounce
operator (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
removeDuplicates
operator (2)
There are also some advantages on the code level:
- By using the
handleEvents
operator (3, 4), we can extract the code for handling the progress view from themap
andsink
operators. This also allows us to replace thesink/store
combo by a much simpler and easier to useassign
subscriber - There is only one place (5) in which we assign the result of the pipeline to the
result
property, 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 Task
. Once 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 Publisher
:
This allows us to call an asynchronous method using the following code:
Closure
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 async/await
?
To answer this question, we need to take a step back and understand the value propositions of Combine and async/await
.
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 🔥
Improve your app's UX with SwiftUI's task view modifier
Mastering the art of the pause
SwiftUI Hero Animations with NavigationTransition
Replicating the App Store Hero Animation
Styling SwiftUI Views
How does view styling work?
Previewing Stateful SwiftUI Views
Interactive Previews for your SwiftUI views
Building a Custom Combine Operator for Exponential Backoff
Make your Combine code reusable