Event Pipelines mit Combine

In meinem Artikel zu PayForMe habe ich Apples Framework Combine kurz angerissen. Heute möchte ich etwas tiefer ins Detail gehen und zeigen, wie man damit am Beispiel des Hinzufügens eines neuen Projektes in unserer App komplexere Situationen bewältigen kann.

About Combine

Combine ist ein deklaratives Framework zum asynchronen Event Handling in Swift – man nennt diese Art der Programmierung auch reaktiv. Reactive Programming erlebt zur Zeit sicherlich einen Hype, auch wenn dieses Konzept schon länger bekannt ist.

Combine im speziellen verfolgt dabei folgende Paradigmen:

  • Deklarativ: Anstatt die Veränderung des Zustands (State) zu beschreiben, werden die Auswirkungen des aktuellen Zustands auf das System beschrieben. Wenn sich etwas am Zustand verändert, wird das System automatisch angepasst.
  • Asynchron: Asynchronität war lange ein nicht so schön progammierbares Konzept – Code, der nach Beendigung eines HTTP-Requests bei User Input o. ä. ausgeführt wurde, wird in Swift herkömmlich zum Beispiel in einer “Closure” ausgeführt, die als letzter Parameter in der Methodensignatur übergeben wird. Dadurch kann man schnell in etwas kommen, das JavaScript-Entwickler liebevoll “Callback Hell” nennen, bei der man vor lauter Einrückungen und geschweiften und normalen Klammern schnell die Übersicht verlieren kann.
  • Funktional: Combine greift viele Konzepte der funktionalen Programmierung auf. Operatoren wie map, filter und reduce sind das Handwerkszeug des Combine-Programmierers.

Gabs das nicht schon längst?

Reactive Programming in Swift gibt es unter dem Namen RxSwift schon seit 2015 und ist damit fast so alt wie Swift. Es ist ein nativer Port von ReactiveX in Swift.

Combine hat sehr viel von RxSwift aufgegriffen; wer damit schon vertraut ist hat also einen deutlich einfacheren Einstieg in Combine. Ein Vorteil von Combine ist allerdings, dass es schon sehr gut in andere Standard-Libraries wie URLSession, UIKit, SwiftUI etc. eingebunden ist.

Konzepte von Combine

Combine besteht eigentlich nur aus 3 Elementen:

  1. Publisher: Publisher sind der Startpunkt einer Pipeline; sie senden Events an ihre Subscriber. Ein ganz einfacher Publisher ist z. B. Just, der an jeden Subscriber bei der Subscription ein einzelnes Event sendet. Just(5) sendet zum Beispiel die Zahl 5 einmal an jeden Subscriber und beendet die Pipeline danach wieder.
  2. Subscriber: Subscriber sind der Endpunkt einer Pipeline. Erst wenn sich ein Subscriber auf einen Publisher registriert hat, sendet der Publisher seine Events (an alle Subscriber). Subscriptions werden in der Regel durch assign oder sink ausgeführt. assign schreibt den Output einer Pipeline in eine Variable, sink führt eine Closure mit dem Output der Pipeline als Paramter aus und ist damit eine Brücke zu der “Nicht-Combine-Welt”.
  3. Operatoren: Operatoren sind das Bindeglied zwischen Publishern und Subscribern. Sie verändern die Events in einer Pipeline bevor sie sie weiterreichen. Viele Operatoren in Combine sind an Array-Operatoren angelehnt: Ein map auf ein Array führt eine Funktion auf alle Elemente des Arrays aus, und ein map in Combine führt eine Funktion auf alle Events einer Pipeline aus. Eine Pipeline ist ja im Prinzip auch nur eine zeitlich versetze Sammlung von Elementen.
  4. Subjects: Können wir bei Mayflower eigentlich bis drei zählen? Ja, denn Subjekte sind nur eine Spezialform von Publishern, bei denen man das Senden eines Events manuell triggern kann. mySubject.send(5) lässt zum Beispiel ein Event mit der Zahl 5 als Inhalt abfeuern. Das kann überall im Code getriggert werden, und ist damit eine weitere gute Brücke zur “Nicht-Combine-Welt”.

Eine ganz simple Pipeline wäre z.b.

Just(5)
    .map { "\($0*3)" }
    .sink { print($0) }

Diese Pipeline gibt die Zahl 15 auf der Kommandozeile aus. Erst wird ein Event mit Just(5) gesendet, dann wird die Integer-Zahl mal 3 genommen und in einen String verwandelt (Das $0 steht für den ersten anonymen Parameter und ist eine Kurzform). Und dann wird eine Closure ausgeführt, in der der String ausgegeben wird.

