Loading...

Combine in SwiftUI and how you can rewrite the same code using async await

question swiftui swift
Ram Patra Published on September 2, 2024

Combine is Apple’s declarative framework for handling asynchronous events and data streams in Swift. Introduced in SwiftUI and iOS 13, Combine leverages reactive programming principles, allowing developers to process values over time and manage complex asynchronous workflows with clarity and efficiency.

What is Combine?

Combine provides a unified declarative API for processing values over time. It allows developers to work with asynchronous events such as user input, network responses, and notifications in a consistent and composable manner. By using Combine, you can chain operations, handle errors gracefully, and manage the lifecycle of asynchronous tasks seamlessly.

Key Components of Combine

  1. Publishers: Objects that emit a sequence of values over time. They define how values are produced and when they are sent.

  2. Subscribers: Objects that receive and react to values emitted by publishers. They define how to handle the incoming data.

  3. Operators: Methods that you can chain to transform, filter, or combine the data streams from publishers before they reach subscribers.

  4. Subscriptions: The connection between a publisher and a subscriber. They manage the lifecycle of data flow and resource management.

  5. Subjects: Special publishers that can both emit values and be subscribed to. They act as bridges between imperative and declarative code.

Basic Terminology

  • Backpressure: A mechanism to control the flow of data to prevent overwhelming subscribers.
  • Sinks: A type of subscriber that allows you to handle received values and completion events with closures.

When to Use Combine

Combine is ideal for scenarios that involve handling asynchronous data streams and complex event processing. Here are common use cases:

  1. Networking: Managing API calls, handling responses, and chaining multiple network requests.
  2. User Interface Updates: Reacting to user input, updating UI components in response to data changes.
  3. Data Binding: Synchronizing data between the model and the view in a declarative manner.
  4. Reactive Forms: Validating and processing form inputs in real-time.
  5. Event Handling: Managing system events, notifications, and other asynchronous triggers.

Combine shines in situations where you need to handle multiple asynchronous tasks that depend on each other, providing a clean and maintainable way to manage such workflows.

Basic Example

Let’s walk through a simple example where Combine is used to handle a network request.

Scenario

Suppose you want to fetch a list of users from a remote API and display them in a table view.

Step-by-Step Implementation

  1. Define the Data Model
struct User: Codable, Identifiable {
    let id: Int
    let name: String
    let email: String
}
  1. Create a Network Manager with Combine
import Combine
import Foundation

class NetworkManager {
    static let shared = NetworkManager()
    private init() {}
    
    func fetchUsers() -> AnyPublisher<[User], Error> {
        let url = URL(string: "https://api.example.com/users")!
        
        return URLSession.shared.dataTaskPublisher(for: url)
            .map { $0.data }
            .decode(type: [User].self, decoder: JSONDecoder())
            .receive(on: DispatchQueue.main) // Ensure updates happen on the main thread
            .eraseToAnyPublisher()
    }
}
  1. Create a ViewModel to Manage Data
import Combine
import SwiftUI

class UsersViewModel: ObservableObject {
    @Published var users: [User] = []
    @Published var errorMessage: String?
    
    private var cancellables = Set<AnyCancellable>()
    
    func loadUsers() {
        NetworkManager.shared.fetchUsers()
            .sink { completion in
                switch completion {
                case .finished:
                    break
                case .failure(let error):
                    self.errorMessage = error.localizedDescription
                }
            } receiveValue: { users in
                self.users = users
            }
            .store(in: &cancellables)
    }
}
  1. Create the SwiftUI View
import SwiftUI

struct UsersView: View {
    @StateObject private var viewModel = UsersViewModel()
    
    var body: some View {
        NavigationView {
            List(viewModel.users) { user in
                VStack(alignment: .leading) {
                    Text(user.name)
                        .font(.headline)
                    Text(user.email)
                        .font(.subheadline)
                        .foregroundColor(.gray)
                }
            }
            .navigationTitle("Users")
            .onAppear {
                viewModel.loadUsers()
            }
            .alert(item: $viewModel.errorMessage) { errorMessage in
                Alert(title: Text("Error"), message: Text(errorMessage), dismissButton: .default(Text("OK")))
            }
        }
    }
}

Explanation

  • NetworkManager: Uses URLSession with dataTaskPublisher to perform the network request. It decodes the JSON response into an array of User objects and ensures that the results are received on the main thread for UI updates.

  • UsersViewModel: An ObservableObject that publishes the list of users and any error messages. It subscribes to the publisher returned by fetchUsers() and handles the incoming data or errors accordingly. The cancellables set stores the subscriptions to manage their lifecycle.

  • UsersView: A SwiftUI view that observes the UsersViewModel. It displays the list of users and triggers the loadUsers() method when the view appears. If an error occurs, it presents an alert to the user.

Advantages of Using Combine

  1. Declarative Syntax: Write clear and concise code that describes what you want to achieve rather than how to do it.

  2. Composability: Chain multiple operations seamlessly using operators, making complex data transformations manageable.

  3. Unified Framework: Handle various asynchronous tasks like networking, user input, and notifications within the same framework.

  4. Type Safety: Strongly typed, ensuring compile-time checks and reducing runtime errors.

  5. Memory Management: Automatic management of subscriptions’ lifecycle using cancellables, preventing memory leaks.

Alternatives to Combine

While Combine is powerful, it may not always be the best fit for every project. Alternatives include:

  1. ReactiveSwift / RxSwift: Third-party reactive programming frameworks that offer similar functionalities with different syntax and features. Useful if you need cross-platform support or are maintaining legacy projects.

  2. Async/Await: Swift’s built-in concurrency model introduced in Swift 5.5. For simpler asynchronous tasks, async/await can be more straightforward and easier to understand.

  3. Completion Handlers: Traditional callback-based asynchronous handling. Suitable for simple tasks but can become unwieldy for complex workflows.

