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:
- 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. - 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
odersink
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”. - 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 einmap
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. - 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. DurchremoveDuplicates
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.
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!
Schreibe einen Kommentar