Combine Framework : Programmation réactive en Swift

Maîtrisez Combine pour gérer les flux de données asynchrones en Swift : Publishers, Subscribers, Operators et patterns avancés pour applications iOS.

Guide Combine Framework pour la programmation réactive en Swift iOS

La programmation réactive transforme la façon de gérer les événements et données asynchrones dans les applications iOS. Combine, le framework natif d'Apple, offre une approche déclarative et type-safe pour orchestrer ces flux complexes. Ce guide explore les concepts fondamentaux jusqu'aux patterns avancés utilisés en production.

Pourquoi Combine plutôt que RxSwift ?

Combine est intégré nativement à iOS 13+, offre une meilleure performance grâce à son optimisation par Apple, et s'intègre parfaitement avec SwiftUI. Pas de dépendance externe à gérer.

Les fondements de Combine

Combine repose sur trois concepts clés : les Publishers qui émettent des valeurs, les Subscribers qui les reçoivent, et les Operators qui transforment les données entre les deux. Cette architecture permet de créer des pipelines de données réactifs et composables.

Publisher : la source de données

Un Publisher est un type qui peut émettre une séquence de valeurs au fil du temps. Chaque Publisher déclare deux types associés : le type de valeur émise (Output) et le type d'erreur possible (Failure). Voici comment créer différents types de Publishers :

PublisherBasics.swiftswift
import Combine

// Just : émet une seule valeur puis termine
// Utile pour convertir une valeur simple en Publisher
let singleValue = Just("Bonjour Combine")

// CurrentValueSubject : stocke et émet la valeur courante
// Parfait pour représenter un état qui change
let counter = CurrentValueSubject<Int, Never>(0)

// PassthroughSubject : émet des valeurs sans les stocker
// Idéal pour les événements ponctuels (clics, notifications)
let buttonTaps = PassthroughSubject<Void, Never>()

// Future : émet une seule valeur de façon asynchrone
// Encapsule une opération async qui retourne un résultat
let asyncOperation = Future<String, Error> { promise in
    // Simule un appel réseau
    DispatchQueue.global().asyncAfter(deadline: .now() + 1) {
        promise(.success("Données chargées"))
    }
}

Le type Never pour l'erreur signifie que le Publisher ne peut jamais échouer. C'est une garantie au niveau du compilateur qui simplifie le code de gestion d'erreurs.

Subscriber : recevoir les valeurs

Un Subscriber s'abonne à un Publisher pour recevoir ses valeurs. La méthode sink est la façon la plus courante de créer un Subscriber. Elle prend deux closures : une pour les erreurs/complétion et une pour chaque valeur reçue :

SubscriberBasics.swiftswift
import Combine

// Variable pour stocker les abonnements
// Sans cette référence, l'abonnement serait immédiatement annulé
var cancellables = Set<AnyCancellable>()

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

// sink() crée un Subscriber qui reçoit les valeurs
publisher
    .sink(
        // Appelée quand le Publisher termine ou échoue
        receiveCompletion: { completion in
            switch completion {
            case .finished:
                print("✅ Terminé avec succès")
            case .failure(let error):
                print("❌ Erreur: \(error)")
            }
        },
        // Appelée pour chaque valeur émise
        receiveValue: { value in
            print("Reçu: \(value)")
        }
    )
    // store() garde une référence à l'abonnement
    .store(in: &cancellables)

// Output:
// Reçu: Swift
// Reçu: Combine
// Reçu: iOS
// ✅ Terminé avec succès
Attention aux fuites mémoire

Toujours stocker les AnyCancellable retournés par sink(). Sans référence, l'abonnement est automatiquement annulé et aucune valeur n'est reçue.

Transformer les données avec les Operators

Les Operators sont le cœur de Combine. Ils permettent de transformer, filtrer et combiner les flux de données de manière déclarative. Chaque Operator retourne un nouveau Publisher, permettant de les chaîner.

Operators de transformation essentiels

Les Operators de transformation modifient chaque valeur émise. map transforme les valeurs, flatMap aplatit les Publishers imbriqués, et compactMap filtre les valeurs nil :

TransformOperators.swiftswift
import Combine

var cancellables = Set<AnyCancellable>()

// map : transforme chaque valeur
// Équivalent du map sur les tableaux
[1, 2, 3, 4, 5].publisher
    .map { $0 * 2 }  // Multiplie chaque nombre par 2
    .sink { print("Double: \($0)") }
    .store(in: &cancellables)
