Combine Framework: Programmazione Reattiva in Swift

Padroneggia Combine per gestire flussi di dati asincroni in Swift: Publisher, Subscriber, Operator e pattern avanzati per app iOS.

Guida al framework Combine per la programmazione reattiva in Swift iOS

La programmazione reattiva trasforma il modo in cui gli eventi asincroni e i flussi di dati vengono gestiti nelle applicazioni iOS. Combine, il framework nativo di Apple, offre un approccio dichiarativo e type-safe per orchestrare pipeline di dati complesse. Questa guida copre i concetti fondamentali fino ad arrivare ai pattern pronti per la produzione.

Perché Combine al posto di RxSwift?

Combine è integrato in iOS 13+, offre prestazioni migliori grazie alle ottimizzazioni di Apple e si integra perfettamente con SwiftUI. Nessuna dipendenza esterna da gestire.

Concetti fondamentali di Combine

Combine si basa su tre concetti chiave: i Publisher che emettono valori, i Subscriber che li ricevono e gli Operator che trasformano i dati tra di essi. Questa architettura consente di costruire pipeline di dati reattive e componibili.

Publisher: la fonte dei dati

Un Publisher è un tipo capace di emettere una sequenza di valori nel tempo. Ogni Publisher dichiara due tipi associati: il tipo del valore emesso (Output) e il tipo di errore possibile (Failure). Ecco come creare diversi tipi di Publisher:

PublisherBasics.swiftswift
import Combine

// Just: emits a single value then completes
// Useful for converting a simple value to a Publisher
let singleValue = Just("Hello Combine")

// CurrentValueSubject: stores and emits the current value
// Perfect for representing state that changes over time
let counter = CurrentValueSubject<Int, Never>(0)

// PassthroughSubject: emits values without storing them
// Ideal for one-time events (taps, notifications)
let buttonTaps = PassthroughSubject<Void, Never>()

// Future: emits a single value asynchronously
// Wraps an async operation that returns a result
let asyncOperation = Future<String, Error> { promise in
    // Simulate a network call
    DispatchQueue.global().asyncAfter(deadline: .now() + 1) {
        promise(.success("Data loaded"))
    }
}

Il tipo Never per gli errori significa che il Publisher non può mai fallire. È una garanzia a tempo di compilazione che semplifica il codice di gestione degli errori.

Subscriber: ricevere i valori

Un Subscriber si iscrive a un Publisher per riceverne i valori. Il metodo sink è il modo più comune per creare un Subscriber. Riceve due closure: una per gli errori o il completamento e una per ogni valore ricevuto:

SubscriberBasics.swiftswift
import Combine

// Variable to store subscriptions
// Without this reference, the subscription would be immediately cancelled
var cancellables = Set<AnyCancellable>()

let publisher = ["Swift", "Combine", "iOS"].publisher

// sink() creates a Subscriber that receives values
publisher
    .sink(
        // Called when the Publisher completes or fails
        receiveCompletion: { completion in
            switch completion {
            case .finished:
                print("✅ Completed successfully")
            case .failure(let error):
                print("❌ Error: \(error)")
            }
        },
        // Called for each emitted value
        receiveValue: { value in
            print("Received: \(value)")
        }
    )
    // store() keeps a reference to the subscription
    .store(in: &cancellables)

// Output:
// Received: Swift
// Received: Combine
// Received: iOS
// ✅ Completed successfully
Attenzione ai memory leak

Memorizza sempre l'AnyCancellable restituito da sink(). Senza un riferimento, la sottoscrizione viene annullata automaticamente e nessun valore viene ricevuto.

Trasformare i dati con gli Operator

Gli Operator sono il cuore di Combine. Permettono di trasformare, filtrare e combinare flussi di dati in modo dichiarativo. Ogni Operator restituisce un nuovo Publisher, consentendo di concatenarli.

Operator di trasformazione essenziali

