Swift Tutorial: un'introduzione al modello di progettazione MVVM
Pubblicato: 2022-03-11Quindi stai iniziando un nuovo progetto iOS, hai ricevuto dal designer tutti i documenti .pdf
e .sketch
necessari e hai già una visione su come costruire questa nuova app.
Inizi a trasferire le schermate dell'interfaccia utente dagli schizzi del designer nei tuoi file ViewController
.swift
, .xib
e .storyboard
.
UITextField
qui, UITableView
lì, qualche altro UILabels
e un pizzico di UIButtons
. Sono inclusi anche IBOutlets
e IBActions
. Tutto bene, siamo ancora nella zona UI.
Tuttavia, è tempo di fare qualcosa con tutti questi elementi dell'interfaccia utente; UIButtons
riceveranno tocchi con le dita, UILabels
e UITableViews
avranno bisogno di qualcuno che dica loro cosa visualizzare e in quale formato.
Improvvisamente, hai più di 3.000 righe di codice.
Hai finito con un sacco di codici spaghetti.
Il primo passaggio per risolvere questo problema consiste nell'applicare il modello di progettazione Model-View-Controller (MVC). Tuttavia, questo modello ha i suoi problemi. Arriva il modello di progettazione Model-View-ViewModel (MVVM) che salva la giornata.
Trattare con il codice degli spaghetti
In pochissimo tempo, il tuo ViewController
iniziale è diventato troppo intelligente e troppo massiccio.
Codice di rete, codice di analisi dei dati, codice di regolazione dei dati per la presentazione dell'interfaccia utente, notifiche sullo stato dell'app, modifiche dello stato dell'interfaccia utente. Tutto quel codice imprigionato all'interno if
-ology di un singolo file che non può essere riutilizzato e si adatterebbe solo a questo progetto.
Il tuo codice ViewController
è diventato il famigerato codice spaghetti.
Come è successo?
Il motivo probabile è qualcosa del genere:
Avevi fretta di vedere come si comportavano i dati di back-end all'interno di UITableView
, quindi hai inserito alcune righe di codice di rete all'interno di un metodo temporaneo di ViewController
solo per recuperare quel .json
dalla rete. Successivamente, dovevi elaborare i dati all'interno di quel .json
, quindi hai scritto un altro metodo temporaneo per farlo. O, peggio ancora, l'hai fatto all'interno dello stesso metodo.
Il ViewController
continuato a crescere quando è arrivato il codice di autorizzazione dell'utente. Poi i formati dei dati hanno iniziato a cambiare, l'interfaccia utente si è evoluta e ha avuto bisogno di alcune modifiche radicali, e hai continuato ad aggiungere più if
in una già massiccia -ology if
.
Ma come UIViewController
è ciò che è sfuggito di mano?
UIViewController
è il punto logico per iniziare a lavorare sul codice dell'interfaccia utente. Rappresenta lo schermo fisico che vedi durante l'utilizzo di qualsiasi app con il tuo dispositivo iOS. Anche Apple utilizza UIViewControllers
nella sua app di sistema principale quando passa tra diverse app e le sue UI animate.
Apple basa la sua astrazione dell'interfaccia utente all'interno di UIViewController
, poiché è al centro del codice dell'interfaccia utente di iOS e fa parte del modello di progettazione MVC .
Aggiornamento al modello di progettazione MVC
Nel modello di progettazione MVC, View dovrebbe essere inattivo e mostra solo i dati preparati su richiesta.
Il controller dovrebbe lavorare sui dati del modello per prepararlo per le viste , che quindi visualizzano tali dati.
View è anche responsabile della notifica al Titolare di qualsiasi azione, come i tocchi dell'utente.
Come accennato, UIViewController
è solitamente il punto di partenza nella creazione di una schermata dell'interfaccia utente. Si noti che nel suo nome contiene sia la "vista" che il "controllore". Ciò significa che "controlla la vista". Ciò non significa che sia il codice "controller" che "view" debbano essere inseriti.
Questa combinazione di codice di visualizzazione e controller si verifica spesso quando si spostano IBOutlets
di piccole viste secondarie all'interno di UIViewController
e si manipolano tali viste secondarie direttamente da UIViewController
. Invece dovresti aver racchiuso quel codice all'interno di una sottoclasse UIView
personalizzata.
È facile vedere che ciò potrebbe portare a incrociare i percorsi del codice View e Controller.
MVVM in soccorso
È qui che il pattern MVVM torna utile.
Poiché UIViewController
dovrebbe essere un controller nel modello MVC e sta già facendo molto con le viste , possiamo unirle nella vista del nostro nuovo modello: MVVM .
Nel modello di progettazione MVVM, Modello è lo stesso del modello MVC. Rappresenta dati semplici.
La vista è rappresentata dagli oggetti UIView
o UIViewController
, accompagnati dai relativi file .xib
e .storyboard
, che dovrebbero visualizzare solo i dati preparati. (Non vogliamo avere il codice NSDateFormatter
, ad esempio, all'interno della vista.)
Solo una semplice stringa formattata che proviene da ViewModel .
ViewModel nasconde tutto il codice di rete asincrono, il codice di preparazione dei dati per la presentazione visiva e l'ascolto del codice per le modifiche del modello . Tutti questi sono nascosti dietro un'API ben definita modellata per adattarsi a questa particolare vista .
Uno dei vantaggi dell'utilizzo di MVVM è il test. Poiché ViewModel è puro NSObject
(o struct
per esempio) e non è accoppiato con il codice UIKit
, puoi testarlo più facilmente nei tuoi unit test senza che influisca sul codice dell'interfaccia utente.
Ora, View ( UIViewController
/ UIView
) è diventato molto più semplice mentre ViewModel funge da collante tra Model e View .
Applicazione di MVVM in Swift
Per mostrarti MVVM in azione, puoi scaricare ed esaminare il progetto Xcode di esempio creato per questo tutorial qui. Questo progetto utilizza Swift 3 e Xcode 8.1.
Esistono due versioni del progetto: Starter e Finished.
La versione Finished è una mini applicazione completata, in cui Starter è lo stesso progetto ma senza i metodi e gli oggetti implementati.
Innanzitutto, ti suggerisco di scaricare il progetto Starter e di seguire questo tutorial. Se hai bisogno di un rapido riferimento al progetto per dopo, scarica il progetto finito .
Introduzione al progetto tutorial
Il progetto tutorial è un'applicazione di basket per il monitoraggio delle azioni dei giocatori durante la partita.
Viene utilizzato per il monitoraggio rapido delle mosse degli utenti e del punteggio complessivo in un gioco di raccolta.
Due squadre giocano fino al raggiungimento del punteggio di 15 (con almeno due punti di differenza). Ogni giocatore può segnare da un punto a due punti e ogni giocatore può assistere, rimbalzare e fallo.
La gerarchia del progetto si presenta così:
Modello
-
Game.swift
- Contiene la logica di gioco, tiene traccia del punteggio complessivo, tiene traccia delle mosse di ogni giocatore.
-
Team.swift
- Contiene il nome della squadra e l'elenco dei giocatori (tre giocatori per squadra).
-
Player.swift
- Un solo giocatore con un nome.
Visualizzazione
-
HomeViewController.swift
- Controller di visualizzazione principale, che presenta
GameScoreboardEditorViewController
- Controller di visualizzazione principale, che presenta
-
GameScoreboardEditorViewController.swift
- Integrato con la visualizzazione Interface Builder in
Main.storyboard
. - Schermata di interesse per questo tutorial.
- Integrato con la visualizzazione Interface Builder in
-
PlayerScoreboardMoveEditorView.swift
- Integrato con la visualizzazione Interface Builder in
PlayerScoreboardMoveEditorView.xib
- La visualizzazione secondaria della vista sopra, utilizza anche il modello di progettazione MVVM.
- Integrato con la visualizzazione Interface Builder in
Visualizza modello
- Il gruppo
ViewModel
è vuoto, questo è ciò che creerai in questo tutorial.
Il progetto Xcode scaricato contiene già segnaposto per gli oggetti View ( UIView
e UIViewController
). Il progetto contiene anche alcuni oggetti personalizzati realizzati per dimostrare uno dei modi su come fornire dati agli oggetti ViewModel (gruppo Services
).
Il gruppo Extensions
contiene estensioni utili per il codice dell'interfaccia utente che non rientrano nell'ambito di questa esercitazione e sono autoesplicative.
Se esegui l'app a questo punto, mostrerà l'interfaccia utente finita, ma non succede nulla quando un utente preme i pulsanti.
Questo perché hai creato solo viste e IBActions
senza collegarle alla logica dell'app e senza riempire gli elementi dell'interfaccia utente con i dati del modello (dall'oggetto Game
, come impareremo più avanti).
Collegamento di View e Model con ViewModel
Nel modello di progettazione MVVM, View non dovrebbe sapere nulla del modello. L'unica cosa che View sa è come lavorare con un ViewModel.
Inizia esaminando la tua vista.
Nel file GameScoreboardEditorViewController.swift
, il metodo fillUI
è vuoto a questo punto. Questo è il posto in cui vuoi popolare l'interfaccia utente con i dati. Per ottenere ciò, è necessario fornire i dati per ViewController
. Lo fai con un oggetto ViewModel.
Innanzitutto, crea un oggetto ViewModel che contenga tutti i dati necessari per questo ViewController
.
Vai al gruppo di progetto ViewModel Xcode, che sarà vuoto, crea un file GameScoreboardEditorViewModel.swift
e trasformalo in un protocollo.
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(); }
L'uso di protocolli come questo mantiene le cose belle e pulite; devi solo definire i dati che utilizzerai.
Quindi, crea un'implementazione per questo protocollo.
Crea un nuovo file, chiamato GameScoreboardEditorViewModelFromGame.swift
, e rendi questo oggetto una sottoclasse di NSObject
.
Inoltre, rendilo conforme al protocollo GameScoreboardEditorViewModel
:
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)") } }
Si noti che è stato fornito tutto il necessario affinché ViewModel funzioni tramite l'inizializzatore.
Gli hai fornito l'oggetto Game
, che è il modello sotto questo ViewModel.
Se esegui l'app ora, non funzionerà comunque perché non hai collegato questi dati ViewModel alla vista stessa.
Quindi, torna al file GameScoreboardEditorViewController.swift
e crea una proprietà pubblica denominata viewModel
.
Rendilo del tipo GameScoreboardEditorViewModel
.
Posizionalo subito prima del metodo viewDidLoad
all'interno di GameScoreboardEditorViewController.swift
.
var viewModel: GameScoreboardEditorViewModel? { didSet { fillUI() } }
Successivamente, è necessario implementare il metodo fillUI
.
Nota come questo metodo viene chiamato da due posizioni, l'osservatore della proprietà viewModel
( didSet
) e il metodo viewDidLoad
. Questo perché possiamo creare un ViewController
e assegnargli un ViewModel prima di collegarlo a una vista (prima che venga chiamato il metodo viewDidLoad
).
D'altra parte, puoi collegare la vista di ViewController a un'altra vista e chiamare viewDidLoad
, ma se viewModel
non è impostato in quel momento, non accadrà nulla.
Ecco perché prima devi controllare se tutto è impostato affinché i tuoi dati riempiano l'interfaccia utente. È importante proteggere il codice da utilizzi imprevisti.
Quindi, vai al metodo fillUI
e sostituiscilo con il codice seguente:
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) }
Ora, implementa il metodo pauseButtonPress
:
@IBAction func pauseButtonPress(_ sender: AnyObject) { viewModel?.togglePause() }
Tutto ciò che devi fare ora è impostare la proprietà viewModel
effettiva su questo ViewController
. Lo fai "dall'esterno".
Apri il file HomeViewController.swift
e decommenta il ViewModel; creare e impostare righe nel metodo showGameScoreboardEditorViewController
:
// uncomment this when view model is implemented let viewModel = GameScoreboardEditorViewModelFromGame(withGame: game) controller.viewModel = viewModel
Ora esegui l'app. Dovrebbe assomigliare a qualcosa di simile a questo:
La vista centrale, che è responsabile del punteggio, del tempo e dei nomi delle squadre, non mostra più i valori impostati in Interface Builder.
Ora mostra i valori dall'oggetto ViewModel stesso, che ottiene i suoi dati dall'oggetto Model effettivo (oggetto Game
).
Eccellente! Ma per quanto riguarda le visualizzazioni dei giocatori? Quei pulsanti ancora non fanno nulla.
Sai che hai sei visualizzazioni per il monitoraggio delle mosse dei giocatori.
Hai creato una visualizzazione secondaria separata, denominata PlayerScoreboardMoveEditorView
per questo, che per ora non fa nulla con i dati reali e mostra i valori statici che sono stati impostati tramite Interface Builder all'interno del file PlayerScoreboardMoveEditorView.xib
.
Devi dargli dei dati.
Lo farai allo stesso modo di GameScoreboardEditorViewController
e GameScoreboardEditorViewModel
.
Apri il gruppo ViewModel nel progetto Xcode e definisci qui il nuovo protocollo.
Crea un nuovo file chiamato PlayerScoreboardMoveEditorViewModel.swift
e inserisci il codice seguente:
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() }
Questo protocollo ViewModel è stato progettato per adattarsi al tuo PlayerScoreboardMoveEditorView
, proprio come hai fatto nella vista genitore, GameScoreboardEditorViewController
.
Devi avere valori per le cinque diverse mosse che un utente può fare e devi reagire quando l'utente tocca uno dei pulsanti di azione. Hai anche bisogno di una String
per il nome del giocatore.
Dopo averlo fatto, crea una classe concreta che implementi questo protocollo, proprio come hai fatto con la vista genitore ( GameScoreboardEditorViewController
).
Quindi, crea un'implementazione di questo protocollo: crea un nuovo file, PlayerScoreboardMoveEditorViewModelFromPlayer.swift
e rendi questo oggetto una sottoclasse di NSObject
. Inoltre, rendilo conforme al protocollo PlayerScoreboardMoveEditorViewModel
:
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))" } }
Ora, devi avere un oggetto che creerà questa istanza "dall'esterno" e lo imposterà come proprietà all'interno di PlayerScoreboardMoveEditorView
.
Ricordi come HomeViewController
era responsabile dell'impostazione della proprietà viewModel
su GameScoreboardEditorViewController
?
Allo stesso modo, GameScoreboardEditorViewController
è una vista principale del tuo PlayerScoreboardMoveEditorView
e quel GameScoreboardEditorViewController
sarà responsabile della creazione degli oggetti PlayerScoreboardMoveEditorViewModel
.
Devi prima espandere il tuo GameScoreboardEditorViewModel
.
Apri GameScoreboardEditorViewMode
l e aggiungi queste due proprietà:
var homePlayers: [PlayerScoreboardMoveEditorViewModel] { get } var awayPlayers: [PlayerScoreboardMoveEditorViewModel] { get }
Inoltre, aggiorna il GameScoreboardEditorViewModelFromGame
con queste due proprietà appena sopra il metodo initWithGame
:

let homePlayers: [PlayerScoreboardMoveEditorViewModel] let awayPlayers: [PlayerScoreboardMoveEditorViewModel]
Aggiungi queste due righe all'interno initWithGame
:
self.homePlayers = GameScoreboardEditorViewModelFromGame.playerViewModels(from: game.homeTeam.players, game: game) self.awayPlayers = GameScoreboardEditorViewModelFromGame.playerViewModels(from: game.awayTeam.players, game: game)
E, naturalmente, aggiungi il metodo playerViewModelsWithPlayers
mancante:
// 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 }
Grande!
Hai aggiornato il tuo ViewModel ( GameScoreboardEditorViewModel
) con l'array dei giocatori in casa e in trasferta. Devi ancora riempire questi due array.
Lo farai nello stesso posto in cui hai usato questo viewModel
per riempire l'interfaccia utente.
Apri GameScoreboardEditorViewController
e vai al metodo fillUI
. Aggiungi queste righe alla fine del metodo:
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]
Per il momento, hai errori di compilazione perché non hai aggiunto la proprietà viewModel
effettiva all'interno di PlayerScoreboardMoveEditorView
.
Aggiungi il codice seguente sopra il init method inside the
PlayerScoreboardMoveEditorView`.
var viewModel: PlayerScoreboardMoveEditorViewModel? { didSet { fillUI() } }
E implementa il metodo 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 }
Infine, esegui l'app e osserva come i dati negli elementi dell'interfaccia utente sono i dati effettivi dell'oggetto Game
.
A questo punto, hai un'app funzionale che utilizza il modello di progettazione MVVM.
Nasconde bene il modello dalla vista e la tua vista è molto più semplice di quanto sei abituato con l'MVC.
Fino a questo punto, hai creato un'app che contiene View e il suo ViewModel.
Quella vista ha anche sei istanze della stessa vista secondaria (vista giocatore) con il suo ViewModel.
Tuttavia, come puoi notare, puoi visualizzare i dati nell'interfaccia utente solo una volta (nel metodo fillUI
) e quei dati sono statici.
Se i tuoi dati nelle viste non cambieranno durante la vita di quella vista, allora hai una soluzione buona e pulita per usare MVVM in questo modo.
Rendere dinamico il ViewModel
Poiché i tuoi dati cambieranno, devi rendere dinamico il tuo ViewModel.
Ciò significa che quando il modello cambia, ViewModel dovrebbe modificare i valori delle sue proprietà pubbliche; propagherebbe la modifica alla vista, che è quella che aggiornerà l'interfaccia utente.
Ci sono molti modi per farlo.
Quando Model cambia, ViewModel riceve prima una notifica.
Hai bisogno di un meccanismo per propagare ciò che cambia fino alla vista.
Alcune delle opzioni includono RxSwift, che è una libreria piuttosto grande e richiede del tempo per abituarsi.
ViewModel potrebbe attivare NSNotification
s su ogni modifica del valore della proprietà, ma ciò aggiunge molto codice che richiede una gestione aggiuntiva, ad esempio la sottoscrizione alle notifiche e l'annullamento della sottoscrizione quando la vista viene deallocata.
Key-Value-Observing (KVO) è un'altra opzione, ma gli utenti confermeranno che la sua API non è elegante.
In questo tutorial utilizzerai i generici e le chiusure Swift, che sono ben descritti nell'articolo Bindings, Generics, Swift e MVVM.
Ora, torniamo all'app di esempio.
Vai al gruppo di progetto ViewModel e crea un nuovo file Swift, 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 } }
Utilizzerai questa classe per le proprietà nei tuoi ViewModel che prevedi di modificare durante il ciclo di vita di View.
Innanzitutto, inizia con PlayerScoreboardMoveEditorView
e il suo ViewModel, PlayerScoreboardMoveEditorViewModel
.
Apri PlayerScoreboardMoveEditorViewModel
e guarda le sue proprietà.
Poiché il playerName
non dovrebbe cambiare, puoi lasciarlo così com'è.
Le altre cinque proprietà (cinque tipi di mosse) cambieranno, quindi devi fare qualcosa al riguardo. La soluzione? La classe Dynamic
sopra menzionata che hai appena aggiunto al progetto.
All'interno PlayerScoreboardMoveEditorViewModel
rimuovi le definizioni per cinque stringhe che rappresentano il conteggio delle mosse e sostituiscilo con questo:
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 }
Ecco come dovrebbe apparire ora il protocollo ViewModel:
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() }
Questo tipo Dynamic
consente di modificare il valore di quella particolare proprietà e, allo stesso tempo, di notificare l'oggetto change-lister, che, in questo caso, sarà la View.
Ora, aggiorna l'effettiva implementazione di ViewModel PlayerScoreboardMoveEditorViewModelFromPlayer
.
Sostituisci questo:
var onePointMoveCount: String var twoPointMoveCount: String var assistMoveCount: String var reboundMoveCount: String var foulMoveCount: String
con quanto segue:
let onePointMoveCount: Dynamic<String> let twoPointMoveCount: Dynamic<String> let assistMoveCount: Dynamic<String> let reboundMoveCount: Dynamic<String> let foulMoveCount: Dynamic<String>
Nota: è possibile dichiarare queste proprietà come costanti con let
poiché non modificherai la proprietà effettiva. Modificherai la proprietà del value
sull'oggetto Dynamic
.
Ora ci sono errori di compilazione perché non hai inizializzato i tuoi oggetti Dynamic
.
All'interno del metodo init di PlayerScoreboardMoveEditorViewModelFromPlayer
, sostituisci l'inizializzazione delle proprietà di spostamento con questo:
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))")
All'interno di PlayerScoreboardMoveEditorViewModelFromPlayer
vai al metodo makeMove
e sostituiscilo con il codice seguente:
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))" }
Come puoi vedere, hai creato istanze della classe Dynamic
e le hai assegnato valori String
. Quando è necessario aggiornare i dati, non modificare la proprietà Dynamic
stessa; piuttosto aggiorna la sua proprietà di value
.
Grande! PlayerScoreboardMoveEditorViewModel
è dinamico ora.
Utilizziamolo e andiamo alla vista che ascolterà effettivamente questi cambiamenti.
Apri PlayerScoreboardMoveEditorView
e il suo metodo fillUI
(dovresti vedere errori di compilazione in questo metodo a questo punto poiché stai tentando di assegnare un valore String
al tipo di oggetto Dynamic
.)
Sostituisci le righe "errate":
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
con quanto segue:
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 }
Quindi, implementa i cinque metodi che rappresentano le azioni di spostamento (sezione Button Action ):
@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() }
Esegui l'app e fai clic su alcuni pulsanti di spostamento. Vedrai come cambiano i valori del contatore all'interno delle visualizzazioni del giocatore quando fai clic sul pulsante di azione.
Hai finito con PlayerScoreboardMoveEditorView
e PlayerScoreboardMoveEditorViewModel
.
Questo era semplice.
Ora devi fare lo stesso con la tua vista principale ( GameScoreboardEditorViewController
).
Innanzitutto, apri GameScoreboardEditorViewModel
e osserva quali valori dovrebbero cambiare durante il ciclo di vita della vista.
Sostituisci le definizioni time
, score
, isFinished
, isPaused
con le versioni Dynamic
:
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 } }
Vai all'implementazione ViewModel ( GameScoreboardEditorViewModelFromGame
) e fai lo stesso con le proprietà dichiarate nel protocollo.
Sostituisci questo:
var time: String var score: String var isFinished: Bool var isPaused: Bool
con quanto segue:
let time: Dynamic<String> let score: Dynamic<String> let isFinished: Dynamic<Bool> let isPaused: Dynamic<Bool>
Riceverai alcuni errori, ora, perché hai modificato il tipo di ViewModel da String
and Bool
a Dynamic<String>
e Dynamic<Bool>
.
Risolviamolo.
Correggi il metodo togglePause
sostituendolo con il seguente:
func togglePause() { if isPaused.value { startTimer() } else { pauseTimer() } self.isPaused.value = !isPaused.value }
Nota come l'unico cambiamento è che non imposti più il valore della proprietà direttamente sulla proprietà. Invece, lo imposta sulla proprietà value
dell'oggetto.
Ora, correggi il metodo initWithGame
sostituendo questo:
self.time = GameScoreboardEditorViewModelFromGame.timeRemainingPretty(game) self.score = GameScoreboardEditorViewModelFromGame.scorePretty(game) self.isFinished = game.isFinished self.isPaused = true
con quanto segue:
self.time = Dynamic(GameScoreboardEditorViewModelFromGame.timeRemainingPretty(for: game)) self.score = Dynamic(GameScoreboardEditorViewModelFromGame.scorePretty(for: game)) self.isFinished = Dynamic(game.isFinished) self.isPaused = Dynamic(true)
Dovresti ottenere il punto ora.
Stai avvolgendo i valori primitivi, come String
, Int
e Bool
, con le versioni Dynamic<T>
di quegli oggetti, che ti danno il meccanismo di binding leggero.
Hai un altro errore da correggere.
Nel metodo startTimer
, sostituisci la riga di errore con:
self.time.value = GameScoreboardEditorViewModelFromGame.timeRemainingPretty(for: self.game)
Hai aggiornato il tuo ViewModel per renderlo dinamico, proprio come hai fatto con il ViewModel del giocatore. Ma devi comunque aggiornare la tua vista ( GameScoreboardEditorViewController
).
Sostituisci l'intero metodo fillUI
con questo:
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] }
L'unica differenza è che hai modificato le quattro proprietà dinamiche e aggiunto listener di modifiche a ciascuna di esse.
A questo punto, se esegui la tua app, attivando il pulsante Avvia/Pausa avvierà e metterà in pausa il timer del gioco. Viene utilizzato per i time-out durante il gioco.
Hai quasi finito tranne che il punteggio non cambia nell'interfaccia utente, quando premi uno dei pulsanti dei punti (pulsante 1
e 2
punti).
Questo perché non hai realmente propagato le modifiche al punteggio nell'oggetto del modello di Game
sottostante fino al ViewModel.
Quindi, apri l'oggetto Modello di Game
per un piccolo esame. Controlla il suo metodo updateScore
.
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) }
Questo metodo fa due cose importanti.
Innanzitutto, imposta la proprietà isFinished
su true
se il gioco è terminato in base ai punteggi di entrambe le squadre.
Successivamente, pubblica una notifica che il punteggio è cambiato. Ascolterai questa notifica in GameScoreboardEditorViewModelFromGame
e aggiornerai il valore del punteggio dinamico nel metodo del gestore delle notifiche.
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
.