Async-Await Vs Combine in Swift

Introduction

Asynchronous programming has always been at the core of iOS development. Over the years, multiple programming techniques and paradigms have been employed to get tasks running out of the main queue. For a large part of the recent history of iOS programming, Grand Central Dispatch (GCD) along with NSOperation has been widely utilized for creating concurrent and multithreaded applications.

In 2019, Apple introduced the Combine framework, which served as a declarative Swift API for processing values over time. This framework provides a suite of operators to manipulate streams of values and process them over a period. Subsequently, in 2021 with Swift 5.5, Apple released async-await – a new concurrency model, promising to make working with concurrent code simpler and less error-prone.

The fundamental question which now arises is – whether we should use async-await instead of Combine for our Swift codebase? Let's dive into the analysis opines of both!

Basics of Asynchronous Programming in Swift

Before answering the pivotal question, let's recapitulate the basics of asynchronous programming. The term ‘asynchronous’ means not happening at the same time. In the realm of iOS development, Asynchronous programming refers to the concept where multiple tasks can run concurrently without waiting for one task to finish first.

GCD and NSOperation, and later Combine and async-await, all revolve around handing off tasks to be run outside the main queue, enabling the creation of smooth and responsive user interfaces.

An Overview of Combine

The Combine framework provides a declarative Swift API for processing values over time. These values can represent various types of event-like asynchronous data, and you can compose, transform, and consume them according to your needs. Combine integrates well with other Apple APIs, such as SwiftUI and CoreData, and offers built-in tools for tasks like error handling, complex asynchronous event processing, combining and chaining asynchronous operations, among others.

Here is an overview of the main components:

Publishers: It’s the one producing values and the heart of Combine's design. You can create a custom publisher, but most of the time, you’ll use those provided by Apple.

Subscribers: They receive and react to the values from a publisher.

Operators: They can manipulate and transform the data being published.

Let's consider a simple example of using Combine:

import Combine

var cancellables = Set<AnyCancellable>() 

let publisher = Just("Hello Combine")

publisher
    .sink { print($0) }
    .store(in: &cancellables)

In the above code, Just is a publisher that emits an output to subscribers just once and then finishes. sink acts as a subscriber and receives values from the publisher. store(in:) is used to hold onto cancellable instances, which ensures that the subscription isn’t deallocated prematurely.

Async-Await in Swift

In Swift 5.5, the async-await pattern was introduced as a modern concurrency mechanism that simplifies asynchronous code. It consists of the use of two keywords - async and await. The async keyword denotes a function that performs asynchronous operations and does not block the execution of the code while the await keyword is used to mark a blocking operation. Async functions return their results via "futures'' that the caller can wait for using the await keyword.

Consider the below code snippet to understand async-await:

func fetchData() async throws -> Data {
    let url = URL(string: "https://example.com")!
    let (data, _) = try await URLSession.shared.data(from: url)
    return data
}

Task {
    do {
        let data = try await fetchData()
        print(data)
    } catch {
        print("Failed to fetch data")
    }
}

In the example above, fetchData() is identified as an asynchronous function with the async keyword. It uses URLSession.shared.data(from: url) which fetches data from the server asynchronously. The calling function uses the await keyword to wait for the completion of the asynchronous function. Task is used to create new top-level tasks.

Async-Await versus Combine: The Trade-offs

The arrival of async-await doesn't necessarily mean you need to drop Combine altogether. Both have their uses, benefits, and trade-offs:

  • Readability and Complexity: async-await syntax is considered simpler and more intuitive than Combine. It can be less daunting for a developer who is not familiar with reactive programming concepts.

    Combine, on the other hand, comes with a learning curve due to the nature of reactive programming and the extensive use of operators. But once mastered, it can offer a comprehensive suite of functionality for complex asynchronous sequences.

  • Integration with Apple APIs: Combine received native integrations with Apple APIs out-of-the-box. This can decrease the boilerplate code needed to adapt traditional APIs for use with Combine. However, with iOS 15, many of Apple’s APIs have now been updated to include async-await counterparts.

  • Debugging: Debugging async-await code is straightforward as it follows a linear, top-to-bottom execution flow. Debugging Combine code can be complex due to the chaining of operators, making it harder to understand where a problem might lie.

  • Backward Compatibility: Combine requires a minimum of iOS 13, whereas async-await is only compatible with iOS 15 and later. This is a significant point to consider when deciding between the two, especially if you target devices running older iOS versions.

  • Error Handling: Error handling in Combine is generally comprehensive once grasped but tricky due to the need to manage completion and failure as separate events. On the other hand, error handling in async-await is simpler and leverages Swift’s native error handling mechanism via do/catch blocks.

To Combine or await – that is the question!

So, which one to use? Any choice here should be informed primarily by your project requirements and iOS versions you target.

