Tutorial Swift: O introducere în modelul de proiectare MVVM
Publicat: 2022-03-11Deci, începeți un nou proiect iOS, ați primit de la designer toate documentele .pdf
și .sketch
necesare și aveți deja o viziune despre cum veți construi această nouă aplicație.
Începeți să transferați ecranele UI din schițele designerului în fișierele dvs. ViewController
.swift
, .xib
și .storyboard
.
UITextField
aici, UITableView
acolo, încă câteva UILabels
și un vârf de UIButtons
. IBOutlets
și IBActions
sunt, de asemenea, incluse. Toate bune, suntem încă în zona UI.
Cu toate acestea, este timpul să facem ceva cu toate aceste elemente de UI; UIButtons
vor primi atingeri cu degetul, UILabels
și UITableViews
vor avea nevoie de cineva care să le spună ce să afișeze și în ce format.
Dintr-o dată, aveți mai mult de 3.000 de linii de cod.
Ai ajuns cu o mulțime de coduri de spaghete.
Primul pas pentru a rezolva acest lucru este aplicarea modelului de proiectare Model-View-Controller (MVC). Cu toate acestea, acest model are propriile sale probleme. Apare modelul de design Model-View-ViewModel (MVVM) care salvează ziua.
Se confruntă cu codul spaghetelor
În cel mai scurt timp, ViewController
-ul dvs. de pornire a devenit prea inteligent și prea masiv.
Cod de rețea, cod de analiză a datelor, cod de ajustări ale datelor pentru prezentarea UI, notificări despre starea aplicației, modificări ale stării UI. Tot acel cod închis în interiorul if
-ology unui singur fișier care nu poate fi reutilizat și s-ar încadra doar în acest proiect.
Codul dvs. ViewController
a devenit infamul cod spaghetti.
Cum sa întâmplat asta?
Motivul probabil este ceva de genul acesta:
Te-ai grăbit să vezi cum se comportă datele back-end în interiorul UITableView
, așa că ai introdus câteva linii de cod de rețea într-o metodă temporară a ViewController
doar pentru a prelua acel .json
din rețea. Apoi, trebuia să procesați datele din acel .json
, așa că ați scris încă o metodă temporară pentru a realiza asta. Sau, și mai rău, ai făcut asta în aceeași metodă.
ViewController
a continuat să crească când a apărut codul de autorizare a utilizatorului. Apoi formatele de date au început să se schimbe, UI a evoluat și a avut nevoie de unele schimbări radicale și ați continuat să adăugați mai multe if
s într-o if
-ology deja masivă.
Dar, de ce UIViewController
este ceea ce a scăpat de sub control?
UIViewController
este locul logic pentru a începe lucrul la codul UI. Acesta reprezintă ecranul fizic pe care îl vedeți în timp ce utilizați orice aplicație cu dispozitivul iOS. Chiar și Apple folosește UIViewControllers
în aplicația de sistem principală atunci când comută între diferite aplicații și interfețele sale animate.
Apple își bazează abstracția UI în interiorul UIViewController
, deoarece se află în centrul codului UI iOS și face parte din modelul de design MVC .
Actualizarea la modelul de design MVC
În modelul de design MVC, View ar trebui să fie inactiv și afișează numai datele pregătite la cerere.
Controlerul ar trebui să lucreze la datele modelului pentru a le pregăti pentru Vizualizări , care apoi afișează datele respective.
View este, de asemenea, responsabil pentru notificarea Controlorului despre orice acțiuni, cum ar fi atingerile utilizatorului.
După cum am menționat, UIViewController
este de obicei punctul de plecare în construirea unui ecran UI. Observați că, în numele său, conține atât „vizualizarea”, cât și „controlerul”. Aceasta înseamnă că „controlează vederea”. Nu înseamnă că atât codul „controller” cât și codul „vizualizare” ar trebui să intre înăuntru.
Acest amestec de vizualizare și cod de controler apare adesea atunci când mutați IBOutlets
-uri ale subview-urilor mici în interiorul UIViewController
și manipulați acele subview-uri direct din UIViewController
. În schimb, ar fi trebuit să împachetați acel cod într-o subclasă personalizată UIView
.
Ușor de observat că acest lucru ar putea duce la încrucișarea căilor de cod View și Controller.
MVVM pentru salvare
Aici este util modelul MVVM .
Deoarece UIViewController
ar trebui să fie un Controller în modelul MVC și deja face multe cu Vizualizările , le putem îmbina în Vederea noului nostru model - MVVM .
În modelul de proiectare MVVM, Modelul este același ca în modelul MVC. Reprezintă date simple.
Vizualizarea este reprezentată de obiectele UIView
sau UIViewController
, însoțite de fișierele lor .xib
și .storyboard
, care ar trebui să afișeze numai datele pregătite. (Nu dorim să avem cod NSDateFormatter
, de exemplu, în interiorul View.)
Doar un șir simplu, formatat, care provine din ViewModel .
ViewModel ascunde tot codul de rețea asincron, codul de pregătire a datelor pentru prezentarea vizuală și ascultarea codului pentru modificările modelului . Toate acestea sunt ascunse în spatele unui API bine definit, modelat pentru a se potrivi cu această vizualizare particulară.
Unul dintre beneficiile utilizării MVVM este testarea. Deoarece ViewModel este NSObject
pur (sau struct
, de exemplu) și nu este cuplat cu codul UIKit
, îl puteți testa mai ușor în testele unitare fără ca acesta să afecteze codul UI.
Acum, vizualizarea ( UIViewController
/ UIView
) a devenit mult mai simplă, în timp ce ViewModel acționează ca lipici între Model și View .
Aplicarea MVVM în Swift
Pentru a vă arăta MVVM în acțiune, puteți descărca și examina exemplul de proiect Xcode creat pentru acest tutorial aici. Acest proiect folosește Swift 3 și Xcode 8.1.
Există două versiuni ale proiectului: Starter și Finished.
Versiunea Finished este o mini aplicație finalizată, în care Starter este același proiect, dar fără metodele și obiectele implementate.
În primul rând, vă sugerez să descărcați proiectul Starter și să urmați acest tutorial. Dacă aveți nevoie de o referință rapidă a proiectului pentru mai târziu, descărcați proiectul Terminat .
Tutorial Introducere proiect
Proiectul tutorial este o aplicație de baschet pentru urmărirea acțiunilor jucătorilor în timpul jocului.
Este folosit pentru urmărirea rapidă a mișcărilor utilizatorului și a scorului general într-un joc de ridicare.
Două echipe joacă până când se ajunge la scorul de 15 (cu o diferență de cel puțin două puncte). Fiecare jucător poate înscrie de la un punct la două puncte, iar fiecare jucător poate asista, reveni și faultează.
Ierarhia proiectului arată astfel:
Model
-
Game.swift
- Conține logica jocului, urmărește scorul general, urmărește mișcările fiecărui jucător.
-
Team.swift
- Conține numele echipei și lista jucătorilor (trei jucători în fiecare echipă).
-
Player.swift
- Un singur jucător cu un nume.
Vedere
-
HomeViewController.swift
- Controler de vizualizare rădăcină, care prezintă
GameScoreboardEditorViewController
- Controler de vizualizare rădăcină, care prezintă
-
GameScoreboardEditorViewController.swift
- Suplimentat cu vizualizarea Interface Builder în
Main.storyboard
. - Ecran de interes pentru acest tutorial.
- Suplimentat cu vizualizarea Interface Builder în
-
PlayerScoreboardMoveEditorView.swift
- Suplimentat cu vizualizarea Interface Builder în
PlayerScoreboardMoveEditorView.xib
- Subview a vederii de mai sus, folosește, de asemenea, modelul de design MVVM.
- Suplimentat cu vizualizarea Interface Builder în
ViewModel
- Grupul
ViewModel
este gol, acesta este ceea ce veți construi în acest tutorial.
Proiectul Xcode descărcat conține deja substituenți pentru obiectele View ( UIView
și UIViewController
). Proiectul conține, de asemenea, câteva obiecte personalizate realizate pentru a demonstra una dintre modalitățile de furnizare de date la obiectele ViewModel (grupul Services
).
Grupul Extensions
conține extensii utile pentru codul UI care nu fac parte din domeniul de aplicare al acestui tutorial și care se explică de la sine.
Dacă rulați aplicația în acest moment, aceasta va afișa interfața de utilizare finalizată, dar nu se întâmplă nimic atunci când un utilizator apasă butoanele.
Acest lucru se datorează faptului că ați creat doar vizualizări și IBActions
fără a le conecta la logica aplicației și fără a completa elementele UI cu datele din model (din obiectul Game
, așa cum vom afla mai târziu).
Conectarea View și Model cu ViewModel
În modelul de proiectare MVVM, View nu ar trebui să știe nimic despre Model. Singurul lucru pe care View îl știe este cum să lucreze cu un ViewModel.
Începeți prin a vă examina vizualizarea.
În fișierul GameScoreboardEditorViewController.swift
, metoda fillUI
este goală în acest moment. Acesta este locul în care doriți să populați interfața de utilizare cu date. Pentru a realiza acest lucru, trebuie să furnizați date pentru ViewController
. Faceți acest lucru cu un obiect ViewModel.
Mai întâi, creați un obiect ViewModel care conține toate datele necesare pentru acest ViewController
.
Accesați grupul de proiecte ViewModel Xcode, care va fi gol, creați un fișier GameScoreboardEditorViewModel.swift
și transformați-l într-un protocol.
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(); }
Folosirea unor astfel de protocoale menține lucrul frumos și curat; trebuie doar să definiți datele pe care le veți folosi.
Apoi, creați o implementare pentru acest protocol.
Creați un fișier nou, numit GameScoreboardEditorViewModelFromGame.swift
și faceți din acest obiect o subclasă a NSObject
.
De asemenea, faceți-l conform cu protocolul 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)") } }
Observați că ați furnizat tot ce este necesar pentru ca ViewModel să funcționeze prin inițializator.
I-ați furnizat obiectul Game
, care este Modelul de sub acest ViewModel.
Dacă rulați aplicația acum, aceasta tot nu va funcționa, deoarece nu ați conectat aceste date ViewModel la View, în sine.
Deci, reveniți la fișierul GameScoreboardEditorViewController.swift
și creați o proprietate publică numită viewModel
.
Faceți-l de tipul GameScoreboardEditorViewModel
.
Plasați-l chiar înaintea metodei viewDidLoad
în interiorul GameScoreboardEditorViewController.swift
.
var viewModel: GameScoreboardEditorViewModel? { didSet { fillUI() } }
Apoi, trebuie să implementați metoda fillUI
.
Observați cum această metodă este apelată din două locuri, observatorul proprietății viewModel
( didSet
) și metoda viewDidLoad
. Acest lucru se datorează faptului că putem crea un ViewController
și îi putem atribui un ViewModel înainte de a-l atașa la o vizualizare (înainte de apelarea metodei viewDidLoad
).
Pe de altă parte, puteți atașa vizualizarea ViewController la o altă vizualizare și puteți apela viewDidLoad
, dar dacă viewModel
nu este setat în acel moment, nu se va întâmpla nimic.
De aceea, mai întâi, trebuie să verificați dacă totul este setat pentru ca datele dvs. să umple interfața de utilizare. Este important să vă protejați codul împotriva utilizării neașteptate.
Deci, accesați metoda fillUI
și înlocuiți-o cu următorul cod:
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) }
Acum, implementați metoda pauseButtonPress
:
@IBAction func pauseButtonPress(_ sender: AnyObject) { viewModel?.togglePause() }
Tot ce trebuie să faceți acum este să setați proprietatea viewModel
reală pe acest ViewController
. Faceți asta „din exterior”.
Deschideți fișierul HomeViewController.swift
și decomentați ViewModel; creați și configurați linii în metoda showGameScoreboardEditorViewController
:
// uncomment this when view model is implemented let viewModel = GameScoreboardEditorViewModelFromGame(withGame: game) controller.viewModel = viewModel
Acum, rulați aplicația. Ar trebui să arate cam așa:
Vizualizarea din mijloc, care este responsabilă pentru scor, timp și numele echipelor, nu mai arată valorile setate în Interface Builder.
Acum, arată valorile de la obiectul ViewModel însuși, care își obține datele de la obiectul Model real (Obiectul Game
).
Excelent! Dar cum rămâne cu vederile jucătorilor? Butoanele alea încă nu fac nimic.
Știți că aveți șase vizualizări pentru urmărirea mișcărilor jucătorilor.
Ați creat o subvizualizare separată, numită PlayerScoreboardMoveEditorView
pentru asta, care nu face nimic cu datele reale deocamdată și afișează valorile statice care au fost setate prin Interface Builder în interiorul fișierului PlayerScoreboardMoveEditorView.xib
.
Trebuie să-i dai câteva date.
O veți face în același mod în care ați făcut-o cu GameScoreboardEditorViewController
și GameScoreboardEditorViewModel
.
Deschideți grupul ViewModel în proiectul Xcode și definiți noul protocol aici.
Creați un fișier nou numit PlayerScoreboardMoveEditorViewModel.swift
și introduceți următorul cod în interior:
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() }
Acest protocol ViewModel a fost conceput pentru a se potrivi PlayerScoreboardMoveEditorView
, la fel cum ați făcut în vizualizarea părinte, GameScoreboardEditorViewController
.
Trebuie să aveți valori pentru cele cinci mișcări diferite pe care le poate face un utilizator și trebuie să reacționați atunci când utilizatorul atinge unul dintre butoanele de acțiune. De asemenea, aveți nevoie de un String
pentru numele jucătorului.
După ce ați făcut acest lucru, creați o clasă concretă care implementează acest protocol, așa cum ați făcut cu vizualizarea părinte ( GameScoreboardEditorViewController
).
Apoi, creați o implementare a acestui protocol: creați un fișier nou, numiți-l PlayerScoreboardMoveEditorViewModelFromPlayer.swift
și faceți din acest obiect o subclasă a NSObject
. De asemenea, faceți-l conform cu protocolul 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))" } }
Acum, trebuie să aveți un obiect care să creeze această instanță „din exterior” și să o setați ca proprietate în interiorul PlayerScoreboardMoveEditorView
.
Vă amintiți cum HomeViewController
a fost responsabil pentru setarea proprietății viewModel
pe GameScoreboardEditorViewController
?
În același mod, GameScoreboardEditorViewController
este o vizualizare părinte a PlayerScoreboardMoveEditorView
și acel GameScoreboardEditorViewController
va fi responsabil pentru crearea obiectelor PlayerScoreboardMoveEditorViewModel
.
Mai întâi trebuie să extindeți GameScoreboardEditorViewModel
.
Deschide GameScoreboardEditorViewMode
l și adaugă aceste două proprietăți:
var homePlayers: [PlayerScoreboardMoveEditorViewModel] { get } var awayPlayers: [PlayerScoreboardMoveEditorViewModel] { get }
De asemenea, actualizați GameScoreboardEditorViewModelFromGame
cu aceste două proprietăți chiar deasupra metodei initWithGame
:
let homePlayers: [PlayerScoreboardMoveEditorViewModel] let awayPlayers: [PlayerScoreboardMoveEditorViewModel]
Adăugați aceste două linii în interiorul initWithGame
:

self.homePlayers = GameScoreboardEditorViewModelFromGame.playerViewModels(from: game.homeTeam.players, game: game) self.awayPlayers = GameScoreboardEditorViewModelFromGame.playerViewModels(from: game.awayTeam.players, game: game)
Și, desigur, adăugați metoda playerViewModelsWithPlayers
lipsă:
// 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 }
Grozav!
V-ați actualizat ViewModel ( GameScoreboardEditorViewModel
) cu matricea de jucători acasă și în deplasare. Mai trebuie să umpleți aceste două matrice.
Veți face acest lucru în același loc în care ați folosit acest viewModel
de vizualizare pentru a completa interfața de utilizare.
Deschideți GameScoreboardEditorViewController
și accesați metoda fillUI
. Adăugați aceste rânduri la sfârșitul metodei:
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]
Pentru moment, aveți erori de compilare, deoarece nu ați adăugat proprietatea reală viewModel
în PlayerScoreboardMoveEditorView
.
Adăugați următorul cod deasupra init method inside the
PlayerScoreboardMoveEditorView`.
var viewModel: PlayerScoreboardMoveEditorViewModel? { didSet { fillUI() } }
Și implementați metoda 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 }
În cele din urmă, rulați aplicația și vedeți cum datele din elementele UI sunt datele reale din obiectul Game
.
În acest moment, aveți o aplicație funcțională care utilizează modelul de design MVVM.
Ascunde frumos modelul din vizualizare, iar vizualizarea dvs. este mult mai simplă decât v-ați obișnuit cu MVC.
Până în acest moment, ați creat o aplicație care conține View și ViewModel-ul său.
Acea vizualizare are, de asemenea, șase exemple ale aceleiași subvizualizări (vizualizarea jucătorului) cu ViewModel-ul său.
Cu toate acestea, după cum puteți observa, puteți afișa date în UI o singură dată (în metoda fillUI
), iar datele respective sunt statice.
Dacă datele dvs. din vizualizări nu se vor schimba pe durata de viață a acelei vizualizări, atunci aveți o soluție bună și curată pentru a utiliza MVVM în acest fel.
Dinamizarea modelului de vizualizare
Deoarece datele dvs. se vor schimba, trebuie să vă faceți ViewModelul dinamic.
Acest lucru înseamnă că atunci când modelul se schimbă, ViewModel ar trebui să-și schimbe valorile proprietății publice; ar propaga modificarea înapoi în vizualizare, care este cea care va actualiza interfața de utilizare.
Există o mulțime de moduri de a face acest lucru.
Când modelul se schimbă, ViewModel este notificat mai întâi.
Aveți nevoie de un mecanism pentru a propaga ceea ce se modifică în vizualizare.
Unele dintre opțiuni includ RxSwift, care este o bibliotecă destul de mare și necesită ceva timp pentru a te obișnui.
ViewModel ar putea declanșa NSNotification
la fiecare modificare a valorii proprietății, dar acest lucru adaugă o mulțime de cod care necesită o gestionare suplimentară, cum ar fi abonarea la notificări și dezabonarea atunci când vizualizarea este dealocată.
Key-Value-Observing (KVO) este o altă opțiune, dar utilizatorii vor confirma că API-ul său nu este elegant.
În acest tutorial, veți folosi generice și închideri Swift, care sunt descrise frumos în articolul Legături, generice, Swift și MVVM.
Acum, să revenim la exemplul de aplicație.
Accesați grupul de proiecte ViewModel și creați un nou fișier 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 } }
Veți folosi această clasă pentru proprietățile din ViewModels pe care vă așteptați să le modificați în timpul ciclului de viață View.
În primul rând, începeți cu PlayerScoreboardMoveEditorView
și ViewModel-ul său, PlayerScoreboardMoveEditorViewModel
.
Deschideți PlayerScoreboardMoveEditorViewModel
și priviți proprietățile acestuia.
Deoarece playerName
nu se așteaptă să se schimbe, îl puteți lăsa așa cum este.
Celelalte cinci proprietăți (cinci tipuri de mutare) se vor schimba, așa că trebuie să faceți ceva în acest sens. Soluția? Clasa Dynamic
menționată mai sus pe care tocmai ați adăugat-o în proiect.
În interiorul PlayerScoreboardMoveEditorViewModel
eliminați definițiile pentru cinci șiruri care reprezintă numărul de mișcări și înlocuiți-l cu acesta:
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 }
Iată cum ar trebui să arate acum protocolul 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() }
Acest tip Dynamic
vă permite să modificați valoarea acelei proprietăți și, în același timp, să notificați obiectul de ascultător de schimbare, care, în acest caz, va fi vizualizarea.
Acum, actualizați implementarea actuală a ViewModel PlayerScoreboardMoveEditorViewModelFromPlayer
.
Înlocuiește asta:
var onePointMoveCount: String var twoPointMoveCount: String var assistMoveCount: String var reboundMoveCount: String var foulMoveCount: String
cu urmatoarele:
let onePointMoveCount: Dynamic<String> let twoPointMoveCount: Dynamic<String> let assistMoveCount: Dynamic<String> let reboundMoveCount: Dynamic<String> let foulMoveCount: Dynamic<String>
Notă: Este în regulă să declarați aceste proprietăți ca constante cu let
, deoarece nu veți modifica proprietatea reală. Veți schimba proprietatea value
pe obiectul Dynamic
.
Acum, există erori de compilare, deoarece nu ați inițializat obiectele Dynamic
.
În metoda de pornire a lui PlayerScoreboardMoveEditorViewModelFromPlayer
, înlocuiți inițializarea proprietăților de mutare cu aceasta:
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))")
În interiorul PlayerScoreboardMoveEditorViewModelFromPlayer
, accesați metoda makeMove
și înlocuiți-o cu următorul cod:
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))" }
După cum puteți vedea, ați creat instanțe ale clasei Dynamic
și i-ați atribuit valori String
. Când trebuie să actualizați datele, nu modificați proprietatea Dynamic
în sine; mai degrabă actualizați proprietatea de value
.
Grozav! PlayerScoreboardMoveEditorViewModel
este dinamic acum.
Să o folosim și să mergem la vizualizarea care va asculta de fapt aceste modificări.
Deschideți PlayerScoreboardMoveEditorView
și metoda sa fillUI
(ar trebui să vedeți erori de construcție în această metodă în acest moment, deoarece încercați să atribuiți valoare String
tipului de obiect Dynamic
.)
Înlocuiți liniile „eroare”:
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
cu urmatoarele:
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 }
Apoi, implementați cele cinci metode care reprezintă acțiuni de mutare (secțiunea Buton 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() }
Rulați aplicația și faceți clic pe unele butoane de mutare. Veți vedea cum se schimbă valorile contorului din vizualizările jucătorului când faceți clic pe butonul de acțiune.
Ați terminat cu PlayerScoreboardMoveEditorView
și PlayerScoreboardMoveEditorViewModel
.
Acest lucru a fost simplu.
Acum, trebuie să faceți același lucru cu vizualizarea principală ( GameScoreboardEditorViewController
).
Mai întâi, deschideți GameScoreboardEditorViewModel
și vedeți ce valori se așteaptă să se schimbe în timpul ciclului de viață al vizualizării.
Înlocuiți definițiile time
, score
, isFinished
, isPaused
cu versiunile 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 } }
Accesați implementarea ViewModel ( GameScoreboardEditorViewModelFromGame
) și faceți același lucru cu proprietățile declarate în protocol.
Înlocuiește asta:
var time: String var score: String var isFinished: Bool var isPaused: Bool
cu urmatoarele:
let time: Dynamic<String> let score: Dynamic<String> let isFinished: Dynamic<Bool> let isPaused: Dynamic<Bool>
Veți primi câteva erori, acum, deoarece ați schimbat tipul ViewModel din String
și Bool
în Dynamic<String>
și Dynamic<Bool>
.
Să reparăm asta.
Remediați metoda togglePause
înlocuind-o cu următoarele:
func togglePause() { if isPaused.value { startTimer() } else { pauseTimer() } self.isPaused.value = !isPaused.value }
Observați cum singura modificare este că nu mai setați valoarea proprietății direct pe proprietate. În schimb, îl setați pe proprietatea value
a obiectului.
Acum, remediați metoda initWithGame
înlocuind aceasta:
self.time = GameScoreboardEditorViewModelFromGame.timeRemainingPretty(game) self.score = GameScoreboardEditorViewModelFromGame.scorePretty(game) self.isFinished = game.isFinished self.isPaused = true
cu urmatoarele:
self.time = Dynamic(GameScoreboardEditorViewModelFromGame.timeRemainingPretty(for: game)) self.score = Dynamic(GameScoreboardEditorViewModelFromGame.scorePretty(for: game)) self.isFinished = Dynamic(game.isFinished) self.isPaused = Dynamic(true)
Ar trebui să înțelegi ideea acum.
Încheiați valorile primitive, cum ar fi String
, Int
și Bool
, cu versiuni Dynamic<T>
ale acelor obiecte, care vă oferă mecanismul de legare ușor.
Mai ai o eroare de remediat.
În metoda startTimer
, înlocuiți linia de eroare cu:
self.time.value = GameScoreboardEditorViewModelFromGame.timeRemainingPretty(for: self.game)
Ți-ai actualizat ViewModel-ul pentru a fi dinamic, la fel cum ai făcut-o cu ViewModel-ul playerului. Dar tot trebuie să vă actualizați View ( GameScoreboardEditorViewController
).
Înlocuiți întreaga metodă fillUI
cu aceasta:
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] }
Singura diferență este că v-ați schimbat cele patru proprietăți dinamice și ați adăugat ascultători de modificare la fiecare dintre ele.
În acest moment, dacă rulați aplicația, comutarea butonului Start/Pauză va porni și va întrerupe cronometrul jocului. Acesta este folosit pentru pauze în timpul jocului.
Aproape ați terminat, cu excepția faptului că scorul nu se schimbă în interfața de utilizare, când apăsați unul dintre butoanele de punct (butonul 1
și 2
puncte).
Acest lucru se datorează faptului că nu ați propagat cu adevărat modificările de scor în obiectul model de Game
de bază până la ViewModel.
Deci, deschideți obiectul model de Game
pentru o mică examinare. Verificați metoda 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) }
Această metodă face două lucruri importante.
În primul rând, setează proprietatea isFinished
la true
dacă jocul este terminat pe baza scorurilor ambelor echipe.
După aceea, postează o notificare că scorul s-a schimbat. Veți asculta această notificare în GameScoreboardEditorViewModelFromGame
și veți actualiza valoarea scorului dinamic în metoda de gestionare a notificărilor.
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
.