When Not to Use Combine

  1. Simple Asynchronous Tasks: For straightforward tasks like a single network request, using async/await or completion handlers might be more readable and less complex.

  2. Legacy Projects: If your project already uses another reactive framework or is not modular enough to incorporate Combine, it might not be worth the effort to integrate.

  3. Cross-Platform Needs: Combine is Apple-specific. If you’re developing for multiple platforms, consider cross-platform solutions like RxSwift.

  4. Learning Curve: Combine introduces new concepts that might have a steep learning curve for developers unfamiliar with reactive programming. For teams not experienced with these paradigms, the initial overhead might outweigh the benefits.

Conclusion

Combine is a robust framework for managing asynchronous data streams and events in Swift. By embracing declarative and reactive programming principles, Combine allows for clean, maintainable, and efficient code, especially in complex scenarios involving multiple asynchronous operations. However, it’s essential to assess your project’s specific needs and consider alternatives when Combine might not be the optimal choice.

Rewriting the Example with Async/Await

This approach simplifies the code and is easier to understand for straightforward asynchronous tasks like fetching data from a network.

Step-by-Step Implementation

  1. Define the Data Model

The data model remains the same as in the Combine example.

struct User: Codable, Identifiable {
    let id: Int
    let name: String
    let email: String
}
  1. Create a Network Manager Using Async/Await

Here, we define the NetworkManager class using async/await for making the network request.

import Foundation

class NetworkManager {
    static let shared = NetworkManager()
    private init() {}
    
    func fetchUsers() async throws -> [User] {
        let url = URL(string: "https://api.example.com/users")!
        
        let (data, _) = try await URLSession.shared.data(from: url)
        let users = try JSONDecoder().decode([User].self, from: data)
        return users
    }
}
  1. Create a ViewModel to Manage Data

The UsersViewModel is updated to use async/await when loading users.

import SwiftUI

@MainActor
class UsersViewModel: ObservableObject {
    @Published var users: [User] = []
    @Published var errorMessage: String?
    
    func loadUsers() async {
        do {
            let users = try await NetworkManager.shared.fetchUsers()
            self.users = users
        } catch {
            self.errorMessage = error.localizedDescription
        }
    }
}

Explanation:

  • @MainActor: This attribute ensures that all the UI updates made in UsersViewModel happen on the main thread, which is necessary for updating the UI safely.

  • async/await: The loadUsers method is now asynchronous and uses the await keyword to call fetchUsers. Errors are handled using a do-catch block, making error handling straightforward.

  1. Create the SwiftUI View

Finally, the SwiftUI view that observes the UsersViewModel remains similar, but now it uses task to handle the asynchronous call.

import SwiftUI

struct UsersView: View {
    @StateObject private var viewModel = UsersViewModel()
    
    var body: some View {
        NavigationView {
            List(viewModel.users) { user in
                VStack(alignment: .leading) {
                    Text(user.name)
                        .font(.headline)
                    Text(user.email)
                        .font(.subheadline)
                        .foregroundColor(.gray)
                }
            }
            .navigationTitle("Users")
            .task {
                await viewModel.loadUsers()
            }
            .alert(item: $viewModel.errorMessage) { errorMessage in
                Alert(title: Text("Error"), message: Text(errorMessage), dismissButton: .default(Text("OK")))
            }
        }
    }
}

Explanation:

  • .task { }: The task modifier is used to start the asynchronous loadUsers function when the view appears. It automatically manages the lifecycle of the task, ensuring it’s cancelled when the view disappears.

  • await: This keyword is used to wait for the loadUsers function to complete. The task modifier handles the async context automatically.

Advantages of Using Async/Await

  1. Simpler Syntax: async/await allows you to write asynchronous code that looks and behaves more like synchronous code, reducing cognitive load.
  2. Improved Readability: The code is more linear and easier to follow, especially for developers who are new to Swift concurrency.
  3. Better Error Handling: Error handling is straightforward using try/catch, which is familiar to most Swift developers.

Conclusion

Using async/await simplifies the code for handling asynchronous tasks in Swift, especially when dealing with network requests. This approach is more intuitive and easier to maintain than using Combine, making it an excellent choice for simple asynchronous workflows.

While Combine offers more powerful tools for complex asynchronous processing and chaining, async/await provides a cleaner and more approachable solution for straightforward tasks like the one demonstrated above.

Presentify

Take your presentation to the next level.

FaceScreen

Put your face and name on your screen.

ToDoBar

Your to-dos on your menu bar.

Ram Patra Published on September 2, 2024
Image placeholder

Keep reading

If this article was helpful, others might be too

question swiftui swift October 7, 2024 How to zoom in and zoom out a SwiftUI view?

In a macOS or iOS app, you can easily add a zoom feature to any SwiftUI view with the scaleEffect modifier. In the below example, I am using a Slider to control the zoom level. Here’s how you can implement zooming in and out with a slider:

question swiftui macos September 4, 2024 How to execute some code before app termination in a macOS app using SwiftUI?

To run some code before app termination in a macOS app using SwiftUI, the correct approach would involve placing the termination logic within a view, such as the ContentView. Here’s how you can do it:

question swiftui swift October 30, 2023 How to convert Color type to hex and vice-versa while retaining alpha information?

The below should work both on macOS and iOS with one minor change. That is, use UIColor instead of NSColor if you’re planning to use it for iOS.