Swift-Tutorial: Eine Einführung in das MVVM-Entwurfsmuster

Veröffentlicht: 2022-03-11

Sie starten also ein neues iOS-Projekt, haben vom Designer alle erforderlichen .pdf und .sketch Dokumente erhalten und haben bereits eine Vorstellung davon, wie Sie diese neue App erstellen werden.

Sie beginnen mit der Übertragung von UI-Bildschirmen aus den Skizzen des Designers in Ihre ViewController .swift -, .xib - und .storyboard -Dateien.

UITextField hier, UITableView dort, noch ein paar UILabels und eine Prise UIButtons . IBOutlets und IBActions sind ebenfalls enthalten. Alles gut, wir sind immer noch in der UI-Zone.

Es ist jedoch an der Zeit, etwas mit all diesen UI-Elementen zu tun; UIButtons erhalten Fingerberührungen, UILabels und UITableViews benötigen jemanden, der ihnen sagt, was sie in welchem ​​Format anzeigen sollen.

Plötzlich haben Sie mehr als 3.000 Codezeilen.

3.000 Zeilen Swift-Code

Sie endeten mit einer Menge Spaghetti-Code.

Der erste Schritt zur Lösung dieses Problems besteht darin, das Entwurfsmuster Model-View-Controller (MVC) anzuwenden. Dieses Muster hat jedoch seine eigenen Probleme. Da kommt das Model-View-ViewModel (MVVM)-Entwurfsmuster, das den Tag rettet.

Umgang mit dem Spaghetti-Code

In kürzester Zeit ist Ihr Start ViewController zu intelligent und zu massiv geworden.

Netzwerkcode, Datenanalysecode, Datenanpassungscode für die UI-Präsentation, App-Statusbenachrichtigungen, UI-Statusänderungen. All dieser Code ist in if -ology einer einzelnen Datei eingeschlossen, die nicht wiederverwendet werden kann und nur in dieses Projekt passen würde.

Ihr ViewController -Code ist zum berüchtigten Spaghetti-Code geworden.

Wie ist das passiert?

Der wahrscheinliche Grund ist ungefähr so:

Sie wollten unbedingt sehen, wie sich die Back-End-Daten in UITableView verhalten, also haben Sie ein paar Zeilen Netzwerkcode in eine temporäre Methode des ViewControllers ViewController , nur um diese .json -Datei aus dem Netzwerk abzurufen. Als Nächstes mussten Sie die Daten in dieser .json , also haben Sie eine weitere temporäre Methode geschrieben, um dies zu erreichen. Oder, noch schlimmer, Sie haben das auf die gleiche Weise getan.

Der ViewController wuchs weiter, als der Benutzerautorisierungscode kam. Dann begannen sich die Datenformate zu ändern, die Benutzeroberfläche entwickelte sich weiter und benötigte einige radikale Änderungen, und Sie fügten einfach immer mehr ifs zu einer bereits massiven if if hinzu.

Aber wie kommt es, dass der UIViewController außer Kontrolle geraten ist?

Der UIViewController ist der logische Ort, um mit der Arbeit an Ihrem UI-Code zu beginnen. Es stellt den physischen Bildschirm dar, den Sie sehen, wenn Sie eine App mit Ihrem iOS-Gerät verwenden. Sogar Apple verwendet UIViewControllers in seiner Hauptsystem-App, wenn es zwischen verschiedenen Apps und seinen animierten UIs wechselt.

Apple stützt seine UI-Abstraktion innerhalb des UIViewController , da es den Kern des iOS-UI-Codes und einen Teil des MVC -Entwurfsmusters bildet.

Siehe auch: Die 10 häufigsten Fehler, von denen iOS-Entwickler nicht wissen, dass sie sie machen

Upgrade auf das MVC-Entwurfsmuster

MVC-Entwurfsmuster

Im MVC-Entwurfsmuster soll View inaktiv sein und nur bei Bedarf aufbereitete Daten anzeigen.

Der Controller sollte an den Modelldaten arbeiten, um sie für die Views vorzubereiten, die diese Daten dann anzeigen.

View ist auch dafür verantwortlich, den Controller über alle Aktionen, wie z. B. Benutzerberührungen, zu benachrichtigen.

Wie bereits erwähnt, ist UIViewController normalerweise der Ausgangspunkt beim Erstellen eines UI-Bildschirms. Beachten Sie, dass es in seinem Namen sowohl die „Ansicht“ als auch den „Controller“ enthält. Das bedeutet, dass es „die Sicht kontrolliert“. Das bedeutet nicht, dass sowohl „Controller“- als auch „View“-Code hineingehen sollten.

Diese Mischung aus Ansichts- und Controller-Code tritt häufig auf, wenn Sie IBOutlets kleiner Unteransichten in den UIViewController und diese Unteransichten direkt vom UIViewController aus manipulieren. Stattdessen hätten Sie diesen Code in eine benutzerdefinierte UIView Unterklasse packen sollen.

Es ist leicht zu erkennen, dass dies dazu führen kann, dass sich die Codepfade von View und Controller kreuzen.

MVVM zur Rettung

Hier kommt das MVVM -Muster ins Spiel.

Da UIViewController ein Controller im MVC-Muster sein soll und bereits viel mit den Views macht, können wir sie in die View unseres neuen Musters - MVVM - zusammenführen.

MVVM-Entwurfsmuster

Im MVVM-Entwurfsmuster ist das Modell dasselbe wie im MVC-Muster. Es repräsentiert einfache Daten.

View wird durch die UIView oder UIViewController Objekte zusammen mit ihren .xib und .storyboard Dateien dargestellt, die nur vorbereitete Daten anzeigen sollten. (Wir möchten beispielsweise keinen NSDateFormatter -Code in der Ansicht haben.)

Nur eine einfache, formatierte Zeichenfolge, die aus dem ViewModel stammt.

ViewModel verbirgt den gesamten asynchronen Netzwerkcode, Datenvorbereitungscode für die visuelle Präsentation und Codeüberwachung für Modelländerungen . All dies ist hinter einer gut definierten API verborgen, die so modelliert ist, dass sie zu dieser bestimmten View passt.

Einer der Vorteile der Verwendung von MVVM ist das Testen. Da ViewModel ein reines NSObject (oder beispielsweise eine struct ) ist und nicht mit dem UIKit -Code gekoppelt ist, können Sie es einfacher in Ihren Komponententests testen, ohne dass es den UI-Code beeinflusst.

Jetzt ist die Ansicht ( UIViewController / UIView ) viel einfacher geworden, während ViewModel als Bindeglied zwischen Model und View fungiert.

Anwenden von MVVM in Swift

MVVM in Swift

Um Ihnen MVVM in Aktion zu zeigen, können Sie das für dieses Tutorial erstellte Xcode-Beispielprojekt hier herunterladen und untersuchen. Dieses Projekt verwendet Swift 3 und Xcode 8.1.

Es gibt zwei Versionen des Projekts: Starter und Finished.

Die fertige Version ist eine fertige Minianwendung, bei der Starter dasselbe Projekt ist, jedoch ohne die implementierten Methoden und Objekte.

Zuerst schlage ich vor, dass Sie das Starter- Projekt herunterladen und diesem Tutorial folgen. Wenn Sie später eine Kurzreferenz des Projekts benötigen, laden Sie das fertige Projekt herunter.

Einführung in das Tutorial-Projekt

Das Tutorial-Projekt ist eine Basketballanwendung zum Verfolgen von Spieleraktionen während des Spiels.

Basketball-Anwendung

Es wird für die schnelle Verfolgung von Benutzerbewegungen und der Gesamtpunktzahl in einem Pickup-Spiel verwendet.

Zwei Mannschaften spielen, bis 15 Punkte (mit mindestens zwei Punkten Unterschied) erreicht sind. Jeder Spieler kann einen bis zwei Punkte erzielen, und jeder Spieler kann helfen, abprallen und foulen.

Die Projekthierarchie sieht folgendermaßen aus:

Projekthierarchie

Modell

  • Game.swift
    • Enthält Spiellogik, verfolgt die Gesamtpunktzahl, verfolgt die Bewegungen jedes Spielers.
  • Team.swift
    • Enthält Teamname und Spielerliste (drei Spieler in jedem Team).
  • Player.swift
    • Ein einzelner Spieler mit einem Namen.

Sicht

  • HomeViewController.swift
    • Root-View-Controller, der den GameScoreboardEditorViewController
  • GameScoreboardEditorViewController.swift
    • Ergänzt mit der Interface Builder-Ansicht in Main.storyboard .
    • Interessanter Bildschirm für dieses Tutorial.
  • PlayerScoreboardMoveEditorView.swift
    • Ergänzt mit der Interface Builder-Ansicht in PlayerScoreboardMoveEditorView.xib
    • Die Unteransicht der obigen Ansicht verwendet ebenfalls das MVVM-Entwurfsmuster.

ViewModel

  • ViewModel -Gruppe ist leer, das werden Sie in diesem Tutorial erstellen.

Das heruntergeladene Xcode-Projekt enthält bereits Platzhalter für die View -Objekte ( UIView und UIViewController ). Das Projekt enthält auch einige benutzerdefinierte Objekte, die erstellt wurden, um eine der Möglichkeiten zu demonstrieren, wie Daten für die ViewModel -Objekte bereitgestellt werden (Gruppe Services ).

Die Gruppe Extensions enthält nützliche Erweiterungen für den UI-Code, die nicht Gegenstand dieses Tutorials sind und selbsterklärend sind.

Wenn Sie die App an dieser Stelle ausführen, wird die fertige Benutzeroberfläche angezeigt, aber es passiert nichts, wenn ein Benutzer die Schaltflächen drückt.

Das liegt daran, dass Sie nur Views und IBActions erstellt haben, ohne sie mit der App-Logik zu verbinden und ohne UI-Elemente mit den Daten aus dem Modell (aus dem Game Objekt, wie wir später lernen werden) zu füllen.

Ansicht und Modell mit ViewModel verbinden

Im MVVM-Entwurfsmuster sollte View nichts über das Modell wissen. Das einzige, was View weiß, ist, wie man mit einem ViewModel arbeitet.

Beginnen Sie damit, Ihre Ansicht zu untersuchen.

In der Datei GameScoreboardEditorViewController.swift ist die Methode fillUI an dieser Stelle leer. Dies ist der Ort, an dem Sie die Benutzeroberfläche mit Daten füllen möchten. Um dies zu erreichen, müssen Sie Daten für den ViewController . Sie tun dies mit einem ViewModel-Objekt.

Erstellen Sie zunächst ein ViewModel-Objekt, das alle erforderlichen Daten für diesen ViewController enthält.

Gehen Sie zur ViewModel Xcode-Projektgruppe, die leer sein wird, erstellen Sie eine GameScoreboardEditorViewModel.swift -Datei und machen Sie daraus ein Protokoll.

 import Foundation protocol GameScoreboardEditorViewModel { var homeTeam: String { get } var awayTeam: String { get } var time: String { get } var score: String { get } var isFinished: Bool { get } var isPaused: Bool { get } func togglePause(); }

Die Verwendung von Protokollen wie diesem hält die Sache schön und sauber; Sie müssen nur die Daten definieren, die Sie verwenden werden.

Erstellen Sie als Nächstes eine Implementierung für dieses Protokoll.

Erstellen Sie eine neue Datei namens GameScoreboardEditorViewModelFromGame.swift und machen Sie dieses Objekt zu einer Unterklasse von NSObject .

Passen Sie es außerdem dem GameScoreboardEditorViewModel Protokoll an:

 import Foundation class GameScoreboardEditorViewModelFromGame: NSObject, GameScoreboardEditorViewModel { let game: Game struct Formatter { static let durationFormatter: DateComponentsFormatter = { let dateFormatter = DateComponentsFormatter() dateFormatter.unitsStyle = .positional return dateFormatter }() } // MARK: GameScoreboardEditorViewModel protocol var homeTeam: String var awayTeam: String var time: String var score: String var isFinished: Bool var isPaused: Bool func togglePause() { if isPaused { startTimer() } else { pauseTimer() } self.isPaused = !isPaused } // MARK: Init init(withGame game: Game) { self.game = game self.homeTeam = game.homeTeam.name self.awayTeam = game.awayTeam.name self.time = GameScoreboardEditorViewModelFromGame.timeRemainingPretty(for: game) self.score = GameScoreboardEditorViewModelFromGame.scorePretty(for: game) self.isFinished = game.isFinished self.isPaused = true } // MARK: Private fileprivate var gameTimer: Timer? fileprivate func startTimer() { let interval: TimeInterval = 0.001 gameTimer = Timer.schedule(repeatInterval: interval) { timer in self.game.time += interval self.time = GameScoreboardEditorViewModelFromGame.timeRemainingPretty(for: self.game) } } fileprivate func pauseTimer() { gameTimer?.invalidate() gameTimer = nil } // MARK: String Utils fileprivate static func timeFormatted(totalMillis: Int) -> String { let millis: Int = totalMillis % 1000 / 100 // "/ 100" <- because we want only 1 digit let totalSeconds: Int = totalMillis / 1000 let seconds: Int = totalSeconds % 60 let minutes: Int = (totalSeconds / 60) return String(format: "%02d:%02d.%d", minutes, seconds, millis) } fileprivate static func timeRemainingPretty(for game: Game) -> String { return timeFormatted(totalMillis: Int(game.time * 1000)) } fileprivate static func scorePretty(for game: Game) -> String { return String(format: "\(game.homeTeamScore) - \(game.awayTeamScore)") } }

Beachten Sie, dass Sie alles bereitgestellt haben, was ViewModel benötigt, um den Initialisierer zu durchlaufen.

Sie haben ihm das Game Objekt bereitgestellt, das das Modell unter diesem ViewModel ist.

Wenn Sie die App jetzt ausführen, funktioniert sie immer noch nicht, da Sie diese ViewModel-Daten nicht mit der Ansicht selbst verbunden haben.

Gehen Sie also zurück zur Datei GameScoreboardEditorViewController.swift und erstellen Sie eine öffentliche Eigenschaft namens viewModel .

Machen Sie es vom Typ GameScoreboardEditorViewModel .

Platzieren Sie es direkt vor der Methode viewDidLoad in GameScoreboardEditorViewController.swift .

 var viewModel: GameScoreboardEditorViewModel? { didSet { fillUI() } }

Als Nächstes müssen Sie die Methode fillUI implementieren.

Beachten Sie, wie diese Methode von zwei Stellen aufgerufen wird, dem viewModel Eigenschaftsbeobachter ( didSet ) und der viewDidLoad Methode. Dies liegt daran, dass wir einen ViewController erstellen und ihm ein ViewModel zuweisen können, bevor wir ihn an eine Ansicht anhängen (bevor die Methode viewDidLoad aufgerufen wird).

Andererseits könnten Sie die Ansicht von ViewController an eine andere Ansicht anhängen und viewDidLoad , aber wenn viewModel zu diesem Zeitpunkt nicht festgelegt ist, wird nichts passieren.

Aus diesem Grund müssen Sie zuerst prüfen, ob alles so eingestellt ist, dass Ihre Daten die Benutzeroberfläche füllen. Es ist wichtig, Ihren Code vor unerwarteter Verwendung zu schützen.

Gehen Sie also zur Methode fillUI und ersetzen Sie sie durch den folgenden Code:

 fileprivate func fillUI() { if !isViewLoaded { return } guard let viewModel = viewModel else { return } // we are sure here that we have all the setup done self.homeTeamNameLabel.text = viewModel.homeTeam self.awayTeamNameLabel.text = viewModel.awayTeam self.scoreLabel.text = viewModel.score self.timeLabel.text = viewModel.time let title: String = viewModel.isPaused ? "Start" : "Pause" self.pauseButton.setTitle(title, for: .normal) }

Implementieren Sie nun die Methode pauseButtonPress :

 @IBAction func pauseButtonPress(_ sender: AnyObject) { viewModel?.togglePause() }

Jetzt müssen Sie nur noch die eigentliche Eigenschaft viewModel für diesen ViewController . Sie tun dies „von außen“.

Öffnen Sie die Datei HomeViewController.swift und kommentieren Sie das ViewModel aus; Zeilen in der Methode showGameScoreboardEditorViewController erstellen und einrichten:

 // uncomment this when view model is implemented let viewModel = GameScoreboardEditorViewModelFromGame(withGame: game) controller.viewModel = viewModel

Führen Sie nun die App aus. Es sollte etwa so aussehen:

iOS-App

Die mittlere Ansicht, die für Punktestand, Zeit und Teamnamen zuständig ist, zeigt keine im Interface Builder eingestellten Werte mehr an.

Jetzt zeigt es die Werte des ViewModel-Objekts selbst an, das seine Daten vom eigentlichen Model-Objekt ( Game Objekt) erhält.

Exzellent! Aber was ist mit den Spieleransichten? Diese Tasten tun immer noch nichts.

Sie wissen, dass Sie sechs Ansichten zum Verfolgen von Spielerbewegungen haben.

Sie haben dafür eine separate Unteransicht mit dem Namen PlayerScoreboardMoveEditorView erstellt, die vorerst nichts mit den echten Daten zu tun hat und statische Werte anzeigt, die über den Interface Builder in der Datei PlayerScoreboardMoveEditorView.xib wurden.

Sie müssen ihm einige Daten geben.

Sie gehen genauso vor wie bei GameScoreboardEditorViewController und GameScoreboardEditorViewModel .

Öffnen Sie die ViewModel-Gruppe im Xcode-Projekt und definieren Sie hier das neue Protokoll.

Erstellen Sie eine neue Datei namens PlayerScoreboardMoveEditorViewModel.swift und fügen Sie den folgenden Code ein:

 import Foundation protocol PlayerScoreboardMoveEditorViewModel { var playerName: String { get } var onePointMoveCount: String { get } var twoPointMoveCount: String { get } var assistMoveCount: String { get } var reboundMoveCount: String { get } var foulMoveCount: String { get } func onePointMove() func twoPointsMove() func assistMove() func reboundMove() func foulMove() }

Dieses ViewModel-Protokoll wurde entwickelt, um zu Ihrer PlayerScoreboardMoveEditorView zu passen, genau wie Sie es in der übergeordneten Ansicht GameScoreboardEditorViewController .

Sie müssen Werte für die fünf verschiedenen Bewegungen haben, die ein Benutzer ausführen kann, und Sie müssen reagieren, wenn der Benutzer eine der Aktionsschaltflächen berührt. Sie benötigen auch einen String für den Spielernamen.

Nachdem Sie dies getan haben, erstellen Sie eine konkrete Klasse, die dieses Protokoll implementiert, genau wie Sie es mit der übergeordneten Ansicht ( GameScoreboardEditorViewController ) getan haben.

Erstellen Sie als Nächstes eine Implementierung dieses Protokolls: Erstellen Sie eine neue Datei, nennen Sie sie PlayerScoreboardMoveEditorViewModelFromPlayer.swift und machen Sie dieses Objekt zu einer Unterklasse von NSObject . Passen Sie es außerdem an das PlayerScoreboardMoveEditorViewModel Protokoll an:

 import Foundation class PlayerScoreboardMoveEditorViewModelFromPlayer: NSObject, PlayerScoreboardMoveEditorViewModel { fileprivate let player: Player fileprivate let game: Game // MARK: PlayerScoreboardMoveEditorViewModel protocol let playerName: String var onePointMoveCount: String var twoPointMoveCount: String var assistMoveCount: String var reboundMoveCount: String var foulMoveCount: String func onePointMove() { makeMove(.onePoint) } func twoPointsMove() { makeMove(.twoPoints) } func assistMove() { makeMove(.assist) } func reboundMove() { makeMove(.rebound) } func foulMove() { makeMove(.foul) } // MARK: Init init(withGame game: Game, player: Player) { self.game = game self.player = player self.playerName = player.name self.onePointMoveCount = "\(game.playerMoveCount(for: player, move: .onePoint))" self.twoPointMoveCount = "\(game.playerMoveCount(for: player, move: .twoPoints))" self.assistMoveCount = "\(game.playerMoveCount(for: player, move: .assist))" self.reboundMoveCount = "\(game.playerMoveCount(for: player, move: .rebound))" self.foulMoveCount = "\(game.playerMoveCount(for: player, move: .foul))" } // MARK: Private fileprivate func makeMove(_ move: PlayerInGameMove) { game.addPlayerMove(move, for: player) onePointMoveCount = "\(game.playerMoveCount(for: player, move: .onePoint))" twoPointMoveCount = "\(game.playerMoveCount(for: player, move: .twoPoints))" assistMoveCount = "\(game.playerMoveCount(for: player, move: .assist))" reboundMoveCount = "\(game.playerMoveCount(for: player, move: .rebound))" foulMoveCount = "\(game.playerMoveCount(for: player, move: .foul))" } }

Nun benötigen Sie ein Objekt, das diese Instanz „von außen“ erstellt und als Eigenschaft innerhalb von PlayerScoreboardMoveEditorView .

Erinnern Sie sich, wie HomeViewController für das Setzen der Eigenschaft viewModel auf dem GameScoreboardEditorViewController war?

Auf die gleiche Weise ist GameScoreboardEditorViewController eine übergeordnete Ansicht Ihres PlayerScoreboardMoveEditorView , und dieser GameScoreboardEditorViewController ist für die Erstellung von PlayerScoreboardMoveEditorViewModel Objekten verantwortlich.

Sie müssen zuerst Ihr GameScoreboardEditorViewModel erweitern.

Öffnen GameScoreboardEditorViewMode l und fügen Sie diese beiden Eigenschaften hinzu:

 var homePlayers: [PlayerScoreboardMoveEditorViewModel] { get } var awayPlayers: [PlayerScoreboardMoveEditorViewModel] { get }

Aktualisieren GameScoreboardEditorViewModelFromGame mit diesen beiden Eigenschaften direkt über der Methode initWithGame :

 let homePlayers: [PlayerScoreboardMoveEditorViewModel] let awayPlayers: [PlayerScoreboardMoveEditorViewModel]

Fügen Sie diese beiden Zeilen in initWithGame :

 self.homePlayers = GameScoreboardEditorViewModelFromGame.playerViewModels(from: game.homeTeam.players, game: game) self.awayPlayers = GameScoreboardEditorViewModelFromGame.playerViewModels(from: game.awayTeam.players, game: game)

Und fügen Sie natürlich die fehlende Methode playerViewModelsWithPlayers hinzu:

 // MARK: Private Init fileprivate static func playerViewModels(from players: [Player], game: Game) -> [PlayerScoreboardMoveEditorViewModel] { var playerViewModels: [PlayerScoreboardMoveEditorViewModel] = [PlayerScoreboardMoveEditorViewModel]() for player in players { playerViewModels.append(PlayerScoreboardMoveEditorViewModelFromPlayer(withGame: game, player: player)) } return playerViewModels }

Toll!

Sie haben Ihr ViewModel ( GameScoreboardEditorViewModel ) mit dem Heim- und Auswärtsspieler-Array aktualisiert. Sie müssen diese beiden Arrays noch füllen.

Sie tun dies an der gleichen Stelle, an der Sie dieses viewModel zum Ausfüllen der Benutzeroberfläche verwendet haben.

Öffnen GameScoreboardEditorViewController und gehen Sie zur Methode fillUI . Fügen Sie diese Zeilen am Ende der Methode hinzu:

 homePlayer1View.viewModel = viewModel.homePlayers[0] homePlayer2View.viewModel = viewModel.homePlayers[1] homePlayer3View.viewModel = viewModel.homePlayers[2] awayPlayer1View.viewModel = viewModel.awayPlayers[0] awayPlayer2View.viewModel = viewModel.awayPlayers[1] awayPlayer3View.viewModel = viewModel.awayPlayers[2]

Im Moment haben Sie Build-Fehler, weil Sie die eigentliche viewModel nicht in PlayerScoreboardMoveEditorView .

Fügen Sie den folgenden Code oberhalb der init method inside the PlayerScoreboardMoveEditorView` hinzu.

 var viewModel: PlayerScoreboardMoveEditorViewModel? { didSet { fillUI() } }

Und implementieren Sie die Methode fillUI :

 fileprivate func fillUI() { guard let viewModel = viewModel else { return } self.name.text = viewModel.playerName self.onePointCountLabel.text = viewModel.onePointMoveCount self.twoPointCountLabel.text = viewModel.twoPointMoveCount self.assistCountLabel.text = viewModel.assistMoveCount self.reboundCountLabel.text = viewModel.reboundMoveCount self.foulCountLabel.text = viewModel.foulMoveCount }

Führen Sie schließlich die App aus und sehen Sie, wie die Daten in den UI-Elementen die tatsächlichen Daten aus dem Game Objekt sind.

iOS-App

An diesem Punkt verfügen Sie über eine funktionsfähige App, die das MVVM-Entwurfsmuster verwendet.

Es verbirgt das Modell schön vor der Ansicht, und Ihre Ansicht ist viel einfacher, als Sie es von der MVC gewohnt sind.

Bis zu diesem Punkt haben Sie eine App erstellt, die die Ansicht und ihr ViewModel enthält.

Diese Ansicht hat auch sechs Instanzen derselben Unteransicht (Spieleransicht) mit ihrem ViewModel.

Wie Sie jedoch vielleicht bemerkt haben, können Sie Daten nur einmal in der Benutzeroberfläche anzeigen (in der fillUI Methode), und diese Daten sind statisch.

Wenn sich Ihre Daten in den Ansichten während der Lebensdauer dieser Ansicht nicht ändern, haben Sie eine gute und saubere Lösung, um MVVM auf diese Weise zu verwenden.

ViewModel dynamisch machen

Da sich Ihre Daten ändern werden, müssen Sie Ihr ViewModel dynamisch machen.

Dies bedeutet, dass ViewModel seine öffentlichen Eigenschaftswerte ändern sollte, wenn sich das Modell ändert. Es würde die Änderung zurück an die Ansicht weitergeben, die die Benutzeroberfläche aktualisiert.

Es gibt viele Möglichkeiten, dies zu tun.

Wenn sich das Modell ändert, wird ViewModel zuerst benachrichtigt.

Sie benötigen einen Mechanismus, um die Änderungen an die Ansicht weiterzugeben.

Einige der Optionen beinhalten RxSwift, eine ziemlich große Bibliothek, an die man sich erst gewöhnen muss.

ViewModel könnte NSNotification s bei jeder Eigenschaftswertänderung auslösen, aber dies fügt eine Menge Code hinzu, der zusätzliche Behandlung erfordert, wie z. B. das Abonnieren von Benachrichtigungen und das Abbestellen, wenn die Zuweisung der Ansicht aufgehoben wird.

Key-Value-Observing (KVO) ist eine weitere Option, aber Benutzer werden bestätigen, dass die API nichts Besonderes ist.

In diesem Tutorial verwenden Sie Swift-Generika und Closures, die im Artikel Bindings, Generics, Swift and MVVM ausführlich beschrieben werden.

Kommen wir nun zurück zur Beispiel-App.

Wechseln Sie zur ViewModel-Projektgruppe und erstellen Sie eine neue Swift-Datei, Dynamic.swift .

 class Dynamic<T> { typealias Listener = (T) -> () var listener: Listener? func bind(_ listener: Listener?) { self.listener = listener } func bindAndFire(_ listener: Listener?) { self.listener = listener listener?(value) } var value: T { didSet { listener?(value) } } init(_ v: T) { value = v } }

Sie verwenden diese Klasse für Eigenschaften in Ihren ViewModels, von denen Sie erwarten, dass sie sich während des View-Lebenszyklus ändern.

Beginnen Sie zunächst mit PlayerScoreboardMoveEditorView und seinem ViewModel PlayerScoreboardMoveEditorViewModel .

Öffnen PlayerScoreboardMoveEditorViewModel und sehen Sie sich seine Eigenschaften an.

Da playerName wird, dass sich der Spielername nicht ändert, können Sie ihn unverändert lassen.

Die anderen fünf Eigenschaften (fünf Bewegungstypen) werden sich ändern, also müssen Sie etwas dagegen tun. Die Lösung? Die oben erwähnte Dynamic Klasse, die Sie gerade dem Projekt hinzugefügt haben.

Entfernen Sie in PlayerScoreboardMoveEditorViewModel die Definitionen für fünf Zeichenfolgen, die die Anzahl der Züge darstellen, und ersetzen Sie sie durch diese:

 var onePointMoveCount: Dynamic<String> { get } var twoPointMoveCount: Dynamic<String> { get } var assistMoveCount: Dynamic<String> { get } var reboundMoveCount: Dynamic<String> { get } var foulMoveCount: Dynamic<String> { get }

So sollte das ViewModel-Protokoll jetzt aussehen:

 import Foundation protocol PlayerScoreboardMoveEditorViewModel { var playerName: String { get } var onePointMoveCount: Dynamic<String> { get } var twoPointMoveCount: Dynamic<String> { get } var assistMoveCount: Dynamic<String> { get } var reboundMoveCount: Dynamic<String> { get } var foulMoveCount: Dynamic<String> { get } func onePointMove() func twoPointsMove() func assistMove() func reboundMove() func foulMove() }

Dieser Dynamic Typ ermöglicht es Ihnen, den Wert dieser bestimmten Eigenschaft zu ändern und gleichzeitig das Change-Listener-Objekt zu benachrichtigen, das in diesem Fall die Ansicht ist.

Aktualisieren Sie nun die eigentliche ViewModel-Implementierung PlayerScoreboardMoveEditorViewModelFromPlayer .

Ersetzen Sie dies:

 var onePointMoveCount: String var twoPointMoveCount: String var assistMoveCount: String var reboundMoveCount: String var foulMoveCount: String

mit den folgenden:

 let onePointMoveCount: Dynamic<String> let twoPointMoveCount: Dynamic<String> let assistMoveCount: Dynamic<String> let reboundMoveCount: Dynamic<String> let foulMoveCount: Dynamic<String>

Hinweis: Es ist in Ordnung, diese Eigenschaften mit let als Konstanten zu deklarieren, da Sie die eigentliche Eigenschaft nicht ändern. Sie ändern die value des Dynamic Objekts.

Jetzt gibt es Build-Fehler, weil Sie Ihre Dynamic Objekte nicht initialisiert haben.

Ersetzen Sie in der init -Methode von PlayerScoreboardMoveEditorViewModelFromPlayer die Initialisierung der Bewegungseigenschaften durch Folgendes:

 self.onePointMoveCount = Dynamic("\(game.playerMoveCount(for: player, move: .onePoint))") self.twoPointMoveCount = Dynamic("\(game.playerMoveCount(for: player, move: .twoPoints))") self.assistMoveCount = Dynamic("\(game.playerMoveCount(for: player, move: .assist))") self.reboundMoveCount = Dynamic("\(game.playerMoveCount(for: player, move: .rebound))") self.foulMoveCount = Dynamic("\(game.playerMoveCount(for: player, move: .foul))")

PlayerScoreboardMoveEditorViewModelFromPlayer Sie in makeMove zur Methode makeMove und ersetzen Sie sie durch den folgenden Code:

 fileprivate func makeMove(_ move: PlayerInGameMove) { game.addPlayerMove(move, for: player) onePointMoveCount.value = "\(game.playerMoveCount(for: player, move: .onePoint))" twoPointMoveCount.value = "\(game.playerMoveCount(for: player, move: .twoPoints))" assistMoveCount.value = "\(game.playerMoveCount(for: player, move: .assist))" reboundMoveCount.value = "\(game.playerMoveCount(for: player, move: .rebound))" foulMoveCount.value = "\(game.playerMoveCount(for: player, move: .foul))" }

Wie Sie sehen können, haben Sie Instanzen der Dynamic -Klasse erstellt und ihr String -Werte zugewiesen. Wenn Sie die Daten aktualisieren müssen, ändern Sie nicht die Dynamic -Eigenschaft selbst; aktualisieren Sie lieber seine value .

Toll! PlayerScoreboardMoveEditorViewModel ist jetzt dynamisch.

Lassen Sie uns davon Gebrauch machen und zu der Ansicht wechseln, die tatsächlich auf diese Änderungen lauscht.

Öffnen PlayerScoreboardMoveEditorView und seine fillUI Methode (Sie sollten an dieser Stelle Build-Fehler in dieser Methode sehen, da Sie versuchen, dem Dynamic Objekttyp einen String -Wert zuzuweisen.)

Ersetzen Sie die „fehlerhaften“ Zeilen:

 self.onePointCountLabel.text = viewModel.onePointMoveCount self.twoPointCountLabel.text = viewModel.twoPointMoveCount self.assistCountLabel.text = viewModel.assistMoveCount self.reboundCountLabel.text = viewModel.reboundMoveCount self.foulCountLabel.text = viewModel.foulMoveCount

mit den folgenden:

 viewModel.onePointMoveCount.bindAndFire { [unowned self] in self.onePointCountLabel.text = $0 } viewModel.twoPointMoveCount.bindAndFire { [unowned self] in self.twoPointCountLabel.text = $0 } viewModel.assistMoveCount.bindAndFire { [unowned self] in self.assistCountLabel.text = $0 } viewModel.reboundMoveCount.bindAndFire { [unowned self] in self.reboundCountLabel.text = $0 } viewModel.foulMoveCount.bindAndFire { [unowned self] in self.foulCountLabel.text = $0 }

Implementieren Sie als Nächstes die fünf Methoden, die Bewegungsaktionen darstellen (Abschnitt Schaltflächenaktion ):

 @IBAction func onePointAction(_ sender: Any) { viewModel?.onePointMove() } @IBAction func twoPointsAction(_ sender: Any) { viewModel?.twoPointsMove() } @IBAction func assistAction(_ sender: Any) { viewModel?.assistMove() } @IBAction func reboundAction(_ sender: Any) { viewModel?.reboundMove() } @IBAction func foulAction(_ sender: Any) { viewModel?.foulMove() }

Führen Sie die App aus und klicken Sie auf einige Schaltflächen zum Verschieben. Sie werden sehen, wie sich die Zählerwerte in den Spieleransichten ändern, wenn Sie auf die Aktionsschaltfläche klicken.

iOS-App

Sie sind mit PlayerScoreboardMoveEditorView und PlayerScoreboardMoveEditorViewModel .

Das war einfach.

Jetzt müssen Sie dasselbe mit Ihrer Hauptansicht ( GameScoreboardEditorViewController ) tun.

Öffnen GameScoreboardEditorViewModel und prüfen Sie, welche Werte sich voraussichtlich während des Lebenszyklus der Ansicht ändern werden.

Ersetzen Sie time , score , isFinished , isPaused Definitionen durch die Dynamic Versionen:

 import Foundation protocol GameScoreboardEditorViewModel { var homeTeam: String { get } var awayTeam: String { get } var time: Dynamic<String> { get } var score: Dynamic<String> { get } var isFinished: Dynamic<Bool> { get } var isPaused: Dynamic<Bool> { get } func togglePause() var homePlayers: [PlayerScoreboardMoveEditorViewModel] { get } var awayPlayers: [PlayerScoreboardMoveEditorViewModel] { get } }

Wechseln Sie zur ViewModel-Implementierung ( GameScoreboardEditorViewModelFromGame ) und machen Sie dasselbe mit den im Protokoll deklarierten Eigenschaften.

Ersetzen Sie dies:

 var time: String var score: String var isFinished: Bool var isPaused: Bool

mit den folgenden:

 let time: Dynamic<String> let score: Dynamic<String> let isFinished: Dynamic<Bool> let isPaused: Dynamic<Bool>

Sie erhalten jetzt einige Fehler, weil Sie den Typ von ViewModel von String und Bool in Dynamic<String> und Dynamic<Bool> geändert haben.

Lassen Sie uns das beheben.

Korrigieren Sie die togglePause Methode, indem Sie sie durch Folgendes ersetzen:

 func togglePause() { if isPaused.value { startTimer() } else { pauseTimer() } self.isPaused.value = !isPaused.value }

Beachten Sie, dass die einzige Änderung darin besteht, dass Sie den Eigenschaftswert nicht mehr direkt für die Eigenschaft festlegen. Stattdessen setzen Sie es auf die value -Eigenschaft des Objekts.

Korrigieren Sie nun die initWithGame Methode, indem Sie Folgendes ersetzen:

 self.time = GameScoreboardEditorViewModelFromGame.timeRemainingPretty(game) self.score = GameScoreboardEditorViewModelFromGame.scorePretty(game) self.isFinished = game.isFinished self.isPaused = true

mit den folgenden:

 self.time = Dynamic(GameScoreboardEditorViewModelFromGame.timeRemainingPretty(for: game)) self.score = Dynamic(GameScoreboardEditorViewModelFromGame.scorePretty(for: game)) self.isFinished = Dynamic(game.isFinished) self.isPaused = Dynamic(true)

Sie sollten den Punkt jetzt verstehen.

Sie umschließen die primitiven Werte wie String , Int und Bool mit Dynamic<T> -Versionen dieser Objekte, wodurch Sie den einfachen Bindungsmechanismus erhalten.

Sie müssen noch einen weiteren Fehler beheben.

Ersetzen Sie in der startTimer Methode die Fehlerzeile durch:

 self.time.value = GameScoreboardEditorViewModelFromGame.timeRemainingPretty(for: self.game)

Sie haben Ihr ViewModel so aktualisiert, dass es dynamisch ist, genau wie Sie es mit dem ViewModel des Players getan haben. Aber Sie müssen noch Ihre Ansicht ( GameScoreboardEditorViewController ) aktualisieren.

Ersetzen Sie die gesamte fillUI Methode durch diese:

 fileprivate func fillUI() { if !isViewLoaded { return } guard let viewModel = viewModel else { return } self.homeTeamNameLabel.text = viewModel.homeTeam self.awayTeamNameLabel.text = viewModel.awayTeam viewModel.score.bindAndFire { [unowned self] in self.scoreLabel.text = $0 } viewModel.time.bindAndFire { [unowned self] in self.timeLabel.text = $0 } viewModel.isFinished.bindAndFire { [unowned self] in if $0 { self.homePlayer1View.isHidden = true self.homePlayer2View.isHidden = true self.homePlayer3View.isHidden = true self.awayPlayer1View.isHidden = true self.awayPlayer2View.isHidden = true self.awayPlayer3View.isHidden = true } } viewModel.isPaused.bindAndFire { [unowned self] in let title = $0 ? "Start" : "Pause" self.pauseButton.setTitle(title, for: .normal) } homePlayer1View.viewModel = viewModel.homePlayers[0] homePlayer2View.viewModel = viewModel.homePlayers[1] homePlayer3View.viewModel = viewModel.homePlayers[2] awayPlayer1View.viewModel = viewModel.awayPlayers[0] awayPlayer2View.viewModel = viewModel.awayPlayers[1] awayPlayer3View.viewModel = viewModel.awayPlayers[2] }

Der einzige Unterschied besteht darin, dass Sie Ihre vier dynamischen Eigenschaften geändert und jedem von ihnen Änderungslistener hinzugefügt haben.

Wenn Sie zu diesem Zeitpunkt Ihre App ausführen, wird durch Umschalten der Schaltfläche Start/Pause der Spiel-Timer gestartet und angehalten. Dies wird für Auszeiten während des Spiels verwendet.

Sie sind fast fertig, außer dass sich die Punktzahl in der Benutzeroberfläche nicht ändert, wenn Sie eine der Punkttasten ( 1 und 2 -Punkte-Taste) drücken.

Dies liegt daran, dass Sie Score-Änderungen im zugrunde liegenden Game nicht wirklich bis zum ViewModel weitergegeben haben.

Öffnen Sie also das Game für eine kleine Untersuchung. Überprüfen Sie die updateScore Methode.

 fileprivate func updateScore(_ score: UInt, withScoringPlayer player: Player) { if isFinished || score == 0 { return } if homeTeam.containsPlayer(player) { homeTeamScore += score } else { assert(awayTeam.containsPlayer(player)) awayTeamScore += score } if checkIfFinished() { isFinished = true } NotificationCenter.default.post(name: Notification.Name(rawValue: GameNotifications.GameScoreDidChangeNotification), object: self) }

Diese Methode macht zwei wichtige Dinge.

Zuerst setzt es die isFinished Eigenschaft auf true , wenn das Spiel basierend auf den Ergebnissen beider Teams beendet ist.

Danach wird eine Benachrichtigung veröffentlicht, dass sich die Punktzahl geändert hat. Sie hören auf diese Benachrichtigung im GameScoreboardEditorViewModelFromGame und aktualisieren den dynamischen Score-Wert in der Notification-Handler-Methode.

Add this line at the bottom of initWithGame method (don't forget the super.init() call to avoid errors):

 super.init() subscribeToNotifications()

Below initWithGame method, add deinit method, since you want to do the cleanup properly and avoid crashes caused by the NotificationCenter .

 deinit { unsubscribeFromNotifications() }

Finally, add the implementations of these methods. Add this section right below the deinit method:

 // MARK: Notifications (Private) fileprivate func subscribeToNotifications() { NotificationCenter.default.addObserver(self, selector: #selector(gameScoreDidChangeNotification(_:)), name: NSNotification.Name(rawValue: GameNotifications.GameScoreDidChangeNotification), object: game) } fileprivate func unsubscribeFromNotifications() { NotificationCenter.default.removeObserver(self) } @objc fileprivate func gameScoreDidChangeNotification(_ notification: NSNotification){ self.score.value = GameScoreboardEditorViewModelFromGame.scorePretty(for: game) if game.isFinished { self.isFinished.value = true } }

Now, run the app, and click on the player views to change scores. Since you've already connected dynamic score and isFinished in the ViewModel with the View, everything should work when you change the score value inside the ViewModel.

How to Further Improve the App

While there's always room for improvement, this is out of scope of this tutorial.

For example, we do not stop the time automatically when the game is over (when one of the teams reaches 15 points), we just hide the player views.

You can play with the app if you like and upgrade it to have a “game creator” view, which would create a game, assign team names, assign player names and create a Game object that could be used to present GameScoreboardEditorViewController .

We can create another “game list” view that uses the UITableView to show multiple games in progress with some detailed info in the table cell. In cell select, we can show the GameScoreboardEditorViewController with the selected Game .

The GameLibrary has already been implemented. Just remember to pass that library reference to the ViewModel objects in their initializer. For example, “game creator's” ViewModel would need to have an instance of GameLibrary passed through the initializer so that it would be able to insert the created Game object into the library. “Game list's” ViewModel would also need this reference to fetch all games from the library, which will be needed by the UITableView.

The idea is to hide all of the dirty (non-UI) work inside the ViewModel and have the UI (View) only act with prepared presentation data.

What now?

After you get used to the MVVM, you can further improve it by using Uncle Bob's Clean Architecture rules.

An additional good read is a three-part tutorial on Android architecture:

  • Android Architecture: Part 1 – Every new beginning is hard,
  • Android Architecture: Part 2 – The clean architecture,
  • Android Architecture: Part 2 – The clean architecture.

Examples are written in Java (for Android), and if you are familiar with Java (which is much closer to Swift then Objective-C is to Java), you'll get ideas on how to further refactor your code inside the ViewModel objects so that they don't import any iOS modules ( UIKit or CoreLocation eg).

These iOS modules can be hidden behind the pure NSObjects , which is good for code reusability.

MVVM is a good choice for most iOS apps, and hopefully, you will give it a try in your next project. Or, try it in your current project when you are creating a UIViewController .

Related: Working With Static Patterns: A Swift MVVM Tutorial