Gli Operator di trasformazione modificano ogni valore emesso. map trasforma i valori, flatMap appiattisce i Publisher annidati e compactMap filtra i valori nil:

TransformOperators.swiftswift
import Combine

var cancellables = Set<AnyCancellable>()

// map: transforms each value
// Equivalent to map on arrays
[1, 2, 3, 4, 5].publisher
    .map { $0 * 2 }  // Multiply each number by 2
    .sink { print("Doubled: \($0)") }
    .store(in: &cancellables)
// Output: 2, 4, 6, 8, 10

// compactMap: transforms AND filters out nil
// Useful for optional conversions
["1", "two", "3", "four", "5"].publisher
    .compactMap { Int($0) }  // Convert to Int, ignore failures
    .sink { print("Valid number: \($0)") }
    .store(in: &cancellables)
// Output: 1, 3, 5

// flatMap: flattens nested Publishers
// Essential for chaining async operations
struct User { let id: Int; let name: String }

func fetchUser(id: Int) -> AnyPublisher<User, Never> {
    // Simulate an API call
    Just(User(id: id, name: "User \(id)"))
        .delay(for: .milliseconds(100), scheduler: RunLoop.main)
        .eraseToAnyPublisher()
}

[1, 2, 3].publisher
    .flatMap { id in fetchUser(id: id) }  // Each ID becomes an API call
    .sink { user in print("User: \(user.name)") }
    .store(in: &cancellables)

Operator di filtraggio

Gli Operator di filtraggio controllano quali valori attraversano la pipeline. Sono essenziali per evitare elaborazioni inutili e ottimizzare le prestazioni:

FilterOperators.swiftswift
import Combine

var cancellables = Set<AnyCancellable>()

let numbers = [1, 2, 2, 3, 3, 3, 4, 5, 5].publisher

// filter: keeps only values that satisfy the condition
numbers
    .filter { $0 > 2 }  // Keep only numbers > 2
    .sink { print("Filtered: \($0)") }
    .store(in: &cancellables)
// Output: 3, 3, 3, 4, 5, 5

// removeDuplicates: removes consecutive identical values
numbers
    .removeDuplicates()  // Eliminate consecutive duplicates
    .sink { print("Without duplicates: \($0)") }
    .store(in: &cancellables)
// Output: 1, 2, 3, 4, 5

// debounce: waits for a pause before emitting
// Perfect for real-time search
let searchText = PassthroughSubject<String, Never>()

searchText
    .debounce(for: .milliseconds(300), scheduler: RunLoop.main)
    .removeDuplicates()  // Ignore if text hasn't changed
    .sink { query in
        print("Search: \(query)")
        // Launch API call here
    }
    .store(in: &cancellables)

// Simulate rapid typing
searchText.send("S")
searchText.send("Sw")
searchText.send("Swi")
searchText.send("Swift")  // Only "Swift" is emitted after 300ms

Pronto a superare i tuoi colloqui su iOS?

Pratica con i nostri simulatori interattivi, flashcards e test tecnici.

Combinare più Publisher

Le applicazioni reali spesso devono combinare più fonti di dati. Combine offre diversi Operator per orchestrare questi flussi multipli.

CombineLatest e Zip

combineLatest emette ogni volta che un Publisher emette, combinando con gli ultimi valori degli altri. zip attende che tutti i Publisher emettano prima di combinare:

CombiningPublishers.swiftswift
import Combine

var cancellables = Set<AnyCancellable>()

// Simulate a form with validation
let email = CurrentValueSubject<String, Never>("")
let password = CurrentValueSubject<String, Never>("")

// combineLatest: combines the latest values from each Publisher
// Emits on every change from either source
Publishers.CombineLatest(email, password)
    .map { email, password in
        // Validate that email contains @ and password > 6 chars
        let isEmailValid = email.contains("@")
        let isPasswordValid = password.count >= 6
        return isEmailValid && isPasswordValid
    }
    .sink { isFormValid in
        print("Form valid: \(isFormValid)")
    }
    .store(in: &cancellables)

