Samouczek Swift: wprowadzenie do wzorca projektowego MVVM
Opublikowany: 2022-03-11Zaczynasz więc nowy projekt iOS, otrzymałeś od projektanta wszystkie potrzebne dokumenty .pdf
i .sketch
, i masz już wizję, jak zbudujesz tę nową aplikację.
Rozpoczynasz przesyłanie ekranów interfejsu użytkownika ze szkiców projektanta do ViewController
.swift
, .xib
i .storyboard
.
UITextField
tutaj, UITableView
tam, kilka innych UILabels
i szczypta UIButtons
. Uwzględniono również IBOutlets
i IBActions
. Wszystko dobrze, wciąż jesteśmy w strefie UI.
Jednak nadszedł czas, aby zrobić coś z tymi wszystkimi elementami interfejsu użytkownika; UIButtons
będą otrzymywać dotknięcia palcem, UILabels
i UITableViews
będą potrzebować kogoś, kto powie im, co wyświetlić i w jakim formacie.
Nagle masz ponad 3000 linii kodu.
Skończyło się na dużej ilości kodu do spaghetti.
Pierwszym krokiem do rozwiązania tego problemu jest zastosowanie wzorca projektowego Model-View-Controller (MVC). Jednak ten wzór ma swoje własne problemy. Pojawia się wzorzec projektowy Model-View-ViewModel (MVVM), który ratuje sytuację.
Radzenie sobie z kodem spaghetti
W krótkim czasie twój początkowy ViewController
stał się zbyt inteligentny i zbyt masywny.
Kod sieciowy, kod parsowania danych, kod korekty danych dla prezentacji interfejsu użytkownika, powiadomienia o stanie aplikacji, zmiany stanu interfejsu użytkownika. Cały ten kod uwięziony wewnątrz if
-ology jednego pliku, którego nie można ponownie wykorzystać i który zmieściłby się tylko w tym projekcie.
Twój kod ViewController
stał się niesławnym kodem spaghetti.
Jak to się stało?
Prawdopodobny powód jest taki:
Spieszyłeś się, aby zobaczyć, jak dane zaplecza zachowują się w UITableView
, więc umieściłeś kilka wierszy kodu sieciowego w metodzie tymczasowej ViewController
, aby pobrać ten .json
z sieci. Następnie musiałeś przetworzyć dane w tym .json
, więc napisałeś kolejną metodę tymczasową , aby to osiągnąć. Albo, co gorsza, zrobiłeś to w ten sam sposób.
ViewController
rósł, gdy pojawił się kod autoryzacji użytkownika. Potem formaty danych zaczęły się zmieniać, interfejs użytkownika ewoluował i wymagał radykalnych zmian, a ty po prostu dodawałeś więcej ifs do i if
if
ologii.
Ale jak to się stało, że UIViewController
się spod kontroli?
UIViewController
to logiczne miejsce do rozpoczęcia pracy nad kodem interfejsu użytkownika. Reprezentuje fizyczny ekran, który widzisz podczas korzystania z dowolnej aplikacji na urządzeniu z systemem iOS. Nawet Apple używa UIViewControllers
w swojej głównej aplikacji systemowej, gdy przełącza się między różnymi aplikacjami i animowanymi interfejsami użytkownika.
Firma Apple opiera swoją abstrakcję interfejsu użytkownika w UIViewController
, ponieważ jest ona rdzeniem kodu interfejsu użytkownika systemu iOS i częścią wzorca projektowego MVC .
Uaktualnianie do wzorca projektowego MVC
We wzorcu projektowym MVC View ma być nieaktywny i na żądanie wyświetla tylko przygotowane dane.
Kontroler powinien pracować na danych modelu , aby przygotować je do Views , które następnie wyświetlają te dane.
View jest również odpowiedzialny za powiadamianie Administratora o wszelkich akcjach, takich jak dotknięcia użytkownika.
Jak wspomniano, UIViewController
jest zwykle punktem wyjścia do tworzenia ekranu interfejsu użytkownika. Zauważ, że w swojej nazwie zawiera zarówno „widok”, jak i „kontroler”. Oznacza to, że „steruje widokiem”. Nie oznacza to, że zarówno kod „kontrolera”, jak i „widoku” powinien znaleźć się w środku.
Ta mieszanka kodu widoku i kontrolera często występuje, gdy przenosisz IBOutlets
małych widoków podrzędnych wewnątrz UIViewController
i manipulujesz tymi widokami podrzędnymi bezpośrednio z UIViewController
. Zamiast tego powinieneś umieścić ten kod w niestandardowej podklasie UIView
.
Łatwo zauważyć, że może to prowadzić do skrzyżowania ścieżek kodu widoku i kontrolera.
MVVM na ratunek
Tutaj przydaje się wzorzec MVVM .
Ponieważ UIViewController
ma być kontrolerem we wzorcu MVC i już dużo robi z Views , możemy scalić je z widokiem naszego nowego wzorca - MVVM .
We wzorcu projektowym MVVM Model jest taki sam jak we wzorcu MVC. Reprezentuje proste dane.
Widok jest reprezentowany przez obiekty UIView
lub UIViewController
wraz z ich .xib
i .storyboard
, które powinny wyświetlać tylko przygotowane dane. (Nie chcemy mieć kodu NSDateFormatter
, na przykład, wewnątrz widoku).
Tylko prosty, sformatowany ciąg, który pochodzi z ViewModel .
ViewModel ukrywa cały asynchroniczny kod sieciowy, kod przygotowania danych do prezentacji wizualnej i kod nasłuchujący zmian modelu . Wszystko to jest ukryte za dobrze zdefiniowanym interfejsem API, dopasowanym do tego konkretnego widoku .
Jedną z korzyści płynących z używania MVVM jest testowanie. Ponieważ ViewModel jest czystym NSObject
(lub na przykład struct
) i nie jest połączony z kodem UIKit
, można go łatwiej przetestować w testach jednostkowych bez wpływu na kod interfejsu użytkownika.
Teraz View ( UIViewController
/ UIView
) stał się znacznie prostszy, podczas gdy ViewModel działa jako klej między Model i View .
Stosowanie MVVM w Swift
Aby pokazać MVVM w akcji, możesz pobrać i zbadać przykładowy projekt Xcode utworzony dla tego samouczka tutaj. Ten projekt używa Swift 3 i Xcode 8.1.
Istnieją dwie wersje projektu: Starter i Finished.
Wersja Finished to kompletna mini aplikacja, w której Starter jest tym samym projektem, ale bez zaimplementowanych metod i obiektów.
Najpierw proponuję pobrać projekt Starter i postępować zgodnie z tym samouczkiem. Jeśli potrzebujesz szybkiego odniesienia do projektu na później, pobierz gotowy projekt.
Wprowadzenie do projektu samouczka
Projekt samouczka to aplikacja do koszykówki służąca do śledzenia działań graczy podczas gry.
Służy do szybkiego śledzenia ruchów użytkownika i ogólnego wyniku w grze typu pickup.
Dwie drużyny grają aż do osiągnięcia 15 punktów (z co najmniej dwupunktową różnicą). Każdy gracz może zdobyć od jednego punktu do dwóch punktów, a każdy gracz może asystować, odbijać i faulować.
Hierarchia projektu wygląda tak:
Model
-
Game.swift
- Zawiera logikę gry, śledzi ogólny wynik, śledzi ruchy każdego gracza.
-
Team.swift
- Zawiera nazwę drużyny i listę graczy (po trzech graczy w każdej drużynie).
-
Player.swift
- Pojedynczy gracz z imieniem.
Pogląd
-
HomeViewController.swift
- Kontroler widoku głównego, który przedstawia
GameScoreboardEditorViewController
- Kontroler widoku głównego, który przedstawia
-
GameScoreboardEditorViewController.swift
- Uzupełniony o widok konstruktora interfejsu w
Main.storyboard
. - Ekran zainteresowania tego samouczka.
- Uzupełniony o widok konstruktora interfejsu w
-
PlayerScoreboardMoveEditorView.swift
- Uzupełniony o widok Interface Builder w
PlayerScoreboardMoveEditorView.xib
- Podwidok powyższego widoku również wykorzystuje wzorzec projektowy MVVM.
- Uzupełniony o widok Interface Builder w
ZobaczModel
- Grupa
ViewModel
jest pusta, właśnie to będziesz budował w tym samouczku.
Pobrany projekt Xcode zawiera już symbole zastępcze dla obiektów View ( UIView
i UIViewController
). Projekt zawiera również kilka niestandardowych obiektów stworzonych w celu zademonstrowania jednego ze sposobów dostarczania danych do obiektów ViewModel (grupa Services
).
Grupa Extensions
zawiera przydatne rozszerzenia kodu interfejsu użytkownika, które nie są objęte zakresem tego samouczka i nie wymagają wyjaśnień.
Jeśli uruchomisz aplikację w tym momencie, wyświetli gotowy interfejs użytkownika, ale nic się nie stanie, gdy użytkownik naciśnie przyciski.
Dzieje się tak, ponieważ utworzyłeś tylko widoki i IBActions
bez łączenia ich z logiką aplikacji i bez wypełniania elementów interfejsu użytkownika danymi z modelu (z obiektu Game
, o czym dowiemy się później).
Łączenie View i Model z ViewModel
We wzorcu projektowym MVVM widok nie powinien nic wiedzieć o modelu. Jedyną rzeczą, którą wie View, jest praca z ViewModel.
Zacznij od zbadania swojego widoku.
W pliku GameScoreboardEditorViewController.swift
metoda fillUI
jest w tym momencie pusta. To jest miejsce, w którym chcesz zapełnić interfejs użytkownika danymi. Aby to osiągnąć, musisz podać dane dla ViewController
. Robisz to za pomocą obiektu ViewModel.
Najpierw utwórz obiekt ViewModel, który zawiera wszystkie niezbędne dane dla tego ViewController
.
Przejdź do grupy projektów ViewModel Xcode, która będzie pusta, utwórz plik GameScoreboardEditorViewModel.swift
i ustaw go jako protokół.
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(); }
Używanie takich protokołów sprawia, że wszystko jest ładne i czyste; musisz tylko zdefiniować dane, których będziesz używać.
Następnie utwórz implementację dla tego protokołu.
Utwórz nowy plik o nazwie GameScoreboardEditorViewModelFromGame.swift
i ustaw ten obiekt jako podklasę NSObject
.
Ponadto upewnij się, że jest zgodny z protokołem 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)") } }
Zauważ, że podałeś wszystko, co jest potrzebne, aby ViewModel działał przez inicjator.
Dostarczyłeś mu obiekt Game
, który jest modelem pod tym ViewModel.
Jeśli teraz uruchomisz aplikację, nadal nie będzie działać, ponieważ nie połączyłeś danych ViewModel z samym widokiem.
Wróć więc do pliku GameScoreboardEditorViewController.swift
i utwórz właściwość publiczną o nazwie viewModel
.
Uczyń go typu GameScoreboardEditorViewModel
.
Umieść go tuż przed metodą viewDidLoad
w GameScoreboardEditorViewController.swift
.
var viewModel: GameScoreboardEditorViewModel? { didSet { fillUI() } }
Następnie musisz zaimplementować metodę fillUI
.
Zwróć uwagę, jak ta metoda jest wywoływana z dwóch miejsc: obserwatora właściwości viewModel
( didSet
) i metody viewDidLoad
. Dzieje się tak, ponieważ możemy stworzyć ViewController
i przypisać do niego ViewModel przed dołączeniem go do widoku (przed wywołaniem metody viewDidLoad
).
Z drugiej strony możesz dołączyć widok ViewController do innego widoku i wywołać viewDidLoad
, ale jeśli viewModel
nie jest ustawione w tym czasie, nic się nie stanie.
Dlatego najpierw musisz sprawdzić, czy wszystko jest ustawione, aby Twoje dane wypełniły interfejs użytkownika. Ważne jest, aby chronić swój kod przed nieoczekiwanym użyciem.
Przejdź więc do metody fillUI
i zastąp ją następującym kodem:
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) }
Teraz zaimplementuj metodę pauseButtonPress
:
@IBAction func pauseButtonPress(_ sender: AnyObject) { viewModel?.togglePause() }
Wszystko, co musisz teraz zrobić, to ustawić rzeczywistą właściwość viewModel
na tym ViewController
. Robisz to „z zewnątrz”.
Otwórz plik HomeViewController.swift
i odkomentuj ViewModel; utwórz i skonfiguruj wiersze w metodzie showGameScoreboardEditorViewController
:
// uncomment this when view model is implemented let viewModel = GameScoreboardEditorViewModelFromGame(withGame: game) controller.viewModel = viewModel
Teraz uruchom aplikację. Powinno to wyglądać mniej więcej tak:
Widok środkowy, który odpowiada za wynik, czas i nazwy drużyn, nie pokazuje już wartości ustawionych w Interface Builder.
Teraz pokazuje wartości z samego obiektu ViewModel, który pobiera swoje dane z rzeczywistego obiektu Model (obiekt Game
).
Doskonały! Ale co z poglądami graczy? Te przyciski nadal nic nie robią.
Wiesz, że masz sześć widoków na śledzenie ruchów gracza.
W tym celu utworzyłeś oddzielny widok podrzędny o nazwie PlayerScoreboardMoveEditorView
, który na razie nie robi nic z rzeczywistymi danymi i wyświetla wartości statyczne, które zostały ustawione za pomocą konstruktora interfejsu w pliku PlayerScoreboardMoveEditorView.xib
.
Musisz podać mu jakieś dane.
Zrobisz to w taki sam sposób, jak w przypadku GameScoreboardEditorViewController
i GameScoreboardEditorViewModel
.
Otwórz grupę ViewModel w projekcie Xcode i zdefiniuj tutaj nowy protokół.
Utwórz nowy plik o nazwie PlayerScoreboardMoveEditorViewModel.swift
i umieść w nim następujący kod:
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() }
Ten protokół ViewModel został zaprojektowany tak, aby pasował do Twojego PlayerScoreboardMoveEditorView
, tak jak w widoku nadrzędnym, GameScoreboardEditorViewController
.
Musisz mieć wartości dla pięciu różnych ruchów, które użytkownik może wykonać, i musisz zareagować, gdy użytkownik dotknie jednego z przycisków akcji. Potrzebujesz również String
dla nazwy gracza.
Po wykonaniu tej czynności utwórz konkretną klasę, która implementuje ten protokół, tak jak w przypadku widoku nadrzędnego ( GameScoreboardEditorViewController
).
Następnie utwórz implementację tego protokołu: Utwórz nowy plik, nazwij go PlayerScoreboardMoveEditorViewModelFromPlayer.swift
i ustaw ten obiekt jako podklasę NSObject
. Ponadto upewnij się, że jest zgodny z protokołem 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))" } }
Teraz musisz mieć obiekt, który utworzy tę instancję „z zewnątrz” i ustawi ją jako właściwość wewnątrz PlayerScoreboardMoveEditorView
.
Pamiętasz, jak HomeViewController
był odpowiedzialny za ustawienie właściwości viewModel
w GameScoreboardEditorViewController
?
W ten sam sposób GameScoreboardEditorViewController
jest widokiem nadrzędnym Twojego PlayerScoreboardMoveEditorView
, a GameScoreboardEditorViewController
będzie odpowiedzialny za tworzenie obiektów PlayerScoreboardMoveEditorViewModel
.
Najpierw musisz rozwinąć swój GameScoreboardEditorViewModel
.
Otwórz GameScoreboardEditorViewMode
l i dodaj te dwie właściwości:
var homePlayers: [PlayerScoreboardMoveEditorViewModel] { get } var awayPlayers: [PlayerScoreboardMoveEditorViewModel] { get }
Zaktualizuj także GameScoreboardEditorViewModelFromGame
o te dwie właściwości tuż nad metodą initWithGame
:
let homePlayers: [PlayerScoreboardMoveEditorViewModel] let awayPlayers: [PlayerScoreboardMoveEditorViewModel]
Dodaj te dwie linie w initWithGame
:

self.homePlayers = GameScoreboardEditorViewModelFromGame.playerViewModels(from: game.homeTeam.players, game: game) self.awayPlayers = GameScoreboardEditorViewModelFromGame.playerViewModels(from: game.awayTeam.players, game: game)
I oczywiście dodaj brakującą metodę playerViewModelsWithPlayers
:
// 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 }
Świetnie!
Zaktualizowałeś swój ViewModel ( GameScoreboardEditorViewModel
) o tablicę graczy w domu i na wyjeździe. Nadal musisz wypełnić te dwie tablice.
Zrobisz to w tym samym miejscu, w którym użyłeś tego viewModel
do wypełnienia interfejsu użytkownika.
Otwórz GameScoreboardEditorViewController
i przejdź do metody fillUI
. Dodaj te wiersze na końcu metody:
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]
W tej chwili masz błędy kompilacji, ponieważ nie dodałeś rzeczywistej właściwości viewModel
wewnątrz PlayerScoreboardMoveEditorView
.
Dodaj następujący kod powyżej init method inside the
PlayerScoreboardMoveEditorView`.
var viewModel: PlayerScoreboardMoveEditorViewModel? { didSet { fillUI() } }
I zaimplementuj metodę 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 }
Na koniec uruchom aplikację i zobacz, jak dane w elementach interfejsu użytkownika są rzeczywistymi danymi z obiektu Game
.
W tym momencie masz działającą aplikację, która używa wzorca projektowego MVVM.
Ładnie ukrywa model z widoku, a widok jest znacznie prostszy niż przyzwyczajony do MVC.
Do tego momentu utworzyłeś aplikację zawierającą View i jego ViewModel.
Ten widok ma również sześć wystąpień tego samego podwidoku (widoku odtwarzacza) z jego ViewModel.
Jednak, jak możesz zauważyć, dane w interfejsie użytkownika można wyświetlić tylko raz (w metodzie fillUI
), a dane te są statyczne.
Jeśli dane w widokach nie ulegną zmianie w okresie istnienia tego widoku, masz dobre i czyste rozwiązanie do korzystania z MVVM w ten sposób.
Dynamiczny ViewModel
Ponieważ Twoje dane ulegną zmianie, musisz uczynić swój ViewModel dynamicznym.
Oznacza to, że gdy model się zmieni, ViewModel powinien zmienić swoje wartości właściwości publicznych; propaguje zmianę z powrotem do widoku, który jest tym, który zaktualizuje interfejs użytkownika.
Jest na to wiele sposobów.
Gdy model się zmieni, ViewModel zostanie powiadomiony jako pierwszy.
Potrzebujesz jakiegoś mechanizmu do propagowania zmian w widoku.
Niektóre opcje obejmują RxSwift, która jest dość dużą biblioteką i przyzwyczajenie się do niej zajmuje trochę czasu.
ViewModel może uruchamiać NSNotification
s przy każdej zmianie wartości właściwości, ale to dodaje dużo kodu, który wymaga dodatkowej obsługi, takiej jak subskrybowanie powiadomień i anulowanie subskrypcji, gdy widok zostanie cofnięty.
Obserwacja wartości klucza (KVO) to kolejna opcja, ale użytkownicy potwierdzą, że jego interfejs API nie jest wyszukany.
W tym samouczku użyjesz generycznych i zamknięć Swift, które są ładnie opisane w artykule Bindings, Generics, Swift i MVVM.
Wróćmy teraz do przykładowej aplikacji.
Przejdź do grupy projektów ViewModel i utwórz nowy plik 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 } }
Użyjesz tej klasy dla właściwości w swoich ViewModels, które spodziewasz się zmienić w cyklu życia widoku.
Najpierw zacznij od PlayerScoreboardMoveEditorView
i jego ViewModel, PlayerScoreboardMoveEditorViewModel
.
Otwórz PlayerScoreboardMoveEditorViewModel
i spójrz na jego właściwości.
Ponieważ playerName
nie powinien się zmienić, możesz go pozostawić bez zmian.
Pozostałe pięć właściwości (pięć rodzajów ruchów) ulegnie zmianie, więc musisz coś z tym zrobić. Rozwiązanie? Wspomniana wyżej klasa Dynamic
, którą właśnie dodałeś do projektu.
Wewnątrz PlayerScoreboardMoveEditorViewModel
usuń definicje pięciu ciągów, które reprezentują liczbę ruchów i zastąp je następującym:
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 }
Tak powinien teraz wyglądać protokół 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() }
Ten typ Dynamic
umożliwia zmianę wartości tej konkretnej właściwości, jednocześnie powiadamiając obiekt nasłuchiwania zmian, którym w tym przypadku będzie widok.
Teraz zaktualizuj rzeczywistą implementację ViewModel PlayerScoreboardMoveEditorViewModelFromPlayer
.
Zastąp to:
var onePointMoveCount: String var twoPointMoveCount: String var assistMoveCount: String var reboundMoveCount: String var foulMoveCount: String
z następującymi:
let onePointMoveCount: Dynamic<String> let twoPointMoveCount: Dynamic<String> let assistMoveCount: Dynamic<String> let reboundMoveCount: Dynamic<String> let foulMoveCount: Dynamic<String>
Uwaga: Można zadeklarować te właściwości jako stałe za pomocą let
, ponieważ nie zmienisz rzeczywistej właściwości. Zmienisz właściwość value
obiektu Dynamic
.
Teraz występują błędy kompilacji, ponieważ nie zainicjowałeś obiektów Dynamic
.
Wewnątrz metody init PlayerScoreboardMoveEditorViewModelFromPlayer
, zastąp inicjalizację właściwości przenoszenia następującym:
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))")
Wewnątrz PlayerScoreboardMoveEditorViewModelFromPlayer
przejdź do metody makeMove
i zastąp ją następującym kodem:
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))" }
Jak widać, utworzyłeś instancje klasy Dynamic
i przypisałeś jej wartości String
. Gdy musisz zaktualizować dane, nie zmieniaj samej właściwości Dynamic
; raczej zaktualizować jego właściwość value
.
Świetnie! PlayerScoreboardMoveEditorViewModel
jest teraz dynamiczny.
Wykorzystajmy to i przejdźmy do widoku, który będzie rzeczywiście nasłuchiwał tych zmian.
Otwórz PlayerScoreboardMoveEditorView
i jego metodę fillUI
(w tym momencie powinieneś zobaczyć błędy kompilacji w tej metodzie, ponieważ próbujesz przypisać wartość String
do typu obiektu Dynamic
).
Zastąp „błędne” wiersze:
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
z następującymi:
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 }
Następnie zaimplementuj pięć metod reprezentujących akcje przenoszenia (sekcja Akcja przycisku ):
@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() }
Uruchom aplikację i kliknij kilka przycisków ruchu. Zobaczysz, jak zmieniają się wartości liczników w widokach odtwarzacza po kliknięciu przycisku akcji.
Skończyłeś z PlayerScoreboardMoveEditorView
i PlayerScoreboardMoveEditorViewModel
.
To było proste.
Teraz musisz zrobić to samo z głównym widokiem ( GameScoreboardEditorViewController
).
Najpierw otwórz GameScoreboardEditorViewModel
i zobacz, które wartości mają się zmienić podczas cyklu życia widoku.
Zastąp definicje time
, score
, isFinished
, isPaused
wersjami 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 } }
Przejdź do implementacji ViewModel ( GameScoreboardEditorViewModelFromGame
) i zrób to samo z właściwościami zadeklarowanymi w protokole.
Zastąp to:
var time: String var score: String var isFinished: Bool var isPaused: Bool
z następującymi:
let time: Dynamic<String> let score: Dynamic<String> let isFinished: Dynamic<Bool> let isPaused: Dynamic<Bool>
Otrzymasz teraz kilka błędów, ponieważ zmieniłeś typ ViewModel z String
i Bool
na Dynamic<String>
i Dynamic<Bool>
.
Naprawmy to.
Napraw metodę togglePause
, zastępując ją następującą:
func togglePause() { if isPaused.value { startTimer() } else { pauseTimer() } self.isPaused.value = !isPaused.value }
Zauważ, że jedyną zmianą jest to, że nie ustawiasz już wartości właściwości bezpośrednio we właściwości. Zamiast tego ustawiasz go we właściwości value
obiektu.
Teraz napraw metodę initWithGame
, zastępując to:
self.time = GameScoreboardEditorViewModelFromGame.timeRemainingPretty(game) self.score = GameScoreboardEditorViewModelFromGame.scorePretty(game) self.isFinished = game.isFinished self.isPaused = true
z następującymi:
self.time = Dynamic(GameScoreboardEditorViewModelFromGame.timeRemainingPretty(for: game)) self.score = Dynamic(GameScoreboardEditorViewModelFromGame.scorePretty(for: game)) self.isFinished = Dynamic(game.isFinished) self.isPaused = Dynamic(true)
Powinieneś teraz zrozumieć.
Zawijasz wartości pierwotne, takie jak String
, Int
i Bool
, z wersjami Dynamic<T>
tych obiektów, które zapewniają lekki mechanizm powiązania.
Masz jeszcze jeden błąd do naprawienia.
W metodzie startTimer
zamień wiersz błędu na:
self.time.value = GameScoreboardEditorViewModelFromGame.timeRemainingPretty(for: self.game)
Zaktualizowałeś swój ViewModel tak, aby był dynamiczny, tak samo jak w przypadku ViewModel odtwarzacza. Ale nadal musisz zaktualizować swój widok ( GameScoreboardEditorViewController
).
Zastąp całą metodę fillUI
następującym:
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] }
Jedyną różnicą jest to, że zmieniłeś swoje cztery właściwości dynamiczne i dodałeś detektory zmian do każdej z nich.
W tym momencie, jeśli uruchomisz swoją aplikację, przełączenie przycisku Start/Pauza uruchomi i wstrzyma licznik czasu gry. Jest to używane do przerw na żądanie podczas gry.
Prawie skończyłeś, z wyjątkiem tego, że wynik nie zmienia się w interfejsie użytkownika, gdy naciśniesz jeden z przycisków punktowych (przycisk 1
i 2
punkty).
Dzieje się tak, ponieważ tak naprawdę nie propagowałeś zmian punktacji w bazowym obiekcie modelu Game
aż do ViewModel.
Otwórz więc obiekt modelu Game
, aby trochę go zbadać. Sprawdź jego metodę 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) }
Ta metoda robi dwie ważne rzeczy.
Po pierwsze, ustawia właściwość isFinished
na true
, jeśli gra zostanie zakończona na podstawie wyników obu drużyn.
Następnie publikuje powiadomienie, że wynik się zmienił. Będziesz nasłuchiwać tego powiadomienia w GameScoreboardEditorViewModelFromGame
i zaktualizujesz wartość dynamicznego wyniku w metodzie obsługi powiadomień.
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.
Co teraz?
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
.