// Output: 2, 4, 6, 8, 10

// compactMap : transforme ET filtre les nil
// Utile pour les conversions optionnelles
["1", "deux", "3", "quatre", "5"].publisher
    .compactMap { Int($0) }  // Convertit en Int, ignore les échecs
    .sink { print("Nombre valide: \($0)") }
    .store(in: &cancellables)
// Output: 1, 3, 5

// flatMap : aplatit les Publishers imbriqués
// Essentiel pour les opérations async en chaîne
struct User { let id: Int; let name: String }

func fetchUser(id: Int) -> AnyPublisher<User, Never> {
    // Simule un appel API
    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) }  // Chaque ID devient un appel API
    .sink { user in print("User: \(user.name)") }
    .store(in: &cancellables)

Operators de filtrage

Les Operators de filtrage contrôlent quelles valeurs passent dans le pipeline. Ils sont essentiels pour éviter les traitements inutiles et optimiser les performances :

FilterOperators.swiftswift
import Combine

var cancellables = Set<AnyCancellable>()

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

// filter : garde uniquement les valeurs qui satisfont la condition
numbers
    .filter { $0 > 2 }  // Garde seulement les nombres > 2
    .sink { print("Filtré: \($0)") }
    .store(in: &cancellables)
// Output: 3, 3, 3, 4, 5, 5

// removeDuplicates : supprime les valeurs consécutives identiques
numbers
    .removeDuplicates()  // Élimine les doublons consécutifs
    .sink { print("Sans doublon: \($0)") }
    .store(in: &cancellables)
// Output: 1, 2, 3, 4, 5

// debounce : attend une pause avant d'émettre
// Parfait pour la recherche en temps réel
let searchText = PassthroughSubject<String, Never>()

searchText
    .debounce(for: .milliseconds(300), scheduler: RunLoop.main)
    .removeDuplicates()  // Ignore si le texte n'a pas changé
    .sink { query in
        print("Recherche: \(query)")
        // Lancer l'appel API ici
    }
    .store(in: &cancellables)

// Simule une frappe rapide
searchText.send("S")
searchText.send("Sw")
searchText.send("Swi")
searchText.send("Swift")  // Seul "Swift" est émis après 300ms

Prêt à réussir tes entretiens iOS ?

Entraîne-toi avec nos simulateurs interactifs, fiches express et tests techniques.

Combiner plusieurs Publishers

Les applications réelles nécessitent souvent de combiner plusieurs sources de données. Combine propose plusieurs Operators pour orchestrer ces flux multiples.

CombineLatest et Zip

combineLatest émet dès qu'un des Publishers émet, en combinant avec la dernière valeur des autres. zip attend que tous les Publishers aient émis avant de combiner :

CombiningPublishers.swiftswift
import Combine

var cancellables = Set<AnyCancellable>()

// Simulons un formulaire avec validation
let email = CurrentValueSubject<String, Never>("")
let password = CurrentValueSubject<String, Never>("")

// combineLatest : combine les dernières valeurs de chaque Publisher
// Émet à chaque changement de l'un ou l'autre
Publishers.CombineLatest(email, password)
    .map { email, password in
        // Valide que l'email contient @ et le mot de passe > 6 chars
        let isEmailValid = email.contains("@")
        let isPasswordValid = password.count >= 6
        return isEmailValid && isPasswordValid
    }
    .sink { isFormValid in
        print("Formulaire valide: \(isFormValid)")
    }
    .store(in: &cancellables)

email.send("user@example.com")  // false (password vide)
password.send("123456")          // true (les deux sont valides)

// zip : attend une valeur de chaque Publisher avant d'émettre
// Utile pour synchroniser des opérations parallèles
let firstAPI = PassthroughSubject<String, Never>()
let secondAPI = PassthroughSubject<Int, Never>()

Publishers.Zip(firstAPI, secondAPI)
    .sink { stringValue, intValue in
        print("Reçu paire: \(stringValue), \(intValue)")
    }
    .store(in: &cancellables)

firstAPI.send("Hello")   // Pas d'émission, attend secondAPI
secondAPI.send(42)       // Émet: ("Hello", 42)
firstAPI.send("World")   // Pas d'émission, attend secondAPI
secondAPI.send(100)      // Émet: ("World", 100)

Merge pour unifier les flux

merge combine plusieurs Publishers du même type en un seul flux. Les valeurs arrivent dans l'ordre d'émission, quel que soit le Publisher source :

MergePublishers.swiftswift
import Combine

var cancellables = Set<AnyCancellable>()

// Plusieurs sources de notifications utilisateur
let pushNotifications = PassthroughSubject<String, Never>()
let localNotifications = PassthroughSubject<String, Never>()
let inAppMessages = PassthroughSubject<String, Never>()

// Merge unifie tous les flux en un seul
Publishers.Merge3(pushNotifications, localNotifications, inAppMessages)
    .sink { message in
        // Traite toutes les notifications de la même façon
        print("📬 Notification: \(message)")
    }
    .store(in: &cancellables)

pushNotifications.send("Nouveau message")     // 📬 Notification: Nouveau message
localNotifications.send("Rappel: réunion")    // 📬 Notification: Rappel: réunion
inAppMessages.send("Bienvenue !")             // 📬 Notification: Bienvenue !

Gestion des erreurs dans Combine

La gestion des erreurs est intégrée au cœur de Combine. Le type Failure des Publishers permet au compilateur de vérifier que toutes les erreurs sont gérées.

Stratégies de récupération

Combine offre plusieurs Operators pour gérer les erreurs : catch pour remplacer par un autre Publisher, retry pour réessayer, et replaceError pour une valeur par défaut :

ErrorHandling.swiftswift
import Combine

var cancellables = Set<AnyCancellable>()

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

// Simule un appel API qui peut échouer
func fetchData() -> AnyPublisher<String, APIError> {
    Fail(error: APIError.networkError)
        .eraseToAnyPublisher()
}

// retry : réessaie N fois avant de propager l'erreur
fetchData()
    .retry(3)  // Essaie jusqu'à 3 fois
    .catch { error -> Just<String> in
        // catch : remplace l'erreur par un Publisher de secours
        print("Erreur après 3 tentatives: \(error)")
        return Just("Données en cache")  // Valeur de fallback
    }
    .sink(
        receiveCompletion: { _ in },
        receiveValue: { print("Résultat: \($0)") }
    )
    .store(in: &cancellables)

// replaceError : remplace toute erreur par une valeur fixe
// Plus simple que catch quand on veut juste une valeur par défaut
fetchData()
    .replaceError(with: "Erreur - valeur par défaut")
    .sink { print("Avec fallback: \($0)") }
    .store(in: &cancellables)
Never vs Error

Utilisez setFailureType(to:) pour changer un Publisher Never en Publisher qui peut échouer, et replaceError(with:) ou catch pour faire l'inverse.

Pattern MVVM avec Combine

Combine s'intègre naturellement avec le pattern MVVM (Model-View-ViewModel). Le ViewModel expose des Publishers que la View observe, créant une liaison réactive entre les données et l'interface.

ViewModel réactif complet

Voici un exemple de ViewModel pour une liste d'utilisateurs avec recherche, chargement et gestion d'erreurs :

UserListViewModel.swiftswift
import Combine
import Foundation

// Modèle de données
struct User: Codable, Identifiable {
    let id: Int
    let name: String
    let email: String
}

// ViewModel avec état réactif
final class UserListViewModel: ObservableObject {
    // MARK: - Published Properties (observés par SwiftUI)

    @Published var users: [User] = []           // Liste des utilisateurs
    @Published var searchQuery: String = ""     // Texte de recherche
    @Published var isLoading: Bool = false      // État de chargement
    @Published var errorMessage: String?        // Message d'erreur éventuel

    // MARK: - Private Properties

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

    // MARK: - Computed Properties

    // Filtre les utilisateurs selon la recherche
    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 les changements de searchQuery
        // debounce évite les appels trop fréquents
        $searchQuery
            .debounce(for: .milliseconds(300), scheduler: RunLoop.main)
            .removeDuplicates()
            .sink { [weak self] query in
                // Logique de recherche côté serveur si nécessaire
                print("Recherche mise à jour: \(query)")
            }
            .store(in: &cancellables)
    }

    // MARK: - Public Methods

    func loadUsers() {
        isLoading = true
        errorMessage = nil

        userService.fetchUsers()
            .receive(on: DispatchQueue.main)  // Assure l'update UI sur 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 avec Combine et URLSession

URLSession intègre nativement Combine via dataTaskPublisher. Voici comment créer un service réseau réutilisable :

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)
            // Vérifie le code de statut HTTP
            .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
            }
            // Décode le JSON en modèle Swift
            .decode(type: [User].self, decoder: decoder)
            // Efface le type concret pour retourner AnyPublisher
            .eraseToAnyPublisher()
    }
}

Prêt à réussir tes entretiens iOS ?

Entraîne-toi avec nos simulateurs interactifs, fiches express et tests techniques.

Intégration avec SwiftUI

Combine et SwiftUI forment un duo puissant. Les propriétés @Published d'un ObservableObject déclenchent automatiquement les mises à jour de la vue.

Vue SwiftUI avec ViewModel Combine

Voici comment connecter le ViewModel à une vue SwiftUI :

UserListView.swiftswift
import SwiftUI

struct UserListView: View {
    // StateObject : crée et possède le ViewModel
    @StateObject private var viewModel = UserListViewModel()

    var body: some View {
        NavigationStack {
            Group {
                if viewModel.isLoading {
                    // Indicateur de chargement centré
                    ProgressView("Chargement...")
                } else if let error = viewModel.errorMessage {
                    // Vue d'erreur avec bouton retry
                    VStack(spacing: 16) {
                        Text("Erreur: \(error)")
                            .foregroundStyle(.red)
                        Button("Réessayer") {
                            viewModel.loadUsers()
                        }
                    }
                } else {
                    // Liste des utilisateurs
                    List(viewModel.filteredUsers) { user in
                        UserRowView(user: user)
                    }
                }
            }
            .navigationTitle("Utilisateurs")
            .searchable(text: $viewModel.searchQuery)  // Binding direct
            .onAppear {
                viewModel.loadUsers()  // Charge au premier affichage
            }
        }
    }
}

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)
    }
}

Patterns avancés

Annulation et nettoyage automatique

La gestion du cycle de vie des abonnements est cruciale pour éviter les fuites mémoire. Le pattern cancellables avec AnyCancellable assure un nettoyage automatique :

CancellationPatterns.swiftswift
import Combine

final class DataManager {
    // Set de cancellables : automatiquement annulés à la destruction
    private var cancellables = Set<AnyCancellable>()

    // Cancellable individuel pour contrôle fin
    private var currentRequest: AnyCancellable?

    func startPolling() {
        // Timer qui émet toutes les 5 secondes
        Timer.publish(every: 5, on: .main, in: .common)
            .autoconnect()  // Démarre automatiquement
            .sink { [weak self] _ in
                self?.fetchLatestData()
            }
            .store(in: &cancellables)
    }

    func fetchLatestData() {
        // Annule la requête précédente si elle existe
        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("Données reçues: \(data)")
            }
    }

    deinit {
        // Tous les cancellables sont automatiquement annulés
        print("DataManager détruit, abonnements annulés")
    }
}

Scheduler pour le threading

Les Schedulers contrôlent sur quel thread les opérations s'exécutent. Utilisez subscribe(on:) pour le travail en arrière-plan et receive(on:) pour les mises à jour UI :

SchedulerPatterns.swiftswift
import Combine
import Foundation

var cancellables = Set<AnyCancellable>()

func loadAndProcessData() -> AnyPublisher<ProcessedData, Error> {
    URLSession.shared.dataTaskPublisher(for: apiURL)
        // Effectue le parsing sur un thread background
        .subscribe(on: DispatchQueue.global(qos: .userInitiated))
        .map(\.data)
        .decode(type: RawData.self, decoder: JSONDecoder())
        // Traitement lourd sur background thread
        .map { rawData in
            // Cette opération coûteuse s'exécute en arrière-plan
            processData(rawData)
        }
        // Retourne sur le main thread pour l'UI
        .receive(on: DispatchQueue.main)
        .eraseToAnyPublisher()
}

Conclusion

Combine offre une approche puissante et déclarative pour gérer les flux de données asynchrones dans les applications iOS. Les concepts clés à retenir :

Publishers émettent des valeurs au fil du temps ✅ Subscribers reçoivent et traitent ces valeurs ✅ Operators transforment et combinent les flux ✅ AnyCancellable gère le cycle de vie des abonnements ✅ @Published avec SwiftUI crée des liaisons réactives automatiques ✅ Schedulers contrôlent le threading pour des performances optimales

La maîtrise de Combine permet de créer des applications iOS robustes, maintenables et réactives. Son intégration native avec SwiftUI en fait un outil incontournable pour le développement iOS moderne.

Passe à la pratique !

Teste tes connaissances avec nos simulateurs d'entretien et tests techniques.

Tags

#combine
#swift
#ios
#reactive
#async

Partager

Articles similaires