email.send("user@example.com")  // false (password empty)
password.send("123456")          // true (both are valid)

// zip: waits for one value from each Publisher before emitting
// Useful for synchronizing parallel operations
let firstAPI = PassthroughSubject<String, Never>()
let secondAPI = PassthroughSubject<Int, Never>()

Publishers.Zip(firstAPI, secondAPI)
    .sink { stringValue, intValue in
        print("Received pair: \(stringValue), \(intValue)")
    }
    .store(in: &cancellables)

firstAPI.send("Hello")   // No emission, waiting for secondAPI
secondAPI.send(42)       // Emits: ("Hello", 42)
firstAPI.send("World")   // No emission, waiting for secondAPI
secondAPI.send(100)      // Emits: ("World", 100)

Merge per unificare i flussi

merge combina più Publisher dello stesso tipo in un unico flusso. I valori arrivano nell'ordine di emissione, indipendentemente dal Publisher che li ha inviati:

MergePublishers.swiftswift
import Combine

var cancellables = Set<AnyCancellable>()

// Multiple user notification sources
let pushNotifications = PassthroughSubject<String, Never>()
let localNotifications = PassthroughSubject<String, Never>()
let inAppMessages = PassthroughSubject<String, Never>()

// Merge unifies all streams into one
Publishers.Merge3(pushNotifications, localNotifications, inAppMessages)
    .sink { message in
        // Handle all notifications the same way
        print("📬 Notification: \(message)")
    }
    .store(in: &cancellables)

pushNotifications.send("New message")      // 📬 Notification: New message
localNotifications.send("Reminder: meeting")  // 📬 Notification: Reminder: meeting
inAppMessages.send("Welcome!")              // 📬 Notification: Welcome!

Gestione degli errori in Combine

La gestione degli errori è integrata nel cuore di Combine. Il tipo Failure dei Publisher consente al compilatore di verificare che tutti gli errori siano gestiti.

Strategie di recupero

Combine offre diversi Operator per gestire gli errori: catch per sostituire con un altro Publisher, retry per riprovare e replaceError per un valore di default:

ErrorHandling.swiftswift
import Combine

var cancellables = Set<AnyCancellable>()

enum APIError: Error {
    case networkError
    case invalidResponse
    case serverError(Int)
}

// Simulate an API call that can fail
func fetchData() -> AnyPublisher<String, APIError> {
    Fail(error: APIError.networkError)
        .eraseToAnyPublisher()
}

// retry: retries N times before propagating the error
fetchData()
    .retry(3)  // Try up to 3 times
    .catch { error -> Just<String> in
        // catch: replaces the error with a fallback Publisher
        print("Error after 3 attempts: \(error)")
        return Just("Cached data")  // Fallback value
    }
    .sink(
        receiveCompletion: { _ in },
        receiveValue: { print("Result: \($0)") }
    )
    .store(in: &cancellables)

// replaceError: replaces any error with a fixed value
// Simpler than catch when only a default value is needed
fetchData()
    .replaceError(with: "Error - default value")
    .sink { print("With fallback: \($0)") }
    .store(in: &cancellables)
Never contro Error

Usa setFailureType(to:) per trasformare un Publisher Never in uno che può fallire, e replaceError(with:) o catch per fare il percorso inverso.

Pattern MVVM con Combine

Combine si integra naturalmente con il pattern MVVM (Model-View-ViewModel). Il ViewModel espone Publisher osservati dalla View, creando un binding reattivo tra dati e interfaccia.

ViewModel reattivo completo

Ecco un esempio di ViewModel per una lista utenti con ricerca, caricamento e gestione errori:

UserListViewModel.swiftswift
import Combine
import Foundation

// Data model
struct User: Codable, Identifiable {
    let id: Int
    let name: String
    let email: String
}

// ViewModel with reactive state
final class UserListViewModel: ObservableObject {
    // MARK: - Published Properties (observed by SwiftUI)

