Учебное пособие по Swift: введение в шаблон проектирования MVVM
Опубликовано: 2022-03-11Итак, вы начинаете новый проект iOS, получили от дизайнера все необходимые документы в форматах .pdf
и .sketch
, и у вас уже есть видение того, как вы будете создавать это новое приложение.
Вы начинаете переносить экраны пользовательского интерфейса из эскизов дизайнера в ViewController
.swift
, .xib
и .storyboard
.
UITextField
здесь, UITableView
там, еще несколько UILabels
и щепотка UIButtons
. Также включены IBOutlets
и IBActions
. Все хорошо, мы все еще в зоне пользовательского интерфейса.
Однако пришло время что-то сделать со всеми этими элементами пользовательского интерфейса; UIButtons
будут получать касания пальцев, UILabels
и UITableViews
потребуется, чтобы кто-то сказал им, что отображать и в каком формате.
Внезапно у вас появилось более 3000 строк кода.
Вы закончили с большим количеством спагетти-кода.
Первым шагом для решения этой проблемы является применение шаблона проектирования Model-View-Controller (MVC). Однако у этой модели есть свои проблемы. Появляется шаблон проектирования Model-View-ViewModel (MVVM), который спасает положение.
Работа со спагетти-кодом
В мгновение ока ваш начальный ViewController
стал слишком умным и слишком массивным.
Сетевой код, код анализа данных, код корректировки данных для представления пользовательского интерфейса, уведомления о состоянии приложения, изменения состояния пользовательского интерфейса. Весь этот код заключен внутри if
-ology одного файла, который нельзя использовать повторно и который подходит только для этого проекта.
Ваш код ViewController
стал печально известным спагетти-кодом.
Как это произошло?
Вероятная причина примерно такая:
Вы спешили посмотреть, как внутренние данные ведут себя внутри UITableView
, поэтому вы поместили несколько строк сетевого кода во временный метод ViewController
только для того, чтобы получить этот .json
из сети. Затем вам нужно было обработать данные внутри этого .json
, поэтому для этого вы написали еще один временный метод. Или, что еще хуже, вы сделали это внутри одного и того же метода.
ViewController
продолжал расти, когда появился код авторизации пользователя. Затем форматы данных начали меняться, пользовательский интерфейс развивался и нуждался в некоторых радикальных изменениях, и вы просто продолжали добавлять больше if
в уже массивную if
-ологию.
Но почему UIViewController
вышел из-под контроля?
UIViewController
— это логическое место для начала работы над вашим кодом пользовательского интерфейса. Он представляет собой физический экран, который вы видите при использовании любого приложения на вашем устройстве iOS. Даже Apple использует UIViewControllers
в своем основном системном приложении, когда переключается между различными приложениями и анимированными пользовательскими интерфейсами.
Apple основывает свою абстракцию пользовательского интерфейса внутри UIViewController
, поскольку он лежит в основе кода пользовательского интерфейса iOS и является частью шаблона проектирования MVC .
Переход на шаблон проектирования MVC
В шаблоне проектирования MVC представление должно быть неактивным и отображать только подготовленные данные по запросу.
Контроллер должен работать с данными модели , чтобы подготовить их для представлений , которые затем отображают эти данные.
Представление также отвечает за уведомление контроллера о любых действиях, таких как прикосновения пользователя.
Как уже упоминалось, UIViewController
обычно является отправной точкой в создании экрана пользовательского интерфейса. Обратите внимание, что в своем имени он содержит как «представление», так и «контроллер». Это означает, что он «управляет представлением». Это не означает, что внутри должен быть код «контроллера» и «представления».
Эта смесь кода представления и контроллера часто происходит, когда вы перемещаете IBOutlets
небольших подпредставлений внутрь UIViewController
и управляете этими подпредставлениями непосредственно из UIViewController
. Вместо этого вы должны были обернуть этот код внутри пользовательского подкласса UIView
.
Легко понять, что это может привести к пересечению путей кода представления и контроллера.
МВВМ спешит на помощь
Вот где шаблон MVVM пригодится.
Поскольку UIViewController
должен быть контроллером в шаблоне MVC, и он уже много делает с представлениями , мы можем объединить их в представление нашего нового шаблона — MVVM .
В шаблоне проектирования MVVM модель такая же, как и в шаблоне MVC. Он представляет простые данные.
Представление представлено UIView
или UIViewController
, сопровождаемыми их .xib
и .storyboard
, которые должны отображать только подготовленные данные. (Мы не хотим иметь код NSDateFormatter
, например, внутри представления.)
Только простая отформатированная строка, полученная из ViewModel .
ViewModel скрывает весь асинхронный сетевой код, код подготовки данных для визуального представления и код, прослушивающий изменения модели . Все они скрыты за четко определенным API, смоделированным для соответствия этому конкретному представлению .
Одним из преимуществ использования MVVM является тестирование. Поскольку ViewModel является чистым NSObject
(или struct
, например) и не связан с кодом UIKit
, вы можете легче протестировать его в своих модульных тестах, не затрагивая код пользовательского интерфейса.
Теперь View ( UIViewController
/ UIView
) стал намного проще, а ViewModel действует как связующее звено между Model и View .
Применение MVVM в Swift
Чтобы показать вам MVVM в действии, вы можете скачать и изучить пример проекта Xcode, созданного для этого руководства, здесь. В этом проекте используются Swift 3 и Xcode 8.1.
Есть две версии проекта: Starter и Finished.
Финишная версия — это законченное мини-приложение, где Стартер — это тот же проект, но без реализованных методов и объектов.
Во-первых, я предлагаю вам загрузить проект Starter и следовать этому руководству. Если вам нужна краткая справка по проекту на будущее, загрузите Завершенный проект.
Введение в учебный проект
Учебный проект представляет собой баскетбольное приложение для отслеживания действий игроков во время игры.
Он используется для быстрого отслеживания действий пользователя и общего счета в игре с пикапом.
Две команды играют до тех пор, пока не будет достигнуто 15 очков (с разницей не менее двух очков). Каждый игрок может набрать от одного до двух очков, и каждый игрок может ассистировать, подбирать и фолить.
Иерархия проекта выглядит так:
Модель
-
Game.swift
- Содержит игровую логику, отслеживает общий счет, отслеживает ходы каждого игрока.
-
Team.swift
- Содержит название команды и список игроков (по три игрока в каждой команде).
-
Player.swift
- Один игрок с именем.
Вид
-
HomeViewController.swift
- Контроллер корневого представления, который представляет
GameScoreboardEditorViewController
- Контроллер корневого представления, который представляет
-
GameScoreboardEditorViewController.swift
- Дополнен представлением Interface Builder в
Main.storyboard
. - Экран интереса для этого урока.
- Дополнен представлением Interface Builder в
-
PlayerScoreboardMoveEditorView.swift
- Дополнен представлением Interface Builder в
PlayerScoreboardMoveEditorView.xib
- Подвид приведенного выше представления также использует шаблон проектирования MVVM.
- Дополнен представлением Interface Builder в
ViewModel
- Группа
ViewModel
пуста, это то, что вы будете создавать в этом руководстве.
Загруженный проект Xcode уже содержит заполнители для объектов View ( UIView
и UIViewController
). Проект также содержит несколько специально созданных объектов, созданных для демонстрации одного из способов предоставления данных объектам ViewModel (группа Services
).
Группа « Extensions
» содержит полезные расширения для кода пользовательского интерфейса, которые не входят в рамки этого руководства и не требуют пояснений.
Если вы запустите приложение в этот момент, оно покажет готовый пользовательский интерфейс, но ничего не произойдет, когда пользователь нажмет кнопки.
Это связано с тем, что вы создали только представления и IBActions
, не подключая их к логике приложения и не заполняя элементы пользовательского интерфейса данными из модели (из объекта Game
, как мы узнаем позже).
Соединение представления и модели с ViewModel
В шаблоне проектирования MVVM представление ничего не должно знать о модели. Единственное, что View знает, это как работать с ViewModel.
Начните с изучения вашего представления.
В файле GameScoreboardEditorViewController.swift
метод fillUI
на данный момент пуст. Это место, где вы хотите заполнить пользовательский интерфейс данными. Для этого вам необходимо предоставить данные для ViewController
. Вы делаете это с объектом ViewModel.
Сначала создайте объект ViewModel, содержащий все необходимые данные для этого ViewController
.
Перейдите в группу проекта ViewModel Xcode, которая будет пустой, создайте файл GameScoreboardEditorViewModel.swift
и сделайте его протоколом.
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(); }
Использование подобных протоколов позволяет сохранять чистоту и чистоту; вам нужно только определить данные, которые вы будете использовать.
Затем создайте реализацию для этого протокола.
Создайте новый файл с именем GameScoreboardEditorViewModelFromGame.swift
и сделайте этот объект подклассом NSObject
.
Кроме того, сделайте так, чтобы он соответствовал протоколу 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)") } }
Обратите внимание, что вы предоставили все необходимое для того, чтобы ViewModel работала через инициализатор.
Вы предоставили ему объект Game
, который является моделью под этой ViewModel.
Если вы запустите приложение сейчас, оно все равно не будет работать, потому что вы не подключили эти данные ViewModel к самому представлению.
Итак, вернитесь к файлу GameScoreboardEditorViewController.swift
и создайте общедоступное свойство с именем viewModel
.
Сделайте его типа GameScoreboardEditorViewModel
.
Поместите его прямо перед методом viewDidLoad
внутри GameScoreboardEditorViewController.swift
.
var viewModel: GameScoreboardEditorViewModel? { didSet { fillUI() } }
Далее вам нужно реализовать метод fillUI
.
Обратите внимание, как этот метод вызывается из двух мест: наблюдателя свойства viewModel
( didSet
) и метода viewDidLoad
. Это связано с тем, что мы можем создать ViewController
и назначить ему ViewModel, прежде чем прикрепить его к представлению (до вызова метода viewDidLoad
).
С другой стороны, вы можете присоединить представление ViewController к другому представлению и вызвать viewDidLoad
, но если в это время не задана viewModel
, ничего не произойдет.
Вот почему сначала вам нужно проверить, все ли настроено для ваших данных, чтобы заполнить пользовательский интерфейс. Важно защитить свой код от неожиданного использования.
Итак, перейдите к методу fillUI
и замените его следующим кодом:
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) }
Теперь реализуйте метод pauseButtonPress
:
@IBAction func pauseButtonPress(_ sender: AnyObject) { viewModel?.togglePause() }
Все, что вам нужно сделать сейчас, это установить фактическое свойство viewModel
в этом ViewController
. Вы делаете это «извне».
Откройте файл HomeViewController.swift
и раскомментируйте ViewModel; создайте и настройте линии в методе showGameScoreboardEditorViewController
:
// uncomment this when view model is implemented let viewModel = GameScoreboardEditorViewModelFromGame(withGame: game) controller.viewModel = viewModel
Теперь запустите приложение. Это должно выглядеть примерно так:
Средний вид, отвечающий за счет, время и названия команд, больше не показывает значения, заданные в конструкторе интерфейсов.
Теперь он показывает значения из самого объекта ViewModel, который получает данные из фактического объекта Model (объект Game
).
Превосходно! А как насчет просмотров игроков? Эти кнопки по-прежнему ничего не делают.
Вы знаете, что у вас есть шесть представлений для отслеживания движений игроков.
Для этого вы создали отдельное подпредставление с именем PlayerScoreboardMoveEditorView
, которое пока ничего не делает с реальными данными и отображает статические значения, которые были установлены с помощью построителя интерфейсов внутри файла PlayerScoreboardMoveEditorView.xib
.
Вам нужно дать ему некоторые данные.
Вы сделаете это так же, как с GameScoreboardEditorViewController
и GameScoreboardEditorViewModel
.
Откройте группу ViewModel в проекте Xcode и определите здесь новый протокол.
Создайте новый файл с именем PlayerScoreboardMoveEditorViewModel.swift
и поместите в него следующий код:
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() }
Этот протокол ViewModel был разработан, чтобы соответствовать вашему PlayerScoreboardMoveEditorView
, точно так же, как вы сделали это в родительском представлении, GameScoreboardEditorViewController
.
Вам нужно иметь значения для пяти различных движений, которые может сделать пользователь, и вам нужно реагировать, когда пользователь касается одной из кнопок действий. Вам также нужна String
для имени игрока.
После того, как вы это сделаете, создайте конкретный класс, реализующий этот протокол, точно так же, как вы сделали с родительским представлением ( GameScoreboardEditorViewController
).
Затем создайте реализацию этого протокола: создайте новый файл, назовите его PlayerScoreboardMoveEditorViewModelFromPlayer.swift
и сделайте этот объект подклассом NSObject
. Кроме того, сделайте так, чтобы он соответствовал протоколу 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))" } }
Теперь вам нужен объект, который создаст этот экземпляр «извне» и установит его как свойство внутри PlayerScoreboardMoveEditorView
.
Помните, как HomeViewController
отвечал за установку свойства viewModel
в GameScoreboardEditorViewController
?
Точно так же GameScoreboardEditorViewController
является родительским представлением вашего PlayerScoreboardMoveEditorView
, и этот GameScoreboardEditorViewController
будет отвечать за создание объектов PlayerScoreboardMoveEditorViewModel
.
Сначала вам нужно расширить GameScoreboardEditorViewModel
.
Откройте GameScoreboardEditorViewMode
l и добавьте эти два свойства:
var homePlayers: [PlayerScoreboardMoveEditorViewModel] { get } var awayPlayers: [PlayerScoreboardMoveEditorViewModel] { get }
Кроме того, обновите GameScoreboardEditorViewModelFromGame
, указав эти два свойства сразу над методом initWithGame
:

let homePlayers: [PlayerScoreboardMoveEditorViewModel] let awayPlayers: [PlayerScoreboardMoveEditorViewModel]
Добавьте эти две строки внутрь initWithGame
:
self.homePlayers = GameScoreboardEditorViewModelFromGame.playerViewModels(from: game.homeTeam.players, game: game) self.awayPlayers = GameScoreboardEditorViewModelFromGame.playerViewModels(from: game.awayTeam.players, game: game)
И, конечно же, добавьте отсутствующий метод 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 }
Здорово!
Вы обновили ViewModel ( GameScoreboardEditorViewModel
) с массивом домашних и гостевых игроков. Вам все еще нужно заполнить эти два массива.
Вы сделаете это в том же месте, где вы использовали эту viewModel
для заполнения пользовательского интерфейса.
Откройте GameScoreboardEditorViewController
и перейдите к методу fillUI
. Добавьте эти строки в конец метода:
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]
На данный момент у вас есть ошибки сборки, потому что вы не добавили фактическое свойство viewModel
внутрь PlayerScoreboardMoveEditorView
.
Добавьте следующий код над init method inside the
PlayerScoreboardMoveEditorView`.
var viewModel: PlayerScoreboardMoveEditorViewModel? { didSet { fillUI() } }
И реализуем метод 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 }
Наконец, запустите приложение и посмотрите, насколько данные в элементах пользовательского интерфейса являются фактическими данными из объекта Game
.
На данный момент у вас есть функциональное приложение, использующее шаблон проектирования MVVM.
Он красиво скрывает модель от представления, и ваше представление намного проще, чем вы привыкли к MVC.
До этого момента вы создали приложение, содержащее View и его ViewModel.
Это представление также имеет шесть экземпляров одного и того же подпредставления (представление игрока) с его ViewModel.
Однако, как вы могли заметить, вы можете отображать данные в пользовательском интерфейсе только один раз (в методе fillUI
), и эти данные являются статическими.
Если ваши данные в представлениях не будут меняться в течение жизни этого представления, то у вас есть хорошее и чистое решение для использования MVVM таким образом.
Делаем ViewModel динамическим
Поскольку ваши данные будут меняться, вам нужно сделать ViewModel динамическим.
Это означает, что при изменении модели ViewModel должна изменить значения своих общедоступных свойств; это передаст изменение обратно в представление, которое обновит пользовательский интерфейс.
Есть много способов сделать это.
Когда модель изменяется, ViewModel сначала получает уведомление.
Вам нужен какой-то механизм для распространения изменений до представления.
Некоторые из опций включают RxSwift, довольно большую библиотеку, к которой нужно некоторое время, чтобы привыкнуть.
ViewModel может NSNotification
при каждом изменении значения свойства, но это добавляет много кода, который требует дополнительной обработки, такой как подписка на уведомления и отказ от подписки, когда представление освобождается.
Key-Value-Observing (KVO) — еще один вариант, но пользователи подтвердят, что его API не прихотлив.
В этом руководстве вы будете использовать дженерики и замыкания Swift, которые хорошо описаны в статье Bindings, Generics, Swift и MVVM.
Теперь вернемся к примеру приложения.
Перейдите в группу проектов ViewModel и создайте новый файл 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 } }
Вы будете использовать этот класс для свойств в ваших моделях представления, которые вы ожидаете изменить в течение жизненного цикла представления.
Во-первых, начните с PlayerScoreboardMoveEditorView
и его ViewModel, PlayerScoreboardMoveEditorViewModel
.
Откройте PlayerScoreboardMoveEditorViewModel
и посмотрите его свойства.
Поскольку не ожидается, что playerName
изменится, вы можете оставить его как есть.
Остальные пять свойств (пять типов движений) изменятся, так что с этим нужно что-то делать. Решение? Вышеупомянутый Dynamic
класс, который вы только что добавили в проект.
Внутри PlayerScoreboardMoveEditorViewModel
удалите определения для пяти строк, представляющих количество ходов, и замените их следующим:
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 }
Вот как сейчас должен выглядеть протокол 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() }
Этот Dynamic
тип позволяет вам изменить значение этого конкретного свойства и в то же время уведомить объект прослушивателя изменений, которым в данном случае будет представление.
Теперь обновите фактическую реализацию ViewModel PlayerScoreboardMoveEditorViewModelFromPlayer
.
Замените это:
var onePointMoveCount: String var twoPointMoveCount: String var assistMoveCount: String var reboundMoveCount: String var foulMoveCount: String
со следующим:
let onePointMoveCount: Dynamic<String> let twoPointMoveCount: Dynamic<String> let assistMoveCount: Dynamic<String> let reboundMoveCount: Dynamic<String> let foulMoveCount: Dynamic<String>
Примечание. Можно объявить эти свойства как константы с помощью let
, так как вы не будете изменять фактическое свойство. Вы измените value
свойства Dynamic
объекта.
Теперь есть ошибки сборки, потому что вы не инициализировали свои Dynamic
объекты.
Внутри метода инициализации PlayerScoreboardMoveEditorViewModelFromPlayer
замените инициализацию свойств перемещения следующим образом:
self.onePointMoveCount = Dynamic("\(game.playerMoveCount(for: player, move: .onePoint))") self.twoPointMoveCount = Dynamic("\(game.playerMoveCount(for: player, move: .twoPoints))") self.assistMoveCount = Dynamic("\(game.playerMoveCount(for: player, move: .assist))") self.reboundMoveCount = Dynamic("\(game.playerMoveCount(for: player, move: .rebound))") self.foulMoveCount = Dynamic("\(game.playerMoveCount(for: player, move: .foul))")
Внутри PlayerScoreboardMoveEditorViewModelFromPlayer
перейдите к методу makeMove
и замените его следующим кодом:
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))" }
Как видите, вы создали экземпляры класса Dynamic
и присвоили ему String
значения. Когда вам нужно обновить данные, не изменяйте само свойство Dynamic
; скорее обновите его свойство value
.
Здорово! PlayerScoreboardMoveEditorViewModel
теперь является динамическим.
Давайте воспользуемся этим и перейдем к представлению, которое действительно будет прослушивать эти изменения.
Откройте PlayerScoreboardMoveEditorView
и его метод fillUI
(на этом этапе вы должны увидеть ошибки сборки в этом методе, так как вы пытаетесь присвоить значение String
типу объекта Dynamic
.)
Замените «ошибочные» строки:
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
со следующим:
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 }
Затем реализуйте пять методов, представляющих действия перемещения (раздел «Действие кнопки »):
@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() }
Запустите приложение и нажмите на несколько кнопок перемещения. Вы увидите, как значения счетчика внутри представлений игрока меняются, когда вы нажимаете кнопку действия.
Вы закончили работу с PlayerScoreboardMoveEditorView
и PlayerScoreboardMoveEditorViewModel
.
Это было просто.
Теперь вам нужно сделать то же самое с вашим основным представлением ( GameScoreboardEditorViewController
).
Сначала откройте GameScoreboardEditorViewModel
и посмотрите, какие значения должны измениться в течение жизненного цикла представления.
Замените time
, score
, isFinished
, isPaused
на 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 } }
Перейдите к реализации ViewModel ( GameScoreboardEditorViewModelFromGame
) и сделайте то же самое со свойствами, объявленными в протоколе.
Замените это:
var time: String var score: String var isFinished: Bool var isPaused: Bool
со следующим:
let time: Dynamic<String> let score: Dynamic<String> let isFinished: Dynamic<Bool> let isPaused: Dynamic<Bool>
Теперь вы получите несколько ошибок, потому что вы изменили тип ViewModel со String
и Bool
на Dynamic<String>
и Dynamic<Bool>
.
Давайте исправим это.
Исправьте метод togglePause
, заменив его следующим:
func togglePause() { if isPaused.value { startTimer() } else { pauseTimer() } self.isPaused.value = !isPaused.value }
Обратите внимание, что единственным изменением является то, что вы больше не устанавливаете значение свойства непосредственно в свойстве. Вместо этого вы устанавливаете его в свойстве value
объекта.
Теперь исправьте метод initWithGame
, заменив это:
self.time = GameScoreboardEditorViewModelFromGame.timeRemainingPretty(game) self.score = GameScoreboardEditorViewModelFromGame.scorePretty(game) self.isFinished = game.isFinished self.isPaused = true
со следующим:
self.time = Dynamic(GameScoreboardEditorViewModelFromGame.timeRemainingPretty(for: game)) self.score = Dynamic(GameScoreboardEditorViewModelFromGame.scorePretty(for: game)) self.isFinished = Dynamic(game.isFinished) self.isPaused = Dynamic(true)
Вы должны понять суть сейчас.
Вы оборачиваете примитивные значения, такие как String
, Int
и Bool
, в Dynamic<T>
версии этих объектов, что дает вам упрощенный механизм привязки.
Вам нужно исправить еще одну ошибку.
В методе startTimer
замените строку ошибки на:
self.time.value = GameScoreboardEditorViewModelFromGame.timeRemainingPretty(for: self.game)
Вы обновили свою ViewModel, чтобы она стала динамической, точно так же, как вы сделали это с ViewModel игрока. Но вам все равно нужно обновить свой View ( GameScoreboardEditorViewController
).
Замените весь метод fillUI
следующим:
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] }
Единственное отличие состоит в том, что вы изменили свои четыре динамических свойства и добавили прослушиватели изменений к каждому из них.
На этом этапе, если вы запустите свое приложение, переключение кнопки « Пуск/Пауза » запустит и приостановит игровой таймер. Используется для тайм-аутов во время игры.
Вы почти закончили, за исключением того, что счет не меняется в пользовательском интерфейсе, когда вы нажимаете одну из кнопок очков (кнопки 1
и 2
очков).
Это связано с тем, что вы на самом деле не распространяли изменения оценки в базовом объекте модели Game
до ViewModel.
Итак, откройте объект Game
model для небольшого изучения. Проверьте его метод 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) }
Этот метод делает две важные вещи.
Во-первых, он устанавливает для свойства isFinished
значение true
, если игра завершена на основе результатов обеих команд.
После этого он публикует уведомление о том, что счет изменился. Вы будете прослушивать это уведомление в GameScoreboardEditorViewModelFromGame
и обновлять значение динамической оценки в методе обработчика уведомлений.
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.
Что теперь?
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
.