23. Juli 2019

Einführung in Reactive Programming mit dem Combine Framework

Mit dem Combine Framework stellt Apple in iOS 13 ein System-Framework zur reaktiven Programmierung bereit. Dies ist eine Abstraktion, die auf der Bereitstellung von Werten und der Verknüpfung von Werten zu „Werteflüssen“ basiert.

Das elementarste Konzept in Combine sind Publisher und Subscriber. Publisher veröffentlichen Werte, die von Subscribern abonniert werden können.

Auf dieser Grundlage können die Werte mit einer Vielzahl von Operatoren, wie zum Beispiel map, verarbeitet werden. Damit wird ein Konstrukt aufgebaut, das man sich wie ein „Rohrsystem“ vorstellen kann, durch das die Werte fließen. Am Ende stehen dabei typischerweise Senken (sink), die die ermittelten Werte final verwenden.

Das folgende Beispielprojekt Clock illustriert die grundlegenden Konzepte am Beispiel der Anzeige der Systemzeit:

Beispielprojekt: Clock

Dabei wird mittels der Combine-Integration in Foundation ein Publisher erstellt, der die aktuelle Systemzeit veröffentlicht. Diese wird mit map verarbeitet und mit sink in einem UILabel angezeigt:

Combine Beispiel: Systemtime Clock

Beispielprojekt: Clock

  1. Lade den Start-Stand von dem Beispielprojekt Clock und öffne den TimeViewController.

  2. Implementiere die Funktion systemTimePublisher so, das ein Timer-Publisher erstellt wird mittels Timer.publish. Dieser muss noch aktiviert werden, dies geschieht durch den Aufruf von autoconnect(). Rufe eraseToAnyPublisher auf, um die Typsignatur zu AnyPublisher<Date, Never> zu bereinigen (ein Publisher, der ein Datumswert veröffentlicht und keinen Fehlerfall kennt):

    func systemTimePublisher() -> AnyPublisher<Date, Never> {
        Timer.publish(every: TimeInterval(1), on: .main, in: .default)
        .autoconnect()
        .eraseToAnyPublisher()
    }
  3. Verwende in viewWillAppear die Funktion systemTimePublisher, formatiere den Datumswert mit map (ein DateFormatter ist im Projekt bereits vorbereitet: DateFormats.timeOnlyFormatter):

    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)
    
        systemTimePublisher()
        .map { date in DateFormats.timeOnlyFormatter.string(from: date) }
    }
  4. Verwende die Methode sink um den publizierten Wert in dem timeLabel anzuzeigen. [weak self] sollte bei dieser Variante sicherheitshalber verwendet werden, um keinen Retain Cycle zu erzeugen:

    systemTimePublisher()
        .map { date in DateFormats.timeOnlyFormatter.string(from: date) }
        .sink { [weak self] (text) in self?.timeLabel.text = text }
  5. Starte die App und prüfe, dass die Zeit angezeigt wird.

  6. Es fehlt noch das Lösen des Werteflusses, wenn dieser nicht mehr benötigt wird, z.B. wenn der Controller gerade nicht sichtbar ist. Deklariere dazu ein Set<AnyCancellable> um mit der store-Methode aufgebaute Verbindungen abzulegen. Löse diese mit der cancel-Methode im viewWillDisappear:

    class TimeViewController: UIViewController {
    
        // ...
        
        var connections = Set<AnyCancellable>()
    
        override func viewWillAppear(_ animated: Bool) {
            super.viewWillAppear(animated)
    
            systemTimePublisher()
                .map { /* ... */ }
                .sink { /* ... */ }
                .store(in: &self.connections)
        }
    
        override func viewWillDisappear(_ animated: Bool) {
            super.viewWillDisappear(animated)
    
            self.connections.forEach { $0.cancel() }
    self.connections.removeAll()
        }
    
    }
  7. Prüfe mit einem debugPrint im sink-Block, das der Datumswert nicht mehr aktualisiert wird, wenn der Controller nicht sichtbar ist.

  8. Verwende alternativ zu sink eine Zuweisung via assign (Key-Value-Assignment):

    systemTimePublisher()
        .map { date in DateFormats.timeOnlyFormatter.string(from: date) }
        .assign(to: \.text, on: self.timeLabel)