    @Published var users: [User] = []           // User list
    @Published var searchQuery: String = ""     // Search text
    @Published var isLoading: Bool = false      // Loading state
    @Published var errorMessage: String?        // Optional error message

    // MARK: - Private Properties

    private var cancellables = Set<AnyCancellable>()
    private let userService: UserServiceProtocol

    // MARK: - Computed Properties

    // Filters users based on search query
    var filteredUsers: [User] {
        guard !searchQuery.isEmpty else { return users }
        return users.filter {
            $0.name.localizedCaseInsensitiveContains(searchQuery)
        }
    }

    // MARK: - Initialization

    init(userService: UserServiceProtocol = UserService()) {
        self.userService = userService
        setupBindings()
    }

    // MARK: - Private Methods

    private func setupBindings() {
        // Observe searchQuery changes
        // debounce prevents too frequent calls
        $searchQuery
            .debounce(for: .milliseconds(300), scheduler: RunLoop.main)
            .removeDuplicates()
            .sink { [weak self] query in
                // Server-side search logic if needed
                print("Search updated: \(query)")
            }
            .store(in: &cancellables)
    }

    // MARK: - Public Methods

    func loadUsers() {
        isLoading = true
        errorMessage = nil

        userService.fetchUsers()
            .receive(on: DispatchQueue.main)  // Ensure UI updates on main thread
            .sink(
                receiveCompletion: { [weak self] completion in
                    self?.isLoading = false
                    if case .failure(let error) = completion {
                        self?.errorMessage = error.localizedDescription
                    }
                },
                receiveValue: { [weak self] users in
                    self?.users = users
                }
            )
            .store(in: &cancellables)
    }
}

Service con Combine e URLSession

URLSession integra nativamente Combine tramite dataTaskPublisher. Ecco come creare un servizio di rete riutilizzabile:

UserService.swiftswift
import Combine
import Foundation

protocol UserServiceProtocol {
    func fetchUsers() -> AnyPublisher<[User], Error>
}

final class UserService: UserServiceProtocol {
    private let baseURL = URL(string: "https://api.example.com")!
    private let session: URLSession
    private let decoder: JSONDecoder

    init(session: URLSession = .shared) {
        self.session = session
        self.decoder = JSONDecoder()
        decoder.keyDecodingStrategy = .convertFromSnakeCase
    }

    func fetchUsers() -> AnyPublisher<[User], Error> {
        let url = baseURL.appendingPathComponent("users")

        return session.dataTaskPublisher(for: url)
            // Check HTTP status code
            .tryMap { data, response in
                guard let httpResponse = response as? HTTPURLResponse else {
                    throw URLError(.badServerResponse)
                }
                guard 200..<300 ~= httpResponse.statusCode else {
                    throw URLError(.badServerResponse)
                }
                return data
            }
            // Decode JSON to Swift model
            .decode(type: [User].self, decoder: decoder)
            // Erase concrete type to return AnyPublisher
            .eraseToAnyPublisher()
    }
}

Pronto a superare i tuoi colloqui su iOS?

Pratica con i nostri simulatori interattivi, flashcards e test tecnici.

Integrazione con SwiftUI

Combine e SwiftUI formano una coppia potente. Le proprietà @Published di un ObservableObject attivano automaticamente gli aggiornamenti della view.

View SwiftUI con ViewModel Combine

Ecco come collegare il ViewModel a una view SwiftUI:

UserListView.swiftswift
import SwiftUI

struct UserListView: View {
    // StateObject: creates and owns the ViewModel
    @StateObject private var viewModel = UserListViewModel()

