Model-Logik in SwiftUI-Views

SwiftUI geht – verglichen mit UIKit und AppKit – einen gänzlich anderen Weg, was die Erstellung grafischer Oberflächen betrifft. Einen zentralen Unterschied findet man bereits in der zugrundeliegenden Architektur: Wo UIKit und AppKit auf Controller zurückgreifen, die Views und die zugehörige Model-Logik miteinander verbinden, fehlt in SwiftUI diese Komponente gänzlich. Controller sind kein Teil von SwiftUI und architektonisch auch gar nicht notwendig. Denn Views erhalten direkten Zugriff auf alle Model-Informationen, die sie benötigen, und führen auch Manipulationen direkt am Model durch.

Diese Vermischung von Model und View birgt aber eine Gefahr, auf die ich in diesem Artikel näher eingehen möchte. Sie kann dafür sorgen, dass zu viel Model-Logik direkt in die Views wandert anstatt sauber in einer separaten Stelle ausgelagert zu werden.

Solche Model-Logik innerhalb von Views hat unter anderem die folgenden Nachteile:

  • Die Model-Logik ist kaum bis gar nicht testbar (beispielsweise via Unit-Tests).
  • Die View wird mit Logik aufgebläht, die für die reine Darstellung irrelevant ist (und somit eigentlich in der View nichts verloren hat).
  • Model-Logik verteilt sich über mehrere verschiedene Views und es fehlt somit eine eindeutige Komponente, in der einmalig die jeweils passende Model-Logik untergebracht ist.

Um diese Problematik ein wenig zu verdeutlichen, betrachten wir nachfolgend ein kleines Beispiel. Listing 1 zeigt eine simple View-Struktur auf Basis eines Models namens Person, über das sich Personen mittels Vor- und Nachname abbilden lassen. Die ContentView definiert eine State-Property, die ein Array an Person-Instanzen enthält. Dieses Array wird via Binding an die PersonsList-View weitergereicht.

PersonsList stellt das Herzstück für unsere Betrachtungen dar. Die View selbst zeigt schlicht alle Personen mit ihrem vollen Namen in einer Liste an und ermöglicht es mithilfe zweier Textfelder und eines Buttons, neue Personen zu erstellen und dem Array hinzuzufügen. Der Button zum Hinzufügen einer neuen Person ist nur dann aktiv, wenn sowohl ein Vor- als auch ein Nachname eingegebenen wurden.

// Listing 1: Ausgangslage
struct Person: Identifiable {
    var id = UUID()
    var firstName: String
    var lastName: String
}

struct ContentView: View {
    @State private var persons = [
        Person(firstName: "Thomas", lastName: "Sillmann"),
        Person(firstName: "Max", lastName: "Mustermann")
    ]
    
    var body: some View {
        PersonsList(persons: $persons)
    }
}

struct PersonsList: View {
    @Binding var persons: [Person]
    
    @State private var newPersonFirstName = ""
    
    @State private var newPersonLastName = ""
    
    var body: some View {
        VStack {
            List(persons) { person in
                // 01: Model-Logik
                Text("\(person.firstName) \(person.lastName)")
            }
            HStack {
                Group {
                    TextField("First name", text: $newPersonFirstName)
                    TextField("Last name", text: $newPersonLastName)
                }
                .textFieldStyle(.roundedBorder)
                Button {
                    // 02: View-Logik
                    let newPerson = Person(firstName: newPersonFirstName, lastName: newPersonLastName)
                    persons.append(newPerson)
                    newPersonFirstName = ""
                    newPersonLastName = ""
                } label: {
                    Image(systemName: "plus.circle")
                }
                // 03: View-Logik
                .disabled(newPersonFirstName.isEmpty || newPersonLastName.isEmpty)
            }
            .padding()
        }
    }
}

Betrachtet diese Ausgangslage in Ruhe. Die für uns relevanten Code-Stellen sind mit Kommentaren versehen und verweisen auf Bereiche, bei der die reine View-Präsentation verlassen wird und Logik zum Einsatz kommt.

Werfen wir einmal einen genauen Blick auf diese Stellen:

  • 01: Model-Logik: Hier wird eine Text-View erzeugt, deren Inhalt sich aus dem Vor- und Nachnamen der zugehörigen Person zusammensetzt. Diese Logik kann man als Teil des Person-Models betrachten. Person könnte uns schlicht eine passende Funktion bereitstellen, um genau ein solches Ergebnis direkt aus einer Person-Instanz auszulesen. Dann ließe sich diese Logik auch beliebig wiederverwenden.
  • 02: View-Logik: Die Action des Buttons zum Hinzufügen einer neuen Person. Auf Basis zweier State-Properties (die mittels Binding an die Textfelder zur Eingabe von Vor- und Nachname gekoppelt sind) wird hier eine neue Person-Instanz erzeugt und dem persons-Array hinzugefügt. Im Anschluss werden noch die Werte der beiden State-Properties bereinigt, um eine neue Eingabe zu ermöglichen. Diese Logik ist View-spezifisch.
  • 03: View-Logik: Auch diese Logik ist View-spezifisch. Sie steuert, ob der Hinzufügen-Button aktiv ist oder nicht. Dazu werden die Werte der beiden State-Properties überprüft. Ist eine von beiden leer (sprich Vor-oder Nachname ist nicht gesetzt), kann der Button auch nicht betätigt werden.