If you are starting a new project and your lowest target is iOS 15, then async-await is unquestionably the preferred choice due to its simplicity and straightforwardness.

In existing projects already implementing Combine, maintaining Combine can make sense due to existing codebase dependencies. However, gradually refactoring to async-await for new features or modules can be considered, especially considering its clearer syntax and easier debugging.

To use an example, let's compare Combine and async-await in a common scenario: fetching a URL.

With Combine:

import Combine

func fetchURLPublisher() -> AnyPublisher<Data, Error> {
   let url = URL(string: "https://api.github.com")!
   return URLSession.shared.dataTaskPublisher(for: url)
       .map(\.data)
       .eraseToAnyPublisher()
}

let cancelablePublisher = fetchURLPublisher()
    .sink(receiveCompletion: { _ in },
          receiveValue: { data in
             print(data)
})

Here we fetch data using the URLSession dataTaskPublisher extension and publish its data.

With async-await:

func fetchURLAsync() async throws -> Data {
   let url = URL(string: "https://api.github.com")!
   let (data, _) = try await URLSession.shared.data(from: url)
   return data
}

Task {
    do {
        let data = try await fetchURLAsync()
        print(data)
    } catch {
        print(error)
    }
}

In this example, we fetch data with URLSession.shared.data(from: url). The async keyword transforms the function into an asynchronous one, and the await keyword is used to designate the point where execution should be paused until the awaited promise is fulfilled.

From these examples, it’s clear that async-await code tends to be cleaner and more understandable, making it an excellent choice for new projects or projects targeting iOS 15 and up.

Migrating from Combine to Async-Await

If you decide to migrate your Combine codebase to async-await based, there are some practical steps to consider:

  • Begin small: Start by identifying smaller, potentially standalone tasks, then gradually phase out Combine where it makes sense.

  • Preserve backward compatibility: For parts of the codebase that need to support versions older than iOS 15, you may have to rely on Combine or older GCD or closure-based methods.

  • Dual Maintenance: There can be cases where you have to maintain both Combine and async-await versions. But, the future seems to point towards more support and integration for async-await.

Here's how you might translate a simple Combine pipeline to an async-await representation:

Combine Version:

func fetchUser() -> AnyPublisher<User, Error> {
    let url = URL(string: "https://api.example.com/user")!
    return URLSession.shared.dataTaskPublisher(for: url)
        .tryMap { data, _ -> User in
            let decoder = JSONDecoder()
            return try decoder.decode(User.self, from: data)
        }
        .eraseToAnyPublisher()
}

fetchUser()
    .sink(receiveCompletion: { completion in
        switch completion {
        case .failure(let error):
            print("Received an error: \(error)")
        case .finished:
            print("Publisher finished")
        }
    }, receiveValue: { user in
        print("Received a user: \(user)")
    })
    .store(in: &cancellables)

Async-await version:

func fetchUser() async throws -> User {
    let url = URL(string: "https://api.example.com/user")!
    let (data, _) = try await URLSession.shared.data(from: url)
    let decoder = JSONDecoder()
    return try decoder.decode(User.self, from: data)
}

Task {
    do {
        let user = try await fetchUser()
        print("Received a user: \(user)")
    } catch {
        print("Received an error: \(error)")
    }
}

In the async-await version, error handling is performed using Swift's native try-catch blocks which makes the code more straightforward and easier to read.

Conclusion

In conclusion, while async/await definitely has its distinct advantages in terms of code simplification, readability, and error handling, Combine is not going anywhere soon. It still remains a powerful tool for managing complex asynchronous tasks, and it's worthy to note that async/await and Combine can coexist and be used in tandem in your codebase, as long as you keep their characteristics and your project requirements in mind.

With the evolution of Swift and iOS development, the choice between async/await and Combine won't be a binary one, but rather a strategic decision based on use-case scenarios, project requirements, and future-proofing your application.

The introduction of async/await in Swift is a game-changer. It not only modernizes Swift but also brings it in line with other programming languages that have similar features. With async/await, Swift developers now have a powerful tool that can help them write clear and concise asynchronous code easily. But at the same time, Combine's potential should not be underestimated. It provides a solid reactive programming foundation and a higher level of control over pipeline behavior, especially when dealing with more complex asynchronous tasks or sequences.

Ultimately, the decision on which approach to take should be based on the nature and requirement of your project, your team's familiarity with reactive programming, and your project's compatibility requirements with different iOS versions.

With async/await and Combine, Swift developers have more options to approach asynchronous programming tasks, and each has its strengths. By understanding these tools thoroughly, you can make informed decisions that fit your project and team best. So be sure to experiment with both Combine and async-await in order to decide which approach works best for your specific use-case scenarios. After all, having more options is always better than having less.