Asynchronous programming with SwiftUI and Combine

The Future of Combine and async/await


Mar 21, 2022 • 14 min readSource Code

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.

private func searchBooks(matching searchTerm: String) -> AnyPublisher<[Book], Never> {
  let escapedSearchTerm = searchTerm.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed) ?? ""
  let url = URL(string: "https://openlibrary.org/search.json?q=\(escapedSearchTerm)")!
  
  return URLSession.shared.dataTaskPublisher(for: url)
    .map(\.data)
    .decode(type: OpenLibrarySearchResult.self, decoder: JSONDecoder())
    .map(\.books)
    .compactMap { openLibraryBooks in
      openLibraryBooks?.map { Book(from: $0) }
    }
    .replaceError(with: [Book]())
    .eraseToAnyPublisher()
}

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.

private func searchBooks(matching searchTerm: String) async -> [Book] {
  let escapedSearchTerm = searchTerm.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed) ?? ""
  let url = URL(string: "https://openlibrary.org/search.json?q=\(escapedSearchTerm)")!
  
  do {
    let (data, _) = try await URLSession.shared.data(from: url)
    
    let searchResult = try OpenLibrarySearchResult.init(data: data)
    guard let libraryBooks = searchResult.books else { return [] }
    return libraryBooks.compactMap { Book(from: $0) }
  }
  catch {
    return []
  }
}

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:

struct BookSearchCombineView: View {
  @StateObject var viewModel = ViewModel()
 
  var body: some View {
    List(viewModel.result) { book in
      BookSearchRowView(book: book)
    }
    .searchable(text: $viewModel.searchTerm)
  }
}

… 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:

fileprivate class ViewModel: ObservableObject {
  @Published var searchTerm: String = ""
  
  @Published private(set) var result: [Book] = []
  @Published var isSearching = false
  
  private var cancellables = Set<AnyCancellable>()
  
  init() {
    $searchTerm
      .debounce(for: 0.8, scheduler: DispatchQueue.main) 1
      .map { searchTerm -> AnyPublisher<[Book], Never> in 2
        self.isSearching = true
        return self.searchBooks(matching: searchTerm)
      }
      .switchToLatest() 3
      .receive(on: DispatchQueue.main) 4
      .sink(receiveValue: { books in 5
        self.result = books
        self.isSearching = false
      })
      .store(in: &cancellables) 6
  }
  
  private func searchBooks(matching searchTerm: String) -> AnyPublisher<[Book], Never> {
  // ...
  }
}

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:

  1. 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.
  2. We use the map operator to call the searchBooks pipeline (which itself is a publisher), and return its results into the pipeline.
  3. 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 use switchToLatest() - this will switch to the latest output from the upstream publisher and discards any other previous events.
  4. To make sure we make changes to the UI only from the main thread, we call receive(on: DispatchQueue.main).
  5. To assign the result of the pipeline (an array of Book instances 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 isSearching property to false (to turn off the progress view on our UI), we need to use the sink subscriber, as this will allow us to perform multiple instructions.
  6. Using the sink subscriber also usually means we need to store the subscription in a Cancellable or a Set of AnyCancellables.

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:

fileprivate class ViewModel: ObservableObject {
  @Published var searchTerm: String = ""
  
  @Published private(set) var result: [Book] = []
  @Published private(set) var isSearching = false
  
  private var searchTask: Task<Void, Never>? 1
  
  @MainActor 7
  func executeQuery() async {
    searchTask?.cancel() 2
    let currentSearchTerm = searchTerm.trimmingCharacters(in: .whitespaces)
    if currentSearchTerm.isEmpty {
      result = []
      isSearching = false
    }
    else {
      searchTask = Task { 3
        isSearching = true 4
        result = await searchBooks(matching: searchTerm) 5
        if !Task.isCancelled {
          isSearching = false 6
        }
      }
    }
  }
  
  private func searchBooks(matching searchTerm: String) async -> [Book] {
  // ...
  } 
}

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:

List(viewModel.result) { book in
  BookSearchRowView(book: book)
}
.searchable(text: $viewModel.searchTerm)
.onReceive(viewModel.$searchTerm) { searchTerm in
  Task {
    await viewModel.executeQuery()
  }
}

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:

fileprivate class ViewModel: ObservableObject {
  // MARK: - Input
  @Published var searchTerm: String = ""
  
  // MARK: - Output
  @Published private(set) var result: [Book] = []
  @Published var isSearching = false
  
  // MARK: - Private
  private var cancellables = Set<AnyCancellable>()
  
  init() {
    $searchTerm
      .debounce(for: 0.8, scheduler: DispatchQueue.main) 1
      .removeDuplicates() 2
      .handleEvents(receiveOutput: { output in 3
        self.isSearching = true
      })
      .flatMap { value in
        Future { promise in
          Task {
            let result = await self.searchBooks(matching: value)
            promise(.success(result))
          }
        }
      }
      .receive(on: DispatchQueue.main)
      .eraseToAnyPublisher()
      .handleEvents(receiveOutput: { output in 4
        self.isSearching = false
      })
      .assign(to: &$result) 5
  }
  
  private func searchBooks(matching searchTerm: String) async -> [Book] {
    let escapedSearchTerm = searchTerm.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed) ?? ""
    let url = URL(string: "https://openlibrary.org/search.json?q=\(escapedSearchTerm)")!
    
    do {
      let (data, _) = try await URLSession.shared.data(from: url)
      
      let searchResult = try OpenLibrarySearchResult.init(data: data)
      guard let libraryBooks = searchResult.books else { return [] }
      return libraryBooks.compactMap { Book(from: $0) }
    }
    catch {
      return []
    }
  }
}

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 the map and sink operators. This also allows us to replace the sink/store combo by a much simpler and easier to use assign 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:

somePublisher
  .flatMap { value in
    Future { promise in
      Task {
        let result = await self.searchBooks(matching: value)
        promise(.success(result))
      }
    }
  }

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:

extension Publisher {  
  /// Executes an asyncronous call and returns its result to the downstream subscriber.
  ///
  /// - Parameter transform: A closure that takes an element as a parameter and returns a publisher that produces elements of that type.
  /// - Returns: A publisher that transforms elements from an upstream  publisher into a publisher of that element's type.
  func `await`<T>(_ transform: @escaping (Output) async -> T) -> AnyPublisher<T, Failure> {
    flatMap { value -> Future<T, Failure> in
      Future { promise in
        Task {
          let result = await transform(value)
          promise(.success(result))
        }
      }
    }
    .eraseToAnyPublisher()
  }
}

This allows us to call an asynchronous method using the following code:

somePublisher
  .await { searchTerm in
    await self.searchBooks(matching: searchTerm)
  }

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 🔥

Source code
You can find the source code for this post in this GitHub repo.
Newsletter
Enjoyed reading this article? Subscribe to my newsletter to receive regular updates, curated links about Swift, SwiftUI, Combine, Firebase, and - of course - some fun stuff 🎈