Combine Framework: Reaktive Programmierung in Swift

Combine meistern, um asynchrone Datenströme in Swift zu beherrschen: Publishers, Subscribers, Operators und fortgeschrittene Muster für iOS-Apps.

Combine-Framework-Anleitung für reaktive Programmierung in Swift iOS

Reaktive Programmierung verändert grundlegend, wie asynchrone Ereignisse und Datenströme in iOS-Anwendungen behandelt werden. Combine, Apples natives Framework, bietet einen deklarativen und typsicheren Ansatz, um komplexe Daten-Pipelines zu orchestrieren. Dieser Leitfaden führt von den Grundkonzepten bis hin zu produktionsreifen Mustern.

Warum Combine statt RxSwift?

Combine ist ab iOS 13 fest integriert, liefert dank Apples Optimierungen bessere Leistung und fügt sich nahtlos in SwiftUI ein. Keine externen Abhängigkeiten zu pflegen.

Combine Kernkonzepte

Combine baut auf drei zentralen Konzepten auf: Publishers, die Werte ausgeben, Subscribers, die diese empfangen, und Operators, die Daten dazwischen transformieren. Diese Architektur erlaubt es, reaktive und kombinierbare Daten-Pipelines aufzubauen.

Publisher: die Datenquelle

Ein Publisher ist ein Typ, der eine Folge von Werten über die Zeit emittieren kann. Jeder Publisher deklariert zwei zugehörige Typen: den Typ des emittierten Werts (Output) und den Typ möglicher Fehler (Failure). So lassen sich verschiedene Publisher-Typen erstellen:

PublisherBasics.swiftswift
import Combine

// Just: emits a single value then completes
// Useful for converting a simple value to a Publisher
let singleValue = Just("Hello Combine")

// CurrentValueSubject: stores and emits the current value
// Perfect for representing state that changes over time
let counter = CurrentValueSubject<Int, Never>(0)

// PassthroughSubject: emits values without storing them
// Ideal for one-time events (taps, notifications)
let buttonTaps = PassthroughSubject<Void, Never>()

// Future: emits a single value asynchronously
// Wraps an async operation that returns a result
let asyncOperation = Future<String, Error> { promise in
    // Simulate a network call
    DispatchQueue.global().asyncAfter(deadline: .now() + 1) {
        promise(.success("Data loaded"))
    }
}

Der Typ Never für Fehler bedeutet, dass der Publisher niemals scheitern kann. Diese Garantie zur Compile-Zeit vereinfacht den Code zur Fehlerbehandlung.

Subscriber: Werte empfangen

Ein Subscriber abonniert einen Publisher, um dessen Werte zu erhalten. Die Methode sink ist die häufigste Art, einen Subscriber zu erstellen. Sie erwartet zwei Closures: eine für Fehler oder Abschluss und eine für jeden empfangenen Wert:

SubscriberBasics.swiftswift
import Combine

// Variable to store subscriptions
// Without this reference, the subscription would be immediately cancelled
var cancellables = Set<AnyCancellable>()

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

// sink() creates a Subscriber that receives values
publisher
    .sink(
        // Called when the Publisher completes or fails
        receiveCompletion: { completion in
            switch completion {
            case .finished:
                print("✅ Completed successfully")
            case .failure(let error):
                print("❌ Error: \(error)")
            }
        },
        // Called for each emitted value
        receiveValue: { value in
            print("Received: \(value)")
        }
    )
    // store() keeps a reference to the subscription
    .store(in: &cancellables)

// Output:
// Received: Swift
// Received: Combine
// Received: iOS
// ✅ Completed successfully
Vorsicht vor Memory Leaks

Das von sink() zurückgegebene AnyCancellable muss stets gespeichert werden. Ohne Referenz wird das Abonnement automatisch beendet und kein Wert empfangen.

Daten transformieren mit Operators

Operators sind das Herzstück von Combine. Sie ermöglichen es, Datenströme deklarativ zu transformieren, zu filtern und zu kombinieren. Jeder Operator gibt einen neuen Publisher zurück, sodass sie sich verketten lassen.

Wesentliche Transformations-Operators

Transformations-Operators verändern jeden emittierten Wert. map transformiert Werte, flatMap flacht verschachtelte Publisher ab und compactMap filtert nil-Werte heraus:

TransformOperators.swiftswift
import Combine

var cancellables = Set<AnyCancellable>()

// map: transforms each value
// Equivalent to map on arrays
[1, 2, 3, 4, 5].publisher
    .map { $0 * 2 }  // Multiply each number by 2
    .sink { print("Doubled: \($0)") }
    .store(in: &cancellables)
// Output: 2, 4, 6, 8, 10

// compactMap: transforms AND filters out nil
// Useful for optional conversions
["1", "two", "3", "four", "5"].publisher
    .compactMap { Int($0) }  // Convert to Int, ignore failures
    .sink { print("Valid number: \($0)") }
    .store(in: &cancellables)
// Output: 1, 3, 5

// flatMap: flattens nested Publishers
// Essential for chaining async operations
struct User { let id: Int; let name: String }

func fetchUser(id: Int) -> AnyPublisher<User, Never> {
    // Simulate an API call
    Just(User(id: id, name: "User \(id)"))
        .delay(for: .milliseconds(100), scheduler: RunLoop.main)
        .eraseToAnyPublisher()
}

[1, 2, 3].publisher
    .flatMap { id in fetchUser(id: id) }  // Each ID becomes an API call
    .sink { user in print("User: \(user.name)") }
    .store(in: &cancellables)

Filter-Operators

Filter-Operators steuern, welche Werte die Pipeline durchlaufen. Sie sind unverzichtbar, um unnötige Verarbeitung zu vermeiden und die Performance zu optimieren:

FilterOperators.swiftswift
import Combine

var cancellables = Set<AnyCancellable>()

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

// filter: keeps only values that satisfy the condition
numbers
    .filter { $0 > 2 }  // Keep only numbers > 2
    .sink { print("Filtered: \($0)") }
    .store(in: &cancellables)
// Output: 3, 3, 3, 4, 5, 5

// removeDuplicates: removes consecutive identical values
numbers
    .removeDuplicates()  // Eliminate consecutive duplicates
    .sink { print("Without duplicates: \($0)") }
    .store(in: &cancellables)
// Output: 1, 2, 3, 4, 5

// debounce: waits for a pause before emitting
// Perfect for real-time search
let searchText = PassthroughSubject<String, Never>()

searchText
    .debounce(for: .milliseconds(300), scheduler: RunLoop.main)
    .removeDuplicates()  // Ignore if text hasn't changed
    .sink { query in
        print("Search: \(query)")
        // Launch API call here
    }
    .store(in: &cancellables)

// Simulate rapid typing
searchText.send("S")
searchText.send("Sw")
searchText.send("Swi")
searchText.send("Swift")  // Only "Swift" is emitted after 300ms

Bereit für deine iOS-Interviews?

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

Mehrere Publisher kombinieren

Reale Anwendungen müssen häufig mehrere Datenquellen verbinden. Combine bietet verschiedene Operators, um diese Mehrfachströme zu orchestrieren.

CombineLatest und Zip

combineLatest emittiert, sobald irgendein Publisher emittiert, und kombiniert dabei mit den letzten Werten der anderen. zip wartet, bis alle Publisher emittiert haben, bevor kombiniert wird:

CombiningPublishers.swiftswift
import Combine

var cancellables = Set<AnyCancellable>()

// Simulate a form with validation
let email = CurrentValueSubject<String, Never>("")
let password = CurrentValueSubject<String, Never>("")

// combineLatest: combines the latest values from each Publisher
// Emits on every change from either source
Publishers.CombineLatest(email, password)
    .map { email, password in
        // Validate that email contains @ and password > 6 chars
        let isEmailValid = email.contains("@")
        let isPasswordValid = password.count >= 6
        return isEmailValid && isPasswordValid
    }
    .sink { isFormValid in
        print("Form valid: \(isFormValid)")
    }
    .store(in: &cancellables)

email.send("user@example.com")  // false (password empty)
password.send("123456")          // true (both are valid)

// zip: waits for one value from each Publisher before emitting
// Useful for synchronizing parallel operations
let firstAPI = PassthroughSubject<String, Never>()
let secondAPI = PassthroughSubject<Int, Never>()

Publishers.Zip(firstAPI, secondAPI)
    .sink { stringValue, intValue in
        print("Received pair: \(stringValue), \(intValue)")
    }
    .store(in: &cancellables)

firstAPI.send("Hello")   // No emission, waiting for secondAPI
secondAPI.send(42)       // Emits: ("Hello", 42)
firstAPI.send("World")   // No emission, waiting for secondAPI
secondAPI.send(100)      // Emits: ("World", 100)

Merge zur Vereinheitlichung von Strömen

merge führt mehrere Publisher desselben Typs zu einem einzigen Strom zusammen. Werte treffen in der Reihenfolge ihrer Emission ein, unabhängig vom Absender:

MergePublishers.swiftswift
import Combine

var cancellables = Set<AnyCancellable>()

// Multiple user notification sources
let pushNotifications = PassthroughSubject<String, Never>()
let localNotifications = PassthroughSubject<String, Never>()
let inAppMessages = PassthroughSubject<String, Never>()

// Merge unifies all streams into one
Publishers.Merge3(pushNotifications, localNotifications, inAppMessages)
    .sink { message in
        // Handle all notifications the same way
        print("📬 Notification: \(message)")
    }
    .store(in: &cancellables)

pushNotifications.send("New message")      // 📬 Notification: New message
localNotifications.send("Reminder: meeting")  // 📬 Notification: Reminder: meeting
inAppMessages.send("Welcome!")              // 📬 Notification: Welcome!

Fehlerbehandlung in Combine

Fehlerbehandlung ist fest in den Kern von Combine integriert. Der Failure-Typ der Publisher erlaubt es dem Compiler zu prüfen, dass alle Fehler behandelt werden.

Recovery-Strategien

Combine bietet mehrere Operators für die Fehlerbehandlung: catch, um durch einen anderen Publisher zu ersetzen, retry, um es erneut zu versuchen, und replaceError für einen Standardwert:

ErrorHandling.swiftswift
import Combine

var cancellables = Set<AnyCancellable>()

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

// Simulate an API call that can fail
func fetchData() -> AnyPublisher<String, APIError> {
    Fail(error: APIError.networkError)
        .eraseToAnyPublisher()
}

// retry: retries N times before propagating the error
fetchData()
    .retry(3)  // Try up to 3 times
    .catch { error -> Just<String> in
        // catch: replaces the error with a fallback Publisher
        print("Error after 3 attempts: \(error)")
        return Just("Cached data")  // Fallback value
    }
    .sink(
        receiveCompletion: { _ in },
        receiveValue: { print("Result: \($0)") }
    )
    .store(in: &cancellables)

// replaceError: replaces any error with a fixed value
// Simpler than catch when only a default value is needed
fetchData()
    .replaceError(with: "Error - default value")
    .sink { print("With fallback: \($0)") }
    .store(in: &cancellables)
Never versus Error

Mit setFailureType(to:) lässt sich ein Never-Publisher in einen umwandeln, der scheitern kann; replaceError(with:) oder catch machen den umgekehrten Weg.

MVVM-Muster mit Combine

Combine fügt sich natürlich in das MVVM-Muster (Model-View-ViewModel) ein. Das ViewModel stellt Publisher bereit, die die View beobachtet, und schafft so eine reaktive Bindung zwischen Daten und Oberfläche.

Vollständiges reaktives ViewModel

Hier ein Beispiel-ViewModel für eine Benutzerliste mit Suche, Ladezustand und Fehlerbehandlung:

UserListViewModel.swiftswift
import Combine
import Foundation

// Data model
struct User: Codable, Identifiable {
    let id: Int
    let name: String
    let email: String
}

// ViewModel with reactive state
final class UserListViewModel: ObservableObject {
    // MARK: - Published Properties (observed by SwiftUI)

    @Published var users: [User] = []           // User list
    @Published var searchQuery: String = ""     // Search text
    @Published var isLoading: Bool = false      // Loading state
    @Published var errorMessage: String?        // Optional error message

    // MARK: - Private Properties

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

    // MARK: - Computed Properties

    // Filters users based on search query
    var filteredUsers: [User] {
        guard !searchQuery.isEmpty else { return users }
        return users.filter {
            $0.name.localizedCaseInsensitiveContains(searchQuery)
        }
    }

    // MARK: - Initialization

    init(userService: UserServiceProtocol = UserService()) {
        self.userService = userService
        setupBindings()
    }

    // MARK: - Private Methods

    private func setupBindings() {
        // Observe searchQuery changes
        // debounce prevents too frequent calls
        $searchQuery
            .debounce(for: .milliseconds(300), scheduler: RunLoop.main)
            .removeDuplicates()
            .sink { [weak self] query in
                // Server-side search logic if needed
                print("Search updated: \(query)")
            }
            .store(in: &cancellables)
    }

    // MARK: - Public Methods

    func loadUsers() {
        isLoading = true
        errorMessage = nil

        userService.fetchUsers()
            .receive(on: DispatchQueue.main)  // Ensure UI updates on main thread
            .sink(
                receiveCompletion: { [weak self] completion in
                    self?.isLoading = false
                    if case .failure(let error) = completion {
                        self?.errorMessage = error.localizedDescription
                    }
                },
                receiveValue: { [weak self] users in
                    self?.users = users
                }
            )
            .store(in: &cancellables)
    }
}

Service mit Combine und URLSession

URLSession integriert Combine nativ über dataTaskPublisher. So lässt sich ein wiederverwendbarer Netzwerkdienst aufbauen:

UserService.swiftswift
import Combine
import Foundation

protocol UserServiceProtocol {
    func fetchUsers() -> AnyPublisher<[User], Error>
}

final class UserService: UserServiceProtocol {
    private let baseURL = URL(string: "https://api.example.com")!
    private let session: URLSession
    private let decoder: JSONDecoder

    init(session: URLSession = .shared) {
        self.session = session
        self.decoder = JSONDecoder()
        decoder.keyDecodingStrategy = .convertFromSnakeCase
    }

    func fetchUsers() -> AnyPublisher<[User], Error> {
        let url = baseURL.appendingPathComponent("users")

        return session.dataTaskPublisher(for: url)
            // Check HTTP status code
            .tryMap { data, response in
                guard let httpResponse = response as? HTTPURLResponse else {
                    throw URLError(.badServerResponse)
                }
                guard 200..<300 ~= httpResponse.statusCode else {
                    throw URLError(.badServerResponse)
                }
                return data
            }
            // Decode JSON to Swift model
            .decode(type: [User].self, decoder: decoder)
            // Erase concrete type to return AnyPublisher
            .eraseToAnyPublisher()
    }
}

Bereit für deine iOS-Interviews?

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

SwiftUI-Integration

Combine und SwiftUI bilden ein starkes Duo. Die @Published-Properties eines ObservableObject lösen automatisch View-Updates aus.

SwiftUI-View mit Combine-ViewModel

So wird das ViewModel an eine SwiftUI-View angebunden:

UserListView.swiftswift
import SwiftUI

struct UserListView: View {
    // StateObject: creates and owns the ViewModel
    @StateObject private var viewModel = UserListViewModel()

    var body: some View {
        NavigationStack {
            Group {
                if viewModel.isLoading {
                    // Centered loading indicator
                    ProgressView("Loading...")
                } else if let error = viewModel.errorMessage {
                    // Error view with retry button
                    VStack(spacing: 16) {
                        Text("Error: \(error)")
                            .foregroundStyle(.red)
                        Button("Retry") {
                            viewModel.loadUsers()
                        }
                    }
                } else {
                    // User list
                    List(viewModel.filteredUsers) { user in
                        UserRowView(user: user)
                    }
                }
            }
            .navigationTitle("Users")
            .searchable(text: $viewModel.searchQuery)  // Direct binding
            .onAppear {
                viewModel.loadUsers()  // Load on first appearance
            }
        }
    }
}

struct UserRowView: View {
    let user: User

    var body: some View {
        VStack(alignment: .leading, spacing: 4) {
            Text(user.name)
                .font(.headline)
            Text(user.email)
                .font(.subheadline)
                .foregroundStyle(.secondary)
        }
        .padding(.vertical, 4)
    }
}

Fortgeschrittene Muster

Cancellation und automatische Bereinigung

Das Lifecycle-Management von Subscriptions ist entscheidend, um Memory Leaks zu vermeiden. Das cancellables-Muster mit AnyCancellable sorgt für automatische Bereinigung:

CancellationPatterns.swiftswift
import Combine

final class DataManager {
    // Set of cancellables: automatically cancelled on destruction
    private var cancellables = Set<AnyCancellable>()

    // Individual cancellable for fine-grained control
    private var currentRequest: AnyCancellable?

    func startPolling() {
        // Timer that emits every 5 seconds
        Timer.publish(every: 5, on: .main, in: .common)
            .autoconnect()  // Starts automatically
            .sink { [weak self] _ in
                self?.fetchLatestData()
            }
            .store(in: &cancellables)
    }

    func fetchLatestData() {
        // Cancel the previous request if it exists
        currentRequest?.cancel()

        currentRequest = URLSession.shared
            .dataTaskPublisher(for: URL(string: "https://api.example.com/data")!)
            .map(\.data)
            .decode(type: [String].self, decoder: JSONDecoder())
            .replaceError(with: [])
            .receive(on: DispatchQueue.main)
            .sink { data in
                print("Data received: \(data)")
            }
    }

    deinit {
        // All cancellables are automatically cancelled
        print("DataManager destroyed, subscriptions cancelled")
    }
}

Schedulers für Threading

Schedulers steuern, in welchem Thread Operationen ausgeführt werden. subscribe(on:) für Hintergrundarbeit und receive(on:) für UI-Updates:

SchedulerPatterns.swiftswift
import Combine
import Foundation

var cancellables = Set<AnyCancellable>()

func loadAndProcessData() -> AnyPublisher<ProcessedData, Error> {
    URLSession.shared.dataTaskPublisher(for: apiURL)
        // Perform parsing on a background thread
        .subscribe(on: DispatchQueue.global(qos: .userInitiated))
        .map(\.data)
        .decode(type: RawData.self, decoder: JSONDecoder())
        // Heavy processing on background thread
        .map { rawData in
            // This expensive operation runs in the background
            processData(rawData)
        }
        // Return to main thread for UI
        .receive(on: DispatchQueue.main)
        .eraseToAnyPublisher()
}

Fazit

Combine bietet einen mächtigen, deklarativen Ansatz, um asynchrone Datenströme in iOS-Apps zu beherrschen. Die wichtigsten Punkte:

Publishers emittieren Werte über die Zeit ✅ Subscribers empfangen und verarbeiten diese Werte ✅ Operators transformieren und kombinieren Ströme ✅ AnyCancellable verwaltet den Subscription-Lifecycle ✅ @Published mit SwiftUI erzeugt automatische reaktive Bindings ✅ Schedulers steuern das Threading für optimale Performance

Wer Combine beherrscht, baut robuste, wartbare und reaktive iOS-Apps. Die native Integration mit SwiftUI macht es zu einem unverzichtbaren Werkzeug der modernen iOS-Entwicklung.

Fang an zu üben!

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

Tags

#combine
#swift
#ios
#reactive
#async

Teilen

Verwandte Artikel