Bereits in diesem simplen Beispiel zeigt sich eine durchaus komplexe Mischung aus Model- und View-Logik, die den reinen View-Aufbau von PersonsList stört. Schöner ist es, solche Funktionalitäten auszulagern.

Schritt 1: Erste Bereinigungen

Wie das in diesem Fall aussehen kann, zeigt Listing 2. Darin wandert zunächst die Model-Logik zum Zusammensetzen des vollen Namens in die Person-Structure. Die neue Computed Property fullName liefert nun die gewünschte Information zurück und kann problemlos von jeder Person-Instanz genutzt werden. Auch das Testen dieser Funktionalität ist jetzt ohne Probleme möglich, es braucht lediglich eine Person-Instanz.

Die View-Logik wandert zunächst in eine Extension der View PersonsList. Die neue Methode addNewPerson() dient zum Hinzufügen einer neuen Person und wird über die Button-Action aufgerufen. Die Computed Property addButtonIsDisabled gibt Aufschluss darüber, ob der Hinzufügen-Button aktiv ist oder nicht. Die jeweilige Logik für die Methode und die Computed Property entspricht 1:1 der vorherigen Implementierung direkt innerhalb der View.

// Listing 2: Auslagerung der Logik in separate Methoden und Properties
struct Person: Identifiable {
    var id = UUID()
    var firstName: String
    var lastName: String
    
    // 01: Model-Logik
    var fullName: String {
        "\(firstName) \(lastName)"
    }
}

struct PersonsList: View {
    @Binding var persons: [Person]
    
    @State private var newPersonFirstName = ""
    
    @State private var newPersonLastName = ""
    
    var body: some View {
        VStack {
            List(persons) { person in
                // 01: Model-Logik
                Text(person.fullName)
            }
            HStack {
                Group {
                    TextField("First name", text: $newPersonFirstName)
                    TextField("Last name", text: $newPersonLastName)
                }
                .textFieldStyle(.roundedBorder)
                Button {
                    // 02: View-Logik
                    addNewPerson()
                } label: {
                    Image(systemName: "plus.circle")
                }
                // 03: View-Logik
                .disabled(addButtonIsDisabled)
            }
            .padding()
        }
    }
}

extension PersonsList {
    // 02: View-Logik
    private func addNewPerson() {
        let newPerson = Person(firstName: newPersonFirstName, lastName: newPersonLastName)
        persons.append(newPerson)
        newPersonFirstName = ""
        newPersonLastName = ""
    }
    
    // 03: View-Logik
    private var addButtonIsDisabled: Bool {
        newPersonFirstName.isEmpty || newPersonLastName.isEmpty
    }
}

Bereits dieser kleine Umbau macht den Code deutlich besser lesbar. Die View-Struktur von PersonsList ist klar erkennbar und jegliche Logik liegt separat an passender Stelle.

Perfekt ist aber auch das noch nicht, und das hängt ausschließlich mit der View-Logik innerhalb der PersonsList-Extension zusammen. Denn gut testbar sind diese Funktionen weiterhin nicht. Dazu müsste eine PersonsList-Instanz erzeugt und auf der die jeweilige Logik aufgerufen werden. Views eignen sich aber generell nicht unbedingt für Unit-Tests. Was also tun?

Schritt 2: Einsatz des View-Models

Hier bietet es sich an, die View um ein eigenes Model – ein sogenanntes View-Model – zu ergänzen. Dieses Model enthält all die Logik, die speziell für die Darstellung und Verarbeitung einer View benötigt wird.

In Listing 3 erfolgt die Implementierung eines solchen View-Models mithilfe einer neuen Klasse. Diese trägt passenderweise den Namen ViewModel und wird als Nested Type innerhalb von PersonsList angelegt. So ließe sich analog für jede View ein passendes View-Model erzeugen.

Die ViewModel-Klasse ist konform zum ObservableObject-Protokoll. Das ist wichtig, damit Änderungen in der Logik des View-Models auch zu einer Aktualisierung der View führen. Das View-Model wird sodann als StateObject in PersonsList eingebunden. In diesem Zuge wandern auch die bisherigen State-Properties newPersonFirstName und newPersonLastName als Published-Properties in das View-Model. Die View enthält dann nur noch die Eigenschaften (neben dem View-Model), die für die Initialisierung wichtig sind (im Falle von PersonsList also das persons-Binding).