Combine & SwiftUI

Combine ist dabei gut mit SwiftUI integriert. Hier ein Beispiel für eine funktionierende, vollständige SwiftUI-View mit einem ViewModel, das überprüft, ob der User einen zulässigen Chatnamen hat, bevor er ihn dem Chat beitreten lässt:

import Combine
import SwiftUI

struct MyView: View {
    @ObservedObject var viewModel = MyViewModel()
    @State var buttonDisabled = true
    
    var body: some View {
        VStack {
            TextField("Gib deinen Namen ein", text: $viewModel.textInput)
            Button(action: {
                print("Verschicke Namen")
            }) {
                Text("Zum Chat")
            }
            .disabled(buttonDisabled)
            .onReceive(viewModel.verifiedInput) {
                self.buttonDisabled = !$0
            }
        }
    }
}

class MyViewModel: ObservableObject {
    @Published var textInput = ""

    var verifiedInput: AnyPublisher<Bool, Never> {
        $textInput
            .debounce(for: 1, scheduler: RunLoop.main)
            .removeDuplicates()
            .map {
                $0.count >= 3 && $0.first!.isLetter
            }
            .eraseToAnyPublisher()
    }
}

Das ViewModel ist ein ObservableObject, was bedeutet, dass es vom View „observed“ wird und dann Bescheid gibt, wenn sich eine seiner Published Properties ändern.

Es enthält zwei Publisher: textInput und verifiedInput. Während textInput nur einen String mit einem Publisher “wrappt”, ist verifiedInput etwas interessanter. Er wendet auf textInput drei Operatoren an:

  • debounce: debounce verhindert, dass in schnellem Rythmus unnötig viele Events versendet werden. Es wartet einen angegebenen Zeitraum und erst wenn in diesem Zeitraum kein neues Event gesendet wird, schickt es das letzte Event weiter. Wir benutzen es, um keine neuen Events zu bearbeiten, während der Nutzer noch am tippen ist, als Zeitraum haben wir eine Sekunde angegeben.
  • removeDuplicates: Nach dem debouncen kann es vorkommen, dass wieder der gleiche Text im Textfeld steht, z. B. wenn der Nutzer einen falschen Buchstaben hinzugefügt und wieder gelöscht hat. Durch removeDuplicates wird der Rest der Pipeline nur ausgeführt, wenn ein Event mit neuem Inhalt gesendet wird.
  • map: Hier steckt unsere Überprüfung auf zulässige Chatnamen: Es wird überprüft, ob der Username mindestens drei Zeichen hat und das erste davon ein Buchstabe ist. Das Ergebnis wird dann als Boolean ausgegeben.

Danach wird noch ein eraseToAnyPublisher ausgeführt, um den Typ des Publishers von Map zu AnyPublisher zu reduzieren.

Anschließend kann sich die View mithilfe von onReceive auf verifiedInput subscriben und bei einem neuen Event ihren Zustand dem neuen Ergebnis anpassen. Sie verwendet hier den Negations-Operator, da der Button nur bei einem nicht zulässigen Input deaktiviert werden soll. Für textInput benutzt sie ein Binding, mit dem sie den Publisher von textInput verständigen kann, wenn sie den gewrappten Wert ändert.

Use Case

Richtig interessant wird es vor allem, wenn es etwas komplexer wird. In unserem PayForMe-Projekt gab es vor allem eine Stelle, in der wir Combine richtig intensiv eingesetzt haben: beim Hinzufügen eines neuen Cospend-Projektes.

Event Pipelines mit Combine: Das Formular zum Hinzufügen eines neuen Cospend-Projekts.

Dafür muss der User die URL seiner NextCloud, den Namen und das Passwort des Projektes eingeben. Außerdem muss er spezifizieren, ob es sich um ein Cospend oder iHateMoney-Projekt handelt.

Die App soll ihm dann Feedback geben, ob dabei eventuell etwas nicht gepasst hat. Um das zu verbessern, senden wir einen Test-Request an die Nextcloud, um herauszufinden, ob alle Parameter stimmen. Erst wenn dieser Request geklappt hat, wird es dem Nutzer ermöglicht, das Projekt hinzuzufügen.

Die Logik dafür wollen wir natürlich von der UI entkoppeln, deshalb passieren diese Checks im ViewModel.

Subscription zu UI Elementen

Los geht’s mit den Publishern, in die der Input der Textfelder analog zu oben gespeichert wird:

import Combine