    var body: some View {
        NavigationStack {
            Group {
                if viewModel.isLoading {
                    // Centered loading indicator
                    ProgressView("Loading...")
                } else if let error = viewModel.errorMessage {
                    // Error view with retry button
                    VStack(spacing: 16) {
                        Text("Error: \(error)")
                            .foregroundStyle(.red)
                        Button("Retry") {
                            viewModel.loadUsers()
                        }
                    }
                } else {
                    // User list
                    List(viewModel.filteredUsers) { user in
                        UserRowView(user: user)
                    }
                }
            }
            .navigationTitle("Users")
            .searchable(text: $viewModel.searchQuery)  // Direct binding
            .onAppear {
                viewModel.loadUsers()  // Load on first appearance
            }
        }
    }
}

struct UserRowView: View {
    let user: User

    var body: some View {
        VStack(alignment: .leading, spacing: 4) {
            Text(user.name)
                .font(.headline)
            Text(user.email)
                .font(.subheadline)
                .foregroundStyle(.secondary)
        }
        .padding(.vertical, 4)
    }
}

Pattern avanzati

Cancellazione e pulizia automatica

Gestire il ciclo di vita delle sottoscrizioni è cruciale per evitare memory leak. Il pattern cancellables con AnyCancellable garantisce una pulizia automatica:

CancellationPatterns.swiftswift
import Combine

final class DataManager {
    // Set of cancellables: automatically cancelled on destruction
    private var cancellables = Set<AnyCancellable>()

    // Individual cancellable for fine-grained control
    private var currentRequest: AnyCancellable?

    func startPolling() {
        // Timer that emits every 5 seconds
        Timer.publish(every: 5, on: .main, in: .common)
            .autoconnect()  // Starts automatically
            .sink { [weak self] _ in
                self?.fetchLatestData()
            }
            .store(in: &cancellables)
    }

    func fetchLatestData() {
        // Cancel the previous request if it exists
        currentRequest?.cancel()

        currentRequest = URLSession.shared
            .dataTaskPublisher(for: URL(string: "https://api.example.com/data")!)
            .map(\.data)
            .decode(type: [String].self, decoder: JSONDecoder())
            .replaceError(with: [])
            .receive(on: DispatchQueue.main)
            .sink { data in
                print("Data received: \(data)")
            }
    }

    deinit {
        // All cancellables are automatically cancelled
        print("DataManager destroyed, subscriptions cancelled")
    }
}

Scheduler per il threading

Gli Scheduler controllano su quale thread vengono eseguite le operazioni. Usa subscribe(on:) per il lavoro in background e receive(on:) per gli aggiornamenti dell'UI:

SchedulerPatterns.swiftswift
import Combine
import Foundation

var cancellables = Set<AnyCancellable>()

func loadAndProcessData() -> AnyPublisher<ProcessedData, Error> {
    URLSession.shared.dataTaskPublisher(for: apiURL)
        // Perform parsing on a background thread
        .subscribe(on: DispatchQueue.global(qos: .userInitiated))
        .map(\.data)
        .decode(type: RawData.self, decoder: JSONDecoder())
        // Heavy processing on background thread
        .map { rawData in
            // This expensive operation runs in the background
            processData(rawData)
        }
        // Return to main thread for UI
        .receive(on: DispatchQueue.main)
        .eraseToAnyPublisher()
}

Conclusione

Combine offre un approccio potente e dichiarativo per gestire i flussi di dati asincroni nelle app iOS. Punti chiave:

✅ I Publisher emettono valori nel tempo ✅ I Subscriber ricevono ed elaborano questi valori ✅ Gli Operator trasformano e combinano i flussi ✅ AnyCancellable gestisce il ciclo di vita delle sottoscrizioni ✅ @Published con SwiftUI crea binding reattivi automatici ✅ Gli Scheduler controllano il threading per prestazioni ottimali

Padroneggiare Combine consente di costruire app iOS robuste, manutenibili e reattive. La sua integrazione nativa con SwiftUI lo rende uno strumento essenziale per lo sviluppo iOS moderno.

Inizia a praticare!

Metti alla prova le tue conoscenze con i nostri simulatori di colloquio e test tecnici.

Tag

#combine
#swift
#ios
#reactive
#async

Condividi

Articoli correlati