Swift Tutorial: un'introduzione al modello di progettazione MVVM

Pubblicato: 2022-03-11

Quindi 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.

3.000 righe di codice Swift

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 .

Correlati: I 10 errori più comuni che gli sviluppatori iOS non sanno che stanno facendo

Aggiornamento al modello di progettazione MVC

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 .

Modello di progettazione 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

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.

Applicazione di basket

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ì:

Gerarchia del progetto

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
  • GameScoreboardEditorViewController.swift
    • Integrato con la visualizzazione Interface Builder in Main.storyboard .
    • Schermata di interesse per questo tutorial.
  • PlayerScoreboardMoveEditorView.swift
    • Integrato con la visualizzazione Interface Builder in PlayerScoreboardMoveEditorView.xib
    • La visualizzazione secondaria della vista sopra, utilizza anche il modello di progettazione MVVM.

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:

App per iOS

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 .

App per iOS

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.

App per iOS

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 .

Related: Working With Static Patterns: A Swift MVVM Tutorial