Combine vs async/await in Swift: Progressive Migrationsmuster

Vollständiger Leitfaden zur Migration von Combine zu async/await in Swift: progressive Strategien, Bridging-Muster und Paradigmen-Koexistenz in iOS-Codebasen.

Migration von Combine zu async/await in Swift mit Koexistenzmustern

Die Einführung von Swift Concurrency mit async/await hat die asynchrone Programmierung unter iOS grundlegend verändert. Bei Projekten, die Combine nutzen, stellt sich die Frage der Migration ganz natürlich. Muss alles neu geschrieben werden? Können beide Ansätze koexistieren? Welche Muster ermöglichen einen reibungslosen Übergang? Dieser Leitfaden untersucht progressive Migrationsstrategien und ermöglicht den Einstieg in async/await, ohne Combine abrupt aufzugeben.

Was dieser Leitfaden behandelt

Dieser Leitfaden präsentiert konkrete Muster für die progressive Migration von Combine zu async/await, mit bidirektionalen Bridging-Beispielen und Koexistenzstrategien für bestehende Codebasen.

Die grundlegenden Unterschiede verstehen

Vor einer Migration ist es essenziell zu verstehen, was Combine von async/await unterscheidet. Beide Ansätze bedienen unterschiedliche Bedürfnisse, und bestimmte Anwendungsfälle bleiben mit Combine besser bedient.

Das mentale Modell von Combine

Combine basiert auf einem Datenstrom-Modell. Ein Publisher emittiert Werte über die Zeit, Operatoren transformieren diese Werte und ein Subscriber empfängt das Endergebnis. Dieses Modell glänzt bei kontinuierlichen Strömen wie UI-Ereignissen, Notifications oder WebSockets.

CombineExample.swiftswift
// Event stream with Combine - stream-based model
import Combine

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

    // Combine excels for continuous streams with transformations
    func setupSearch() {
        $searchText
            // Wait 300ms pause in typing
            .debounce(for: .milliseconds(300), scheduler: RunLoop.main)
            // Ignore consecutive duplicates
            .removeDuplicates()
            // Filter searches that are too short
            .filter { $0.count >= 3 }
            // Transform text into network request
            .flatMap { query in
                self.searchAPI(query: query)
                    // Local error handling
                    .catch { _ in Just([]) }
            }
            // Final subscription
            .sink { results in
                self.updateUI(with: results)
            }
            .store(in: &cancellables)
    }

    private func searchAPI(query: String) -> AnyPublisher<[SearchResult], Error> {
        // Network implementation
    }
}

Dieser Code veranschaulicht die Stärke von Combine: das Verketten deklarativer Operatoren zur Verarbeitung eines kontinuierlichen Ereignisstroms.

Das mentale Modell von async/await

Async/await verfolgt ein sequenzielles Modell: eine Operation startet, der Code wartet auf das Ergebnis und fährt dann fort. Dieses Modell ist intuitiver für punktuelle Operationen wie isolierte Netzwerkanfragen oder Dateilesevorgänge.

AsyncAwaitExample.swiftswift
// One-off operations with async/await - sequential model
import Foundation

actor SearchService {
    // async/await excels for sequential operations
    func performSearch(query: String) async throws -> [SearchResult] {
        // Pre-validation - clear sequential reading
        guard query.count >= 3 else {
            return []
        }

        // Network request with await
        let url = URL(string: "https://api.example.com/search?q=\(query)")!
        let (data, response) = try await URLSession.shared.data(from: url)

        // Response verification
        guard let httpResponse = response as? HTTPURLResponse,
              httpResponse.statusCode == 200 else {
            throw SearchError.invalidResponse
        }

        // Result decoding
        let results = try JSONDecoder().decode([SearchResult].self, from: data)
        return results
    }
}

Die Lektüre verläuft linear, Fehler werden mit try natürlich propagiert und der Ausführungsfluss ist sofort verständlich.

Wann welcher Ansatz zu wählen ist

Combine bleibt relevant für kontinuierliche Ströme (UI-Ereignisse, Timer, WebSockets). Async/await eignet sich besser für punktuelle Operationen (API-Anfragen, Dateilesen, isolierte Berechnungen).

Brücke von Combine zu async/await schlagen

Der erste Schritt einer Migration besteht oft darin, bestehende Publisher in async/await-Code zu konsumieren. Swift bietet native Werkzeuge für dieses Bridging.

AsyncSequence mit Publisher.values verwenden

Seit Swift 5.5 stellt jeder Publisher eine .values-Eigenschaft bereit, die einen AsyncPublisher zurückgibt. Diese asynchrone Sequenz erlaubt das Iterieren über die emittierten Werte mit einer for await-Schleife.

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

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

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

    // Consuming the Publisher with async/await
    func observeNotifications() async {
        // .values converts the Publisher to AsyncSequence
        for await notification in notificationPublisher.values {
            // Process each notification
            await handleAppBecameActive(notification)
        }
        // This line is never reached for an infinite Publisher
    }

    private func handleAppBecameActive(_ notification: Notification) async {
        // Async processing logic
    }
}

Dieser Ansatz erhält den ursprünglichen Publisher und ermöglicht gleichzeitig dessen Konsum in einem asynchronen Kontext.

Einen einzelnen Wert mit firstValue erhalten

Für Publisher, die einen einzigen Wert emittieren (wie eine Netzwerkanfrage), vereinfachen die Eigenschaft .values.first(where:) oder eine eigene Erweiterung das Bridging.

SingleValueBridging.swiftswift
// Extension to extract a single value from a Publisher
import Combine

extension Publisher where Failure == Never {
    // Awaits and returns the first emitted value
    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 {
    // Throwing version for Publishers with errors
    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)
                        }
                    )
            }
        }
    }
}

// Usage in async code
class UserRepository {
    private let apiClient: APIClient

    func fetchCurrentUser() async throws -> User {
        // Consume an existing Publisher asynchronously
        try await apiClient.userPublisher().firstValueThrowing
    }
}

Diese Erweiterung kapselt die Komplexität des Bridgings und bietet eine saubere API.

Bereit für deine iOS-Interviews?

Übe mit unseren interaktiven Simulatoren, Flashcards und technischen Tests.

Brücke von async/await zu Combine schlagen

Die umgekehrte Migration ist ebenfalls notwendig: async-Code in bestehenden Combine-Pipelines konsumieren.

Einen Publisher aus einer async-Funktion erstellen

Der direkteste Ansatz nutzt Future in Kombination mit einem Task, um den async-Aufruf zu kapseln.

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

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

// Usage in a Combine pipeline
class ImageProcessor {
    @Published var selectedImageURL: URL?
    private var cancellables = Set<AnyCancellable>()