class AddProjectModel: ObservableObject {    
    @Published
    var serverAddress = ""
    
    @Published
    var projectName = ""
    
    @Published
    var projectPassword = ""

Dazu kommt eine Variable, in der gespeichert wird, ob es sich um ein Cospend- oder iHateMoney-Projekt handelt:

    @Published
    var projectType = ProjectBackend.cospend

Erstellen des Model Objekts

Der nächste Part fügt für ein iHateMoney-Projekt die URL von iHateMoney hinzu:

    var validatedAddress: AnyPublisher<(ProjectBackend, String?), Never> {
        return Publishers.CombineLatest($projectType, $serverAddress)
            .map {
                type, serverAddress in
                if type == .cospend {
                    return (type, serverAddress)
                } else {
                    return (type, NetworkService.iHateMoneyURLString)
                }
        }
        .eraseToAnyPublisher()
    }

Danach werden die Eingabeparameter getestet und im Erfolgsfall eine Project-Modellklasse generiert:

var validatedInput: AnyPublisher<Project, Never> {
        return Publishers.CombineLatest3(validatedAddress, $projectName, $projectPassword)
            .debounce(for: 1, scheduler: DispatchQueue.main)
            .compactMap { server, name, password in
                if let address = server.1, address.isValidURL && !name.isEmpty && !password.isEmpty {
                    guard let url = URL(string: address) else { return nil }
                    return Project(name: name.lowercased(), password: password, backend: server.0, url: url)
                } else {
                    return nil
                }
        }
        .removeDuplicates()
        .eraseToAnyPublisher()
    }

Mit CombineLatest3 wird der Output von drei Publishern zu einem Publisher kombiniert. debounce und removeDuplicates sind schon von oben bekannt, compactMap führt eine Map-Funktion aus, “verschluckt” allerdings das Ergebnis, falls es nil ist. Dadurch brechen wir ab, falls der Input syntaktisch kein valides Projekt ist (Adresse keine valide URL, Name leer oder Passwort leer).

Validieren mithilfe der API

Damit haben wir aus den vier Inputfeldern ein potenzielles Projekt generiert, das wir jetzt im Netzwerk testen können:

    var validatedServer: AnyPublisher<Int, Never> {
        return Publishers.FlatMap(upstream: validatedInput, maxPublishers: .unlimited) {
            project in
            return NetworkService.shared.testProject(project)
        }
        .removeDuplicates()
        .receive(on: RunLoop.main)
        .eraseToAnyPublisher()
    }

Hier verwenden wir flatMap, denn wir bekommen von NetworkServerice.shared.testProject(project) einen Publisher zurück, und haben damit einen Publisher von einem Publisher. Analog zur Array-Funktion verwandelt flatMap das zu einem einfachen Publisher.

Zum besseren Verständnis blende ich hier die entsprechende Stelle von Networkservice ein:

    func testProject(_ project: Project) -> AnyPublisher<Int, Never> {
        let request = buildURLRequest("members", params: [:], project: project)
        return URLSession.shared.dataTaskPublisher(for: request)
        .map { data, response -> Int in
            guard let httpResponse = response as? HTTPURLResponse else { print("Network Error"); return -1}
            return httpResponse.statusCode
        }
        .replaceError(with: -1)
        .eraseToAnyPublisher()
    }

Statt einer computed Variable wie die bereits gezeigten Publisher, ist testProject eine Funktion, da der Networkservice Informationen zu dem Cospend-Projekt braucht, zu dem er den Networkrequest senden soll.

Bei der Einführung von Combine wurden andere Frameworks erweitert, um besser mir Combine zu integrieren, z. B. wie bei URLSession.dataTaskPublisher, das sehr ähnlich zu URLSession.dataTask funktioniert, allerdings einen Publisher zurückgibt, der bei Subscription den Networkrequest ausführt, das Ergebnis beim Subscriber abgibt und danach die Pipeline beendet.

Danach führen wir ein map aus, im dem die HTTP-Response unwrapped wird, um den Statuscode zu erhalten.

Der Return-Typ von testProject ist ein AnyPublisher<Int, Never>. Das Never bedeutet, dass er niemals failen kann … allerdings kann ein DataTaskPublisher durchaus failen. Deshalb fügen wir ein replaceError an, wodurch in einem Fehlerfall einfach -1 zurückgegeben wird, was eine simple Form des Fehlerhandlings ist.

Danach wird mit receive(on: ) das Event dispatcht und beim nächsten Cycle auf dem MainLoop ausgeführt, was wichtig ist, um mit UI-Updates nicht in Probleme zu geraten. Vollkommen ohne Closures oder sich um Threading, eine DispatchQueue oder ähnliches kümmern zu müssen!

Was jetzt noch wichtig wäre: dem User anzeigen, was falsch gelaufen ist, falls es zu einem Fehler kam.

