Swift-Tutorial: Eine Einführung in das MVVM-Entwurfsmuster
Veröffentlicht: 2022-03-11Sie 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.
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.
Upgrade auf das 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.
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
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.
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:
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
- Root-View-Controller, der den
-
GameScoreboardEditorViewController.swift
- Ergänzt mit der Interface Builder-Ansicht in
Main.storyboard
. - Interessanter Bildschirm für dieses Tutorial.
- Ergänzt mit der Interface Builder-Ansicht in
-
PlayerScoreboardMoveEditorView.swift
- Ergänzt mit der Interface Builder-Ansicht in
PlayerScoreboardMoveEditorView.xib
- Die Unteransicht der obigen Ansicht verwendet ebenfalls das MVVM-Entwurfsmuster.
- Ergänzt mit der Interface Builder-Ansicht in
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:
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.
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.
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
.