    func setupProcessingPipeline() {
        $selectedImageURL
            .compactMap { $0 }
            // Use an async function in the Combine pipeline
            .asyncMap { url in
                // downloadImage is an async function
                try await self.downloadImage(from: url)
            }
            .asyncMap { imageData in
                // processImage is also async
                try await self.processImage(imageData)
            }
            .receive(on: DispatchQueue.main)
            .sink(
                receiveCompletion: { completion in
                    if case .failure(let error) = completion {
                        print("Error: \(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 {
        // Async image processing
    }
}

Eigener Publisher für Async-Streams

Für anspruchsvollere Anforderungen kann ein eigener Publisher einen vollständigen AsyncSequence-Stream kapseln.

AsyncSequencePublisher.swiftswift
// Publisher wrapper for 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) {
        // Start asynchronous iteration
        task = Task {
            do {
                for try await element in sequence {
                    // Check subscription is still 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
    }
}

// Convenience extension for any AsyncSequence
extension AsyncSequence {
    var publisher: AsyncSequencePublisher<Self> {
        AsyncSequencePublisher(self)
    }
}

Koexistenzstrategien in einer Codebasis

Die vollständige Migration einer großen Codebasis braucht Zeit. Hier sind Muster, um Combine und async/await harmonisch nebeneinander bestehen zu lassen.

Geschichtete Architektur mit Abstraktion

Das Definieren von Protokollen, die die Implementierung abstrahieren, ermöglicht eine progressive Migration ohne Änderung des aufrufenden Codes.

RepositoryAbstraction.swiftswift
// Abstraction enabling two implementations
import Combine

// Protocol defining the contract
protocol UserRepositoryProtocol {
    // Modern async interface
    func fetchUser(id: String) async throws -> User

    // Legacy Combine interface (optional with default implementation)
    func fetchUserPublisher(id: String) -> AnyPublisher<User, Error>
}

// Default Publisher implementation based on 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()
    }
}

// Modern implementation - async first
class UserRepository: UserRepositoryProtocol {
    private let apiClient: APIClient

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

    func fetchUser(id: String) async throws -> User {
        // Native async implementation
        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 is provided by the default extension
}

Dieser Ansatz erlaubt neuen Aufrufern die Nutzung von async/await, während Legacy-Code weiterhin Publisher verwendet.

Auf Speicherverwaltung achten

Beim Bridging können erstellte Task länger leben als die Objekte, die sie erzeugt haben. Es empfiehlt sich, immer [weak self] zu verwenden oder Tasks explizit abzubrechen, um Speicherlecks zu vermeiden.

Hybrides ViewModel

Ein ViewModel kann während der Übergangsphase beide Schnittstellen anbieten.

HybridViewModel.swiftswift
// ViewModel supporting both Combine and async/await
import Combine
import SwiftUI

@MainActor
class ProfileViewModel: ObservableObject {
    // Published state for 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
    }

    // Async interface for modern UIKit or SwiftUI with .task
    func loadUser(id: String) async {
        isLoading = true
        errorMessage = nil

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

        isLoading = false
    }

    // Combine interface for legacy code
    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)
    }

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

Häufige Combine-Operatoren migrieren

Einige Combine-Operatoren haben kein direktes async/await-Äquivalent. Hier ist gezeigt, wie sie nachgebildet werden können.

Debounce-Äquivalent mit async

DebounceAsync.swiftswift
// Debounce implementation with async/await
import Foundation

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

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

    // Cancels previous execution and schedules a new one
    func debounce(_ operation: @escaping @Sendable () async -> Void) {
        task?.cancel()

        task = Task {
            do {
                // Wait for the specified duration
                try await Task.sleep(for: duration)
                // Execute operation if not cancelled
                await operation()
            } catch {
                // Task cancelled - expected behavior
            }
        }
    }
}

// Usage in a 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 {
            // Error handling
        }
    }
}

Merge-Äquivalent mit TaskGroup

MergeAsync.swiftswift
// Combining multiple async streams with TaskGroup
import Foundation

struct AsyncMerge {
    // Executes multiple async operations in parallel and returns all results
    static func merge<T>(
        _ operations: [@Sendable () async throws -> T]
    ) async throws -> [T] {
        try await withThrowingTaskGroup(of: T.self) { group in
            // Launch all operations in parallel
            for operation in operations {
                group.addTask {
                    try await operation()
                }
            }

            // Collect results
            var results: [T] = []
            for try await result in group {
                results.append(result)
            }
            return results
        }
    }

    // Streaming version that emits results as they arrive
    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)
                    }
                }
            }
        }
    }
}

// Usage
class DataAggregator {
    func fetchAllData() async throws -> AggregatedData {
        // Execute three requests in parallel
        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]
        )
    }
}

Bereit für deine iOS-Interviews?

Übe mit unseren interaktiven Simulatoren, Flashcards und technischen Tests.

Anwendungsfälle, in denen Combine vorzuziehen bleibt

Trotz der Vorteile von async/await sind bestimmte Szenarien weiterhin besser mit Combine zu lösen.

Reaktive UI-Ereignisströme

SwiftUI und UIKit erzeugen kontinuierliche Ereignisströme, in denen Combine-Operatoren (debounce, throttle, combineLatest) glänzen.