    var errorTextPublisher: AnyPublisher<String, Never> {
        return Publishers.Map(upstream: validatedServer) {
            statusCode in
            if statusCode != 200 {
                switch statusCode {
                    case -1:
                        return "Could not find server"
                    case 401:
                        return "Unauthorized: Wrong project id/pw"
                    default:
                        return "Server error: \(statusCode)"
                }
            }
            return ""
        }.eraseToAnyPublisher()
    }

Dieser Publisher wird von der UI subscribed, wodurch die Fehlermeldung direkt angezeigt wird. Würden wir statt String einen LocalizedStringKey zurückgeben, könnten wir ihn automatisiert in andere Sprachen übersetzen. Aber das ist ein Thema für ein anderes Mal.

Progress Indicator

Zu einer guten UI gehört, dass dem User signalisiert wird, wenn gerade Netzwerkkommunikation stattfindet und er auf das Ergebnis warten muss. In der UI zeigen wir deshalb den Status an, wobei wir nach dem Anfangszustand drei mögliche Zustände haben:

enum ValidationState {
    case inProgress
    case success
    case failure
}

Um die UI bestmöglich von der Logik zu entkoppeln, wollen wir am liebsten einen Publisher, der immer den neuesten der drei Zustände durchgibt. Dafür wollen wir uns sowohl auf validatedInput als auch auf validatedServer subscriben; dafür haben wir bereits CombineLatest verwendet. Dieser Publisher funktioniert aber im jetzigen Fall nicht, da uns CombineLatest nur die jeweils letzen Events der kombinierten Publisher liefert, allerdings nicht die Information, welches der Events als letztes kam.

Abhilfe schafft der Merge-Publisher. Der erfordert jedoch, dass die Streams den gleichen Typen haben:

    var validationProgress: AnyPublisher<ValidationState, Never> {
        return Publishers.Merge(inputProgress, serverProgress)
            .eraseToAnyPublisher()
    }

Dafür haben wir zwei weitere kleine Publisher erstellt, die den entsprechenden Typ mit einem map verwandeln:

    private var inputProgress: AnyPublisher<ValidationState, Never> {
        return Publishers.Map(upstream: validatedInput) {
            input in
            return ValidationState.inProgress
        }
        .eraseToAnyPublisher()
    }
    
    private var serverProgress: AnyPublisher<ValidationState, Never> {
        return Publishers.Map(upstream: validatedServer) {
            server in
            return server == 200 ? ValidationState.success : ValidationState.failure
        }
        .eraseToAnyPublisher()
    }

Und das war’s! Damit haben wir Validation der Eingaben des Nutzers, inklusive Erstellung der Model-Klasse für das Projekt und Validation durch einen Netzwerk-Request, der abfragt, ob das entsprechende Projekt auf dem Server existiert.

… und der ganze Rest

Literatur

Die umfassendste Dokumentation (englischsprachig) zu Combine ist das Buch Using Combine von Joseph Heck, kostenlos online verfügbar oder als eBook zu kaufen (wenn man den Autor unterstützen will). Die Doku von Apple ist okay, aber teilweise etwas dürftig. Besser fährt man mit den WWDC-Sessions. Allerdings aufgepasst: Es haben sich nach der Beta ein paar Details geändert und ein paar Komponenten heißen jetzt anders.

Debugging

Wer sich den Source Code des Projekts anschaut, der sieht einige in diesem Artikel entfernte .lane(„Text“)-Operatoren. Damit kann man mit dem Tool Timelane wunderbar seine Event-Streams debuggen und erkennen, welche Events wann passieren.

… oder direkt mitentwickeln

Im Source Code sieht man auch, dass wir gerade dabei sind, die Funktionalität hinzuzufügen, ein Projekt direkt über die App zu erstellen. Das gestaltet sich allerdings etwas tricky …

Wenn du also Lust hast, an einem spannenden SwiftUI/Combine-Projekt mitzuentwicklen und dafür zu sorgen, dass sich die Open-Source-Welt in iOS sich etwas mehr ausdehnt, schau gerne mal bei den Issues vorbei, erstelle neue oder hilf als Entwickler mit, neue Features hinzuzufügen und Bugs zu entfernen!

Für neue Blogupdates anmelden:


Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert.