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.

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.
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.
// 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.
// 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.
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.
// 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.
// 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.
// 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.
// 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.
// 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.
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.
// 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
// 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
// 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.
// 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.
// 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
.taskdans 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
- ✅
.valuesconvertit un Publisher en AsyncSequence - ✅
Future+Taskencapsule 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
Partager
Articles similaires

Migration Core Data vers SwiftData : guide étape par étape 2026
Guide complet pour migrer une application iOS de Core Data vers SwiftData avec des exemples pratiques, stratégies de coexistence et bonnes pratiques.

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.

Questions entretien iOS accessibilité en 2026 : VoiceOver et Dynamic Type
Préparez vos entretiens iOS avec les questions clés sur l'accessibilité : VoiceOver, Dynamic Type, traits sémantiques et audits d'accessibilité.