UIEventsCombine.swiftswift
// Combine remains optimal for reactive UI events
import Combine
import SwiftUI

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

    // Derived states computed 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() {
        // Email validation with 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)

        // Password strength validation
        $password
            .map { password in
                password.count >= 8 &&
                password.rangeOfCharacter(from: .uppercaseLetters) != nil &&
                password.rangeOfCharacter(from: .decimalDigits) != nil
            }
            .assign(to: &$isPasswordStrong)

        // Password matching
        Publishers.CombineLatest($password, $confirmPassword)
            .map { password, confirm in
                !password.isEmpty && password == confirm
            }
            .assign(to: &$passwordsMatch)

        // Final combination to enable submit button
        Publishers.CombineLatest3($isEmailValid, $isPasswordStrong, $passwordsMatch)
            .map { $0 && $1 && $2 }
            .assign(to: &$canSubmit)
    }
}

Dieses deklarative Muster wäre mit async/await deutlich umständlicher.

Verwaltung von WebSocket-Verbindungen

WebSockets emittieren kontinuierlich Nachrichten, ein natürlicher Anwendungsfall für Combine.

WebSocketCombine.swiftswift
// WebSocket with Combine for continuous stream
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>()

    // Exposed Publisher for consumers
    var messagePublisher: AnyPublisher<ChatMessage, Never> {
        messageSubject.eraseToAnyPublisher()
    }

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

        // Start reception loop
        receiveMessages()

        // Message processing pipeline
        messageSubject
            // Buffer messages to avoid too frequent UI updates
            .collect(.byTime(RunLoop.main, .milliseconds(100)))
            // Accumulate in history
            .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 reception
                self?.receiveMessages()

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

Checkliste für die progressive Migration

Eine erfolgreiche Migration folgt einem methodischen Vorgehen. Hier die empfohlenen Schritte.

Phase 1: Vorbereitung

  • ✅ Die in der Codebasis verwendeten Publisher identifizieren
  • ✅ Kategorisieren: kontinuierliche Ströme vs punktuelle Operationen
  • ✅ Bridging-Erweiterungen erstellen (firstValue, asyncMap)
  • ✅ Abstrakte Protokolle für die Repositories definieren

Phase 2: Migration der punktuellen Operationen

  • ✅ Einfache Netzwerkanfragen zu async/await konvertieren
  • ✅ Dateilesevorgänge migrieren
  • ✅ Datenbankoperationen umwandeln
  • ✅ Publisher über Standardimplementierungen erhalten

Phase 3: Anpassung der ViewModels

  • ✅ Async-Methoden zu bestehenden ViewModels hinzufügen
  • .task in SwiftUI für neue Bildschirme verwenden
  • ✅ @Published-Bindings zur Kompatibilität beibehalten

Phase 4: Aufräumen

  • ✅ Combine-Methoden entfernen, die unbrauchbar geworden sind
  • ✅ Nicht verwendete Bridging-Erweiterungen löschen
  • ✅ Bewusst beibehaltene Combine-Muster dokumentieren

Fazit

Die Migration von Combine zu async/await stellt eine natürliche Evolution für moderne Swift-Projekte dar. Der progressive Ansatz mit bidirektionalen Bridging-Mustern ermöglicht den Einstieg in die Vorteile von async/await ohne brutalen Bruch.

Wichtige Erkenntnisse:

  • ✅ Combine und async/await bedienen unterschiedliche Bedürfnisse
  • .values wandelt einen Publisher in eine AsyncSequence um
  • Future + Task kapseln async-Code in einem Publisher
  • ✅ Abstrakte Protokolle erleichtern die Koexistenz
  • ✅ Combine bleibt relevant für reaktive UI-Ströme
  • ✅ Operatoren wie debounce können in async nachgebildet werden
  • ✅ Die progressive Migration reduziert Regressionsrisiken

Das Ziel besteht nicht darin, Combine zu eliminieren, sondern für jeden Kontext das richtige Werkzeug zu wählen: async/await für punktuelle Operationen, Combine für kontinuierliche Ereignisströme.

Fang an zu üben!

Teste dein Wissen mit unseren Interview-Simulatoren und technischen Tests.

Tags

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

Teilen

Verwandte Artikel