# 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. - Published: 2026-03-13 - Updated: 2026-03-28 - Author: SharpSkill - Tags: swift, ios, combine, async-await, migration - Reading time: 14 min --- 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. ```swift // CombineExample.swift // Flux d'événements avec Combine - modèle stream-based import Combine class SearchViewModel { @Published var searchText = "" private var cancellables = Set() // 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. ```swift // AsyncAwaitExample.swift // 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`. ```swift // BridgingCombineToAsync.swift // Conversion Publisher → AsyncSequence via .values import Combine class NotificationObserver { private let notificationPublisher: AnyPublisher 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. ```swift // SingleValueBridging.swift // 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. ## 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. ```swift // BridgingAsyncToCombine.swift // Conversion async → Publisher via Future import Combine extension Publisher { // Opérateur flatMap async pour pipelines Combine func asyncMap( _ transform: @escaping (Output) async throws -> T ) -> AnyPublisher { 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() 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. ```swift // AsyncSequencePublisher.swift // Publisher wrapper pour AsyncSequence import Combine struct AsyncSequencePublisher: Publisher { typealias Output = S.Element typealias Failure = Error private let sequence: S init(_ sequence: S) { self.sequence = sequence } func receive(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: Subscription where Sub.Input == S.Element, Sub.Failure == Error { private var task: Task? 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 { 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. ```swift // RepositoryAbstraction.swift // 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 } // Implémentation par défaut du Publisher basée sur async extension UserRepositoryProtocol { func fetchUserPublisher(id: String) -> AnyPublisher { 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. ```swift // HybridViewModel.swift // 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() private var loadTask: Task? 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 ```swift // DebounceAsync.swift // Implémentation de debounce avec async/await import Foundation actor Debouncer { private var task: Task? 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 ```swift // MergeAsync.swift // 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( _ 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( _ operations: [@Sendable () async throws -> T] ) -> AsyncThrowingStream { 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] ) } } ``` ## 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. ```swift // UIEventsCombine.swift // 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() 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. ```swift // WebSocketCombine.swift // 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() private var cancellables = Set() // Publisher exposé pour les consommateurs var messagePublisher: AnyPublisher { 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. --- Source: SharpSkill (https://sharpskill.dev), tech interview preparation for your real stack. HTML version of this page: https://sharpskill.dev/fr/blog/ios/combine-vs-async-await-swift-patterns-migration