Im nächsten Schritt erhält das View-Model noch die Logik bezüglich der Button-Action und des Aktiv-Status des Hinzufügen-Buttons. Hierbei stößt man auf ein Problem: Die addNewPerson()-Methode benötigt das persons-Binding, um die neue Person-Instanz dem Array hinzufügen zu können. Dieses Binding ist dem View-Model aber nicht bekannt.

Das ist aber kein Problem, schließlich kennt die zugrundeliegende View jene Information. Entsprechend erweitert man die Methode um einen Parameter, über den man das persons-Binding übergeben kann. Diese neue Variante trägt den Namen addNewPerson(to:).

// Listing 3: Umsetzung und Einsatz eines View-Models
struct PersonsList: View {
    @Binding var persons: [Person]
    
    // 04: Einsatz eines neuen View-Models
    @StateObject private var viewModel = ViewModel()
    
    var body: some View {
        VStack {
            List(persons) { person in
                // 01: Model-Logik
                Text(person.fullName)
            }
            HStack {
                Group {
                    // 05: Zugriff auf die Properties über das View-Model
                    TextField("First name", text: $viewModel.newPersonFirstName)
                    TextField("Last name", text: $viewModel.newPersonLastName)
                }
                .textFieldStyle(.roundedBorder)
                Button {
                    // 02: View-Logik (via View-Model)
                    viewModel.addNewPerson(to: $persons)
                } label: {
                    Image(systemName: "plus.circle")
                }
                // 03: View-Logik (via View-Model)
                .disabled(viewModel.addButtonIsDisabled)
            }
            .padding()
        }
    }
}

extension PersonsList {
    // 04: Einsatz eines neuen View-Models auf Basis von PersonsList
    class ViewModel: ObservableObject {
        // 05: Umzug der State-Properties ins View-Model
        @Published var newPersonFirstName = ""
        
        @Published var newPersonLastName = ""
        
        // 02: View-Logik (mit Parameter aus View)
        func addNewPerson(to persons: Binding<[Person]>) {
            let newPerson = Person(firstName: newPersonFirstName, lastName: newPersonLastName)
            persons.wrappedValue.append(newPerson)
            newPersonFirstName = ""
            newPersonLastName = ""
        }
        
        // 03: View-Logik
        var addButtonIsDisabled: Bool {
            newPersonFirstName.isEmpty || newPersonLastName.isEmpty
        }
    }
}

Durch Einsatz des View-Models und Wegfall der State-Properties wurde PersonsList noch einmal weiter entschlackt. Zusätzlich bringt das neue View-Model einen weiteren Vorteil mit sich: Es ist testbar! Es können einfach mittels Aufruf von PersonsList.ViewModel() Instanzen davon erzeugt und die gegebenen Funktionen getestet werden. Da die View ausschließlich auf jene Funktionen innerhalb des View-Models zugreift, lässt sich so das korrekte Verhalten der View sicherstellen.

Braucht es im Laufe der Zeit weitere Eigenschaften oder Funktionen, lassen die sich auch ganz einfach innerhalb des View-Models ergänzen und aus der View heraus ansprechen. Solch ein View-Model als Nested Type lässt sich für jede View individuell erzeugen.

Fazit

Um Views in SwiftUI sauber und übersichtlich zu halten, ist es wichtig, die Logik an geeigneten Stellen auszulagern. Wo es sinnvoll ist, sollte man hierbei zunächst die Model-Typen um passende Funktionen ergänzen (wie im gezeigten Beispiel eine Computed Property zur Ausgabe des vollen Namens einer Person-Instanz). Solche Funktionen lassen sich dann an jeder Stelle nutzen, an der das zugehörige Model zum Einsatz kommt.

Logik, die rein View-spezifisch ist, bietet sich in einem separaten View-Model an. Zwar lässt sich auch mit Funktionen innerhalb von Views arbeiten, die sind aber unter Umständen nicht gut testbar. Ein View-Model als separate Instanz ist hingegen vollkommen unabhängig. So erhält man eine klare Trennung zwischen der Darstellung der View und deren Funktionsweise.

Euer Thomas


Kommentare

2 Antworten zu „Model-Logik in SwiftUI-Views“

  1. Avatar von Marcel Jäger
    Marcel Jäger

    Nach deinem neuen Artikel über die WWDC 23 kam mir eine Frage zu diesem Thema.
    Macht ein ViewModel mit SwiftData Sinn?
    Und wie sieht für dich die Zukunft von einem Pattern wie diesem aus?

    1. Ich kann es noch nicht sicher sagen, aber ich schätze, grundsätzlich ist solch ein Pattern weiterhin sinnvoll. Manches verlagert sich vielleicht direkt in die View (wie durch den Einsatz von Query mit SwiftData), aber die eigentliche View-Logik wird man weiterhin mit solch einem Pattern schön auslagern können.

Schreibe einen Kommentar

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