Combine vs async/await en Swift : patterns de migration progressive

Guide complet sur la migration de Combine vers async/await en Swift : stratégies progressives, bridging patterns, et coexistence des deux paradigmes dans une codebase iOS.

Migration de Combine vers async/await en Swift avec patterns de coexistence

L'introduction de Swift Concurrency avec async/await a bouleversé les pratiques de programmation asynchrone sur iOS. Pour les projets utilisant Combine, la question de la migration se pose naturellement. Faut-il tout réécrire ? Peut-on faire coexister les deux approches ? Quels patterns adopter pour une transition fluide ? Ce guide explore les stratégies de migration progressive, permettant d'adopter async/await sans abandonner brutalement Combine.

Objectif de ce guide

Ce guide présente des patterns concrets pour migrer progressivement de Combine vers async/await, avec des exemples de bridging bidirectionnel et des stratégies de coexistence adaptées aux codebases existantes.

Comprendre les différences fondamentales

Avant d'entamer une migration, il est essentiel de comprendre ce qui distingue Combine d'async/await. Ces deux approches répondent à des besoins différents, et certains cas d'usage restent plus adaptés à Combine.

Modèle mental de Combine

Combine repose sur un modèle de flux de données (streams). Un Publisher émet des valeurs au fil du temps, un ou plusieurs opérateurs transforment ces valeurs, et un Subscriber reçoit le résultat final. Ce modèle excelle pour les flux continus comme les événements UI, les notifications, ou les WebSockets.

CombineExample.swiftswift
// Flux d'événements avec Combine - modèle stream-based
import Combine

class SearchViewModel {
    @Published var searchText = ""
    private var cancellables = Set<AnyCancellable>()

    // Combine excelle pour les flux continus avec transformations
    func setupSearch() {
        $searchText
            // Attend 300ms de pause dans la frappe
            .debounce(for: .milliseconds(300), scheduler: RunLoop.main)
            // Ignore les doublons consécutifs
            .removeDuplicates()
            // Filtre les recherches trop courtes
            .filter { $0.count >= 3 }
            // Transforme le texte en requête réseau
            .flatMap { query in
                self.searchAPI(query: query)
                    // Gestion d'erreur locale
                    .catch { _ in Just([]) }
            }
            // Souscription finale
            .sink { results in
                self.updateUI(with: results)
            }
            .store(in: &cancellables)
    }

    private func searchAPI(query: String) -> AnyPublisher<[SearchResult], Error> {
        // Implémentation réseau
    }
}

Ce code illustre la force de Combine : chaîner des opérateurs déclaratifs pour traiter un flux d'événements continus.

Modèle mental d'async/await

Async/await adopte un modèle séquentiel : une opération démarre, le code attend son résultat, puis continue. Ce modèle est plus intuitif pour les opérations ponctuelles comme les requêtes réseau isolées ou les lectures de fichiers.

AsyncAwaitExample.swiftswift
// Opérations ponctuelles avec async/await - modèle séquentiel
import Foundation

actor SearchService {
    // async/await excelle pour les opérations séquentielles
    func performSearch(query: String) async throws -> [SearchResult] {
        // Validation préalable - lecture séquentielle claire
        guard query.count >= 3 else {
            return []
        }

        // Requête réseau avec await
        let url = URL(string: "https://api.example.com/search?q=\(query)")!
        let (data, response) = try await URLSession.shared.data(from: url)

        // Vérification de la réponse
        guard let httpResponse = response as? HTTPURLResponse,
              httpResponse.statusCode == 200 else {
            throw SearchError.invalidResponse
        }

        // Décodage du résultat
        let results = try JSONDecoder().decode([SearchResult].self, from: data)
        return results
    }
}

La lecture est linéaire, les erreurs se propagent naturellement avec try, et le flux d'exécution est immédiatement compréhensible.

Quand choisir chaque approche

Combine reste pertinent pour les flux continus (événements UI, timers, WebSockets). Async/await convient mieux aux opérations ponctuelles (requêtes API, lecture fichiers, calculs isolés).

Bridging de Combine vers async/await

La première étape d'une migration consiste souvent à consommer des Publishers existants dans du code async/await. Swift fournit des outils natifs pour ce bridging.

Utiliser AsyncSequence avec Publisher.values

Depuis Swift 5.5, chaque Publisher expose une propriété .values qui retourne un AsyncPublisher. Cette séquence asynchrone permet d'itérer sur les valeurs émises avec une boucle for await.

BridgingCombineToAsync.swiftswift
// Conversion Publisher → AsyncSequence via .values
import Combine

class NotificationObserver {
    private let notificationPublisher: AnyPublisher<Notification, Never>

    init() {
        // Publisher Combine existant
        notificationPublisher = NotificationCenter.default
            .publisher(for: UIApplication.didBecomeActiveNotification)
            .eraseToAnyPublisher()
    }

    // Consommation du Publisher avec async/await
    func observeNotifications() async {
        // .values convertit le Publisher en AsyncSequence
        for await notification in notificationPublisher.values {
            // Traitement de chaque notification
            await handleAppBecameActive(notification)
        }
        // Cette ligne n'est jamais atteinte pour un Publisher infini
    }

    private func handleAppBecameActive(_ notification: Notification) async {
        // Logique async de traitement
    }
}

Cette approche préserve le Publisher d'origine tout en permettant sa consommation dans un contexte async.

Obtenir une seule valeur avec firstValue

Pour les Publishers qui émettent une seule valeur (comme une requête réseau), la propriété .values.first(where:) ou une extension personnalisée simplifie le bridging.

SingleValueBridging.swiftswift
// Extension pour extraire une valeur unique d'un Publisher
import Combine

extension Publisher where Failure == Never {
    // Attend et retourne la première valeur émise
    var firstValue: Output {
        get async {
            await withCheckedContinuation { continuation in
                var cancellable: AnyCancellable?
                cancellable = self.first()
                    .sink { value in
                        continuation.resume(returning: value)
                        cancellable?.cancel()
                    }
            }
        }
    }
}

extension Publisher {
    // Version throwing pour les Publishers avec erreurs
    var firstValueThrowing: Output {
        get async throws {
            try await withCheckedThrowingContinuation { continuation in
                var cancellable: AnyCancellable?
                cancellable = self.first()
                    .sink(
                        receiveCompletion: { completion in
                            if case .failure(let error) = completion {
                                continuation.resume(throwing: error)
                            }
                            cancellable?.cancel()
                        },
                        receiveValue: { value in
                            continuation.resume(returning: value)
                        }
                    )
            }
        }
    }
}

// Utilisation dans du code async
class UserRepository {
    private let apiClient: APIClient

    func fetchCurrentUser() async throws -> User {
        // Consomme un Publisher existant de manière async
        try await apiClient.userPublisher().firstValueThrowing
    }
}

Cette extension encapsule la complexité du bridging et offre une API propre.

Prêt à réussir tes entretiens iOS ?

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

Bridging d'async/await vers Combine

La migration inverse est également nécessaire : consommer du code async dans des pipelines Combine existants.

Créer un Publisher depuis une fonction async

L'approche la plus directe utilise Future combiné à une Task pour encapsuler l'appel async.

BridgingAsyncToCombine.swiftswift
// Conversion async → Publisher via Future
import Combine

extension Publisher {
    // Opérateur flatMap async pour pipelines Combine
    func asyncMap<T>(
        _ transform: @escaping (Output) async throws -> T
    ) -> AnyPublisher<T, Error> {
        flatMap { value in
            Future { promise in
                Task {
                    do {
                        // Exécute la transformation async
                        let result = try await transform(value)
                        promise(.success(result))
                    } catch {
                        promise(.failure(error))
                    }
                }
            }
        }
        .eraseToAnyPublisher()
    }
}

// Utilisation dans un pipeline Combine
class ImageProcessor {
    @Published var selectedImageURL: URL?
    private var cancellables = Set<AnyCancellable>()

    func setupProcessingPipeline() {
        $selectedImageURL
            .compactMap { $0 }
            // Utilise une fonction async dans le pipeline Combine
            .asyncMap { url in
                // downloadImage est une fonction async
                try await self.downloadImage(from: url)
            }
            .asyncMap { imageData in
                // processImage est également async
                try await self.processImage(imageData)
            }
            .receive(on: DispatchQueue.main)
            .sink(
                receiveCompletion: { completion in
                    if case .failure(let error) = completion {
                        print("Erreur: \(error)")
                    }
                },
                receiveValue: { processedImage in
                    self.displayImage(processedImage)
                }
            )
            .store(in: &cancellables)
    }

    private func downloadImage(from url: URL) async throws -> Data {
        let (data, _) = try await URLSession.shared.data(from: url)
        return data
    }

    private func processImage(_ data: Data) async throws -> UIImage {
        // Traitement d'image async
    }
}

Publisher personnalisé pour flux async

Pour des besoins plus avancés, un Publisher personnalisé peut encapsuler un flux AsyncSequence complet.

AsyncSequencePublisher.swiftswift
// Publisher wrapper pour AsyncSequence
import Combine

struct AsyncSequencePublisher<S: AsyncSequence>: Publisher {
    typealias Output = S.Element
    typealias Failure = Error

    private let sequence: S

    init(_ sequence: S) {
        self.sequence = sequence
    }

    func receive<Sub>(subscriber: Sub) where Sub: Subscriber,
                                              Failure == Sub.Failure,
                                              Output == Sub.Input {
        let subscription = AsyncSubscription(
            sequence: sequence,
            subscriber: subscriber
        )
        subscriber.receive(subscription: subscription)
    }
}

private final class AsyncSubscription<S: AsyncSequence, Sub: Subscriber>: Subscription
where Sub.Input == S.Element, Sub.Failure == Error {

    private var task: Task<Void, Never>?
    private var subscriber: Sub?
    private let sequence: S

    init(sequence: S, subscriber: Sub) {
        self.sequence = sequence
        self.subscriber = subscriber
    }

    func request(_ demand: Subscribers.Demand) {
        // Démarre l'itération asynchrone
        task = Task {
            do {
                for try await element in sequence {
                    // Vérifie que la souscription est toujours active
                    guard subscriber != nil else { break }
                    _ = subscriber?.receive(element)
                }
                subscriber?.receive(completion: .finished)
            } catch {
                subscriber?.receive(completion: .failure(error))
            }
        }
    }

    func cancel() {
        task?.cancel()
        subscriber = nil
    }
}

// Extension pratique pour toute AsyncSequence
extension AsyncSequence {
    var publisher: AsyncSequencePublisher<Self> {
        AsyncSequencePublisher(self)
    }
}

Stratégies de coexistence dans une codebase

La migration complète d'une grande codebase prend du temps. Voici des patterns pour faire coexister Combine et async/await harmonieusement.

Architecture en couches avec abstraction

Définir des protocoles qui abstraient l'implémentation permet de migrer progressivement sans modifier le code appelant.

RepositoryAbstraction.swiftswift
// Abstraction permettant deux implémentations
import Combine

// Protocole définissant le contrat
protocol UserRepositoryProtocol {
    // Interface async moderne
    func fetchUser(id: String) async throws -> User

    // Interface Combine legacy (optionnelle avec implémentation par défaut)
    func fetchUserPublisher(id: String) -> AnyPublisher<User, Error>
}

// Implémentation par défaut du Publisher basée sur async
extension UserRepositoryProtocol {
    func fetchUserPublisher(id: String) -> AnyPublisher<User, Error> {
        Future { promise in
            Task {
                do {
                    let user = try await self.fetchUser(id: id)
                    promise(.success(user))
                } catch {
                    promise(.failure(error))
                }
            }
        }
        .eraseToAnyPublisher()
    }
}

// Implémentation moderne - async first
class UserRepository: UserRepositoryProtocol {
    private let apiClient: APIClient

    init(apiClient: APIClient) {
        self.apiClient = apiClient
    }

    func fetchUser(id: String) async throws -> User {
        // Implémentation native async
        let url = URL(string: "https://api.example.com/users/\(id)")!
        let (data, _) = try await URLSession.shared.data(from: url)
        return try JSONDecoder().decode(User.self, from: data)
    }

    // fetchUserPublisher est fourni par l'extension par défaut
}

Cette approche permet aux nouveaux appelants d'utiliser async/await tandis que le code legacy continue d'utiliser les Publishers.

Attention à la gestion mémoire

Lors du bridging, les Task créées peuvent survivre aux objets les ayant créées. Toujours utiliser [weak self] ou annuler explicitement les tâches pour éviter les fuites mémoire.

ViewModel hybride

Un ViewModel peut exposer les deux interfaces pendant la période de transition.

HybridViewModel.swiftswift
// ViewModel supportant Combine et async/await
import Combine
import SwiftUI

@MainActor
class ProfileViewModel: ObservableObject {
    // État publié pour SwiftUI (Combine)
    @Published private(set) var user: User?
    @Published private(set) var isLoading = false
    @Published private(set) var errorMessage: String?

    private let repository: UserRepositoryProtocol
    private var cancellables = Set<AnyCancellable>()
    private var loadTask: Task<Void, Never>?

    init(repository: UserRepositoryProtocol) {
        self.repository = repository
    }

    // Interface async pour UIKit moderne ou SwiftUI avec .task
    func loadUser(id: String) async {
        isLoading = true
        errorMessage = nil

        do {
            user = try await repository.fetchUser(id: id)
        } catch {
            errorMessage = error.localizedDescription
        }

        isLoading = false
    }

    // Interface Combine pour code legacy
    func loadUserPublisher(id: String) {
        isLoading = true
        errorMessage = nil

        repository.fetchUserPublisher(id: id)
            .receive(on: DispatchQueue.main)
            .sink(
                receiveCompletion: { [weak self] completion in
                    self?.isLoading = false
                    if case .failure(let error) = completion {
                        self?.errorMessage = error.localizedDescription
                    }
                },
                receiveValue: { [weak self] user in
                    self?.user = user
                }
            )
            .store(in: &cancellables)
    }

    // Annulation propre
    func cancelLoading() {
        loadTask?.cancel()
        cancellables.removeAll()
        isLoading = false
    }
}

Migration des opérateurs Combine courants

Certains opérateurs Combine n'ont pas d'équivalent direct en async/await. Voici comment les reproduire.

Équivalent de debounce avec async

DebounceAsync.swiftswift
// Implémentation de debounce avec async/await
import Foundation

actor Debouncer {
    private var task: Task<Void, Never>?
    private let duration: Duration

    init(duration: Duration) {
        self.duration = duration
    }

    // Annule l'exécution précédente et planifie une nouvelle
    func debounce(_ operation: @escaping @Sendable () async -> Void) {
        task?.cancel()

        task = Task {
            do {
                // Attend la durée spécifiée
                try await Task.sleep(for: duration)
                // Exécute l'opération si non annulée
                await operation()
            } catch {
                // Task annulée - comportement attendu
            }
        }
    }
}

// Utilisation dans un ViewModel
@MainActor
class SearchViewModel: ObservableObject {
    @Published var searchText = ""
    @Published private(set) var results: [SearchResult] = []

    private let debouncer = Debouncer(duration: .milliseconds(300))
    private let searchService: SearchService

    init(searchService: SearchService) {
        self.searchService = searchService
    }

    func onSearchTextChanged(_ text: String) {
        Task {
            await debouncer.debounce { [weak self] in
                guard let self else { return }
                await self.performSearch(text)
            }
        }
    }

    private func performSearch(_ query: String) async {
        guard query.count >= 3 else {
            results = []
            return
        }

        do {
            results = try await searchService.search(query: query)
        } catch {
            // Gestion d'erreur
        }
    }
}

Équivalent de merge avec TaskGroup

MergeAsync.swiftswift
// Combiner plusieurs flux async avec TaskGroup
import Foundation

struct AsyncMerge {
    // Exécute plusieurs opérations async en parallèle et retourne tous les résultats
    static func merge<T>(
        _ operations: [@Sendable () async throws -> T]
    ) async throws -> [T] {
        try await withThrowingTaskGroup(of: T.self) { group in
            // Lance toutes les opérations en parallèle
            for operation in operations {
                group.addTask {
                    try await operation()
                }
            }

            // Collecte les résultats
            var results: [T] = []
            for try await result in group {
                results.append(result)
            }
            return results
        }
    }

    // Version streaming qui émet les résultats dès qu'ils arrivent
    static func mergeStream<T: Sendable>(
        _ operations: [@Sendable () async throws -> T]
    ) -> AsyncThrowingStream<T, Error> {
        AsyncThrowingStream { continuation in
            Task {
                await withThrowingTaskGroup(of: T.self) { group in
                    for operation in operations {
                        group.addTask {
                            try await operation()
                        }
                    }

                    do {
                        for try await result in group {
                            continuation.yield(result)
                        }
                        continuation.finish()
                    } catch {
                        continuation.finish(throwing: error)
                    }
                }
            }
        }
    }
}

// Utilisation
class DataAggregator {
    func fetchAllData() async throws -> AggregatedData {
        // Exécute trois requêtes en parallèle
        let results = try await AsyncMerge.merge([
            { try await self.fetchUsers() },
            { try await self.fetchPosts() },
            { try await self.fetchComments() }
        ])

        return AggregatedData(
            users: results[0] as! [User],
            posts: results[1] as! [Post],
            comments: results[2] as! [Comment]
        )
    }
}

Prêt à réussir tes entretiens iOS ?

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

Cas d'usage où Combine reste préférable

Malgré les avantages d'async/await, certains scénarios restent mieux servis par Combine.

Flux d'événements UI réactifs

SwiftUI et UIKit génèrent des flux d'événements continus où les opérateurs Combine (debounce, throttle, combineLatest) brillent.

UIEventsCombine.swiftswift
// Combine reste optimal pour les événements UI réactifs
import Combine
import SwiftUI

class FormViewModel: ObservableObject {
    @Published var email = ""
    @Published var password = ""
    @Published var confirmPassword = ""

    // États dérivés calculés via Combine
    @Published private(set) var isEmailValid = false
    @Published private(set) var isPasswordStrong = false
    @Published private(set) var passwordsMatch = false
    @Published private(set) var canSubmit = false

    private var cancellables = Set<AnyCancellable>()

    init() {
        setupValidation()
    }

    private func setupValidation() {
        // Validation email avec debounce
        $email
            .debounce(for: .milliseconds(300), scheduler: RunLoop.main)
            .map { email in
                let regex = /^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$/
                return email.wholeMatch(of: regex) != nil
            }
            .assign(to: &$isEmailValid)

        // Validation force du mot de passe
        $password
            .map { password in
                password.count >= 8 &&
                password.rangeOfCharacter(from: .uppercaseLetters) != nil &&
                password.rangeOfCharacter(from: .decimalDigits) != nil
            }
            .assign(to: &$isPasswordStrong)

        // Correspondance des mots de passe
        Publishers.CombineLatest($password, $confirmPassword)
            .map { password, confirm in
                !password.isEmpty && password == confirm
            }
            .assign(to: &$passwordsMatch)

        // Combinaison finale pour activer le bouton submit
        Publishers.CombineLatest3($isEmailValid, $isPasswordStrong, $passwordsMatch)
            .map { $0 && $1 && $2 }
            .assign(to: &$canSubmit)
    }
}

Ce pattern déclaratif serait beaucoup plus verbeux avec async/await.

Gestion de connexions WebSocket

Les WebSockets émettent des messages en continu, un cas d'usage naturel pour Combine.

WebSocketCombine.swiftswift
// WebSocket avec Combine pour flux continu
import Combine
import Foundation

class WebSocketManager: ObservableObject {
    @Published private(set) var messages: [ChatMessage] = []
    @Published private(set) var connectionState: ConnectionState = .disconnected

    private var webSocketTask: URLSessionWebSocketTask?
    private let messageSubject = PassthroughSubject<ChatMessage, Never>()
    private var cancellables = Set<AnyCancellable>()

    // Publisher exposé pour les consommateurs
    var messagePublisher: AnyPublisher<ChatMessage, Never> {
        messageSubject.eraseToAnyPublisher()
    }

    func connect(to url: URL) {
        webSocketTask = URLSession.shared.webSocketTask(with: url)
        webSocketTask?.resume()
        connectionState = .connected

        // Démarre la réception en boucle
        receiveMessages()

        // Pipeline de traitement des messages
        messageSubject
            // Buffer les messages pour éviter les mises à jour UI trop fréquentes
            .collect(.byTime(RunLoop.main, .milliseconds(100)))
            // Accumule dans l'historique
            .scan([ChatMessage]()) { accumulated, new in
                accumulated + new
            }
            .assign(to: &$messages)
    }

    private func receiveMessages() {
        webSocketTask?.receive { [weak self] result in
            switch result {
            case .success(let message):
                if case .string(let text) = message,
                   let data = text.data(using: .utf8),
                   let chatMessage = try? JSONDecoder().decode(ChatMessage.self, from: data) {
                    self?.messageSubject.send(chatMessage)
                }
                // Continue la réception
                self?.receiveMessages()

            case .failure(let error):
                self?.connectionState = .error(error.localizedDescription)
            }
        }
    }
}

Checklist de migration progressive

Une migration réussie suit une approche méthodique. Voici les étapes recommandées.

Phase 1 : Préparation

  • ✅ Identifier les Publishers utilisés dans la codebase
  • ✅ Catégoriser : flux continus vs opérations ponctuelles
  • ✅ Créer les extensions de bridging (firstValue, asyncMap)
  • ✅ Définir des protocoles abstraits pour les repositories

Phase 2 : Migration des opérations ponctuelles

  • ✅ Convertir les requêtes réseau simples en async/await
  • ✅ Migrer les lectures de fichiers
  • ✅ Transformer les opérations base de données
  • ✅ Conserver les Publishers via les implémentations par défaut

Phase 3 : Adaptation des ViewModels

  • ✅ Ajouter les méthodes async aux ViewModels existants
  • ✅ Utiliser .task dans SwiftUI pour les nouveaux écrans
  • ✅ Maintenir les bindings @Published pour la compatibilité

Phase 4 : Nettoyage

  • ✅ Supprimer les méthodes Combine devenues inutilisées
  • ✅ Retirer les extensions de bridging non utilisées
  • ✅ Documenter les patterns Combine conservés intentionnellement

Conclusion

La migration de Combine vers async/await représente une évolution naturelle pour les projets Swift modernes. L'approche progressive, utilisant des patterns de bridging bidirectionnel, permet d'adopter les avantages d'async/await sans rupture brutale.

Points clés à retenir :

  • ✅ Combine et async/await répondent à des besoins différents
  • .values convertit un Publisher en AsyncSequence
  • Future + Task encapsule du code async dans un Publisher
  • ✅ Les protocoles abstraits facilitent la coexistence
  • ✅ Combine reste pertinent pour les flux UI réactifs
  • ✅ Les opérateurs comme debounce peuvent être recréés en async
  • ✅ La migration progressive réduit les risques de régression

L'objectif n'est pas d'éliminer Combine, mais de choisir le bon outil selon le contexte : async/await pour les opérations ponctuelles, Combine pour les flux d'événements continus.

Passe à la pratique !

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

Tags

#swift
#ios
#combine
#async-await
#migration

Partager

Articles similaires