本文概述
因此, 你开始一个新的iOS项目, 从设计人员那里收到了所有需要的.pdf和.sketch文档, 并且你已经对如何构建此新应用程序有一个愿景。
你开始将UI屏幕从设计师的素描转移到ViewController的.swift, .xib和.storyboard文件中。
这里的UITextField, 那里的UITableView, 更多的UILabel和一些UIButton。 IBOutlets和IBAction也包括在内。很好, 我们仍然在UI区域中。
但是, 是时候使用所有这些UI元素了; UIButtons将接收手指触摸, UILabel和UITableViews将需要有人告诉他们要显示什么以及以什么格式显示。
突然, 你有超过3, 000行代码。
你最终得到了很多意大利面条代码。
解决此问题的第一步是应用模型视图控制器(MVC)设计模式。但是, 这种模式有其自身的问题。有Model-View-ViewModel(MVVM)设计模式, 可以节省时间。
处理意粉代码
你启动的ViewController很快就变得太聪明和太大了。
UI演示的网络代码, 数据解析代码, 数据调整代码, 应用程序状态通知, UI状态更改。所有这些代码都包含在单个文件的if-ology中, 这些代码无法重用, 并且仅适合该项目。
你的ViewController代码已成为臭名昭著的意大利面条代码。
那是怎么发生的?
可能的原因是这样的:
你急于查看后端数据在UITableView中的行为方式, 因此将几行网络代码放入ViewController的temp方法中, 只是为了从网络中获取该.json。接下来, 你需要处理.json中的数据, 因此你编写了另一种临时方法来完成该任务。或者, 甚至更糟的是, 你使用相同的方法进行了此操作。
当用户授权代码出现时, ViewController不断增长。然后, 数据格式开始发生变化, UI发生了变化, 需要进行一些根本性的改变, 而你只是不断地将更多的ifs添加到本已庞大的if-ology中。
但是, UIViewController怎么会失控呢?
UIViewController是开始处理UI代码的逻辑位置。它代表你在iOS设备上使用任何应用时看到的物理屏幕。甚至Apple在不同的应用程序及其动画UI之间切换时, 也会在其主系统应用程序中使用UIViewControllers。
苹果公司将其UI抽象基于UIViewController, 因为它是iOS UI代码的核心, 也是MVC设计模式的一部分。
相关:iOS开发人员不知道的10个最常见错误
升级到MVC设计模式
在MVC设计模式中, View应该处于非活动状态, 并且仅按需显示准备的数据。
Controller应该处理Model数据, 以将其准备好用于View, 然后显示该数据。
View还负责将任何操作(例如用户触摸)通知控制器。
如前所述, UIViewController通常是构建UI屏幕的起点。请注意, 它的名称同时包含”视图”和”控制器”。这意味着它”控制了视图”。这并不意味着”控制器”和”视图”代码都应该放在里面。
当你在UIViewController内移动小子视图的IBOutlet并直接从UIViewController操纵这些子视图时, 通常会发生视图和控制器代码的这种混合。相反, 你应该将该代码包装在自定义UIView子类中。
显而易见, 这可能导致View和Controller代码路径交叉。
MVVM抢救
这是MVVM模式派上用场的地方。
由于UIViewController应该是MVC模式的控制器, 并且已经对Views做了很多工作, 因此我们可以将它们合并到新模式MVVM的View中。
在MVVM设计模式中, 模型与在MVC模式中相同。它代表简单的数据。
视图由UIView或UIViewController对象以及它们的.xib和.storyboard文件表示, 它们仅应显示准备好的数据。 (例如, 我们不希望在视图中包含NSDateFormatter代码。)
仅来自ViewModel的简单格式化字符串。
ViewModel隐藏所有异步联网代码, 用于可视化表示的数据准备代码以及用于侦听Model更改的代码。所有这些都隐藏在精心定义的API后面, 该API建模为适合该特定View。
使用MVVM的好处之一就是测试。由于ViewModel是纯NSObject(例如结构), 并且未与UIKit代码结合使用, 因此你可以在单元测试中更轻松地对其进行测试, 而不会影响UI代码。
现在, 视图(UIViewController / UIView)变得更加简单, 而视图模型充当模型和视图之间的粘合剂。
在Swift中应用MVVM
为了向你展示实际中的MVVM, 你可以在此处下载并检查为本教程创建的示例Xcode项目。该项目使用Swift 3和Xcode 8.1。
该项目有两个版本:Starter和Finished。
Finished版本是一个完整的迷你应用程序, 其中Starter是同一项目, 但没有实现方法和对象。
首先, 建议你下载Starter项目, 然后按照本教程进行操作。如果以后需要该项目的快速参考, 请下载Finished项目。
教程项目简介
教程项目是一个篮球应用程序, 用于跟踪游戏中玩家的动作。
它用于快速跟踪用户的移动以及皮卡游戏中的总体得分。
两支球队比赛直到得分达到15分(至少相差两分)。每个球员可以得分1分到2分, 每个球员可以助攻, 篮板和犯规。
项目层次结构如下所示:
模型
- 游戏迅捷
- 包含游戏逻辑, 跟踪总体得分, 跟踪每个玩家的动作。
- 团队迅捷
- 包含团队名称和球员列表(每个团队三名球员)。
- 玩家迅捷
- 一个有名字的球员。
视图
- HomeViewController.swift
- 根视图控制器, 显示GameScoreboardEditorViewController
- GameScoreboardEditorViewController.swift
- 在Main.storyboard中补充了Interface Builder视图。
- 本教程感兴趣的屏幕。
- PlayerScoreboardMoveEditorView.swift
- 在PlayerScoreboardMoveEditorView.xib中补充了Interface Builder视图
- 上面视图的子视图, 也使用MVVM设计模式。
视图模型
- ViewModel组为空, 这是你将在本教程中构建的组。
下载的Xcode项目已经包含View对象(UIView和UIViewController)的占位符。该项目还包含一些定制对象, 以演示如何向ViewModel对象(Services组)提供数据的方法之一。
扩展组包含有用的UI代码扩展, 这些扩展不在本教程的范围内, 并且不言自明。
如果此时运行该应用程序, 它将显示完成的UI, 但是当用户按下按钮时什么也没有发生。
这是因为你只创建了视图和IBAction, 而没有将它们连接到应用程序逻辑, 也没有用模型中的数据(来自Game对象的数据填充UI元素, 这将在后面学习)。
使用ViewModel连接视图和模型
在MVVM设计模式中, View不应该对模型一无所知。 View唯一知道的是如何使用ViewModel。
首先检查你的视图。
在GameScoreboardEditorViewController.swift文件中, 此时的fillUI方法为空。这是你要用数据填充UI的地方。为此, 你需要为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下的Model。
如果你现在运行该应用程序, 则该应用程序仍然无法运行, 因为你尚未将此ViewModel数据连接到View本身。
因此, 返回到GameScoreboardEditorViewController.swift文件, 并创建一个名为viewModel的公共属性。
将其设置为GameScoreboardEditorViewModel类型。
将其放在GameScoreboardEditorViewController.swift中的viewDidLoad方法之前。
var viewModel: GameScoreboardEditorViewModel? {
didSet {
fillUI()
}
}
接下来, 你需要实现fillUI方法。
注意如何从两个地方调用此方法, 即viewModel属性观察器(didSet)和viewDidLoad方法。这是因为我们可以在将ViewController附加到视图之前(在调用viewDidLoad方法之前)创建一个ViewController并为其分配一个ViewModel。
另一方面, 你可以将ViewController的视图附加到另一个视图并调用viewDidLoad, 但是如果此时未设置viewModel, 则不会发生任何事情。
这就是为什么首先需要检查是否为数据填充了UI的所有设置。务必保护你的代码以防意外使用。
因此, 转到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()
}
你现在需要做的就是在此ViewController上设置实际的viewModel属性。你可以从外部进行此操作。
打开HomeViewController.swift文件, 然后取消对ViewModel的注释;在showGameScoreboardEditorViewController方法中创建和设置行:
// uncomment this when view model is implemented
let viewModel = GameScoreboardEditorViewModelFromGame(withGame: game)
controller.viewModel = viewModel
现在, 运行该应用程序。它看起来应该像这样:
负责得分, 时间和团队名称的中间视图不再显示在Interface Builder中设置的值。
现在, 它显示了ViewModel对象本身的值, 而ViewModel对象是从实际的Model对象(游戏对象)获取数据的。
优秀的!但是玩家的看法呢?这些按钮仍然无法执行任何操作。
你知道你有六个视图来跟踪玩家的动作。
你为此创建了一个单独的子视图, 名为PlayerScoreboardMoveEditorView, 该子视图目前不处理实际数据, 并显示通过PlayerScoreboardMoveEditorView.xib文件中的”界面生成器”设置的静态值。
你需要为其提供一些数据。
你将执行与GameScoreboardEditorViewController和GameScoreboardEditorViewModel相同的操作。
在Xcode项目中打开ViewModel组, 然后在此处定义新协议。
创建一个名为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中一样。
你需要具有用户可以执行的五种不同动作的值, 并且需要在用户触摸其中一个操作按钮时做出反应。你还需要一个字符串作为播放器名称。
完成此操作后, 创建一个实现该协议的具体类, 就像处理父视图(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如何负责在GameScoreboardEditorViewController上设置viewModel属性吗?
同样, GameScoreboardEditorViewController是PlayerScoreboardMoveEditorView的父视图, 而GameScoreboardEditorViewController将负责创建PlayerScoreboardMoveEditorViewModel对象。
你需要首先扩展GameScoreboardEditorViewModel。
打开GameScoreboardEditorViewModel并添加以下两个属性:
var homePlayers: [PlayerScoreboardMoveEditorViewModel] { get }
var awayPlayers: [PlayerScoreboardMoveEditorViewModel] { get }
另外, 使用initWithGame方法上方的这两个属性更新GameScoreboardEditorViewModelFromGame:
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填充UI的相同位置进行操作。
打开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]
目前, 你有构建错误, 因为你没有在PlayerScoreboardMoveEditorView中添加实际的viewModel属性。
在PlayerScoreboardMoveEditorView`的init方法上方添加以下代码。
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
}
最后, 运行该应用程序, 并查看UI元素中的数据如何是Game对象中的实际数据。
至此, 你已经拥有使用MVVM设计模式的功能性应用程序。
它很好地从视图中隐藏了模型, 并且你的视图比使用MVC时要简单得多。
至此, 你已经创建了一个包含View及其ViewModel的应用程序。
该View的ViewModel也具有相同子视图(玩家视图)的六个实例。
但是, 你可能会注意到, 你只能在UI中一次显示数据(使用fillUI方法), 并且该数据是静态的。
如果视图中的数据在该视图的整个生命周期内都不会发生变化, 那么你将拥有一个很好且干净的解决方案, 以这种方式使用MVVM。
使ViewModel动态化
因为你的数据将发生变化, 所以你需要使ViewModel动态。
这意味着当Model更改时, ViewModel应该更改其公共属性值。它将更改传播回视图, 这将是更新UI的视图。
有很多方法可以做到这一点。
当Model更改时, ViewModel首先被通知。
你需要某种机制来将更改传播到View。
其中一些选项包括RxSwift, 它是一个相当大的库, 需要一些时间才能习惯。
ViewModel可能会在每次属性值更改时触发NSNotifications, 但这会增加很多代码, 需要进行其他处理, 例如订阅通知和在取消分配视图时取消订阅。
键值观察(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
}
}
你将使用此类在View生命周期中希望更改的ViewModels属性中使用。
首先, 从PlayerScoreboardMoveEditorView及其视图模型PlayerScoreboardMoveEditorViewModel开始。
打开PlayerScoreboardMoveEditorViewModel并查看其属性。
由于不希望更改玩家名称, 因此你可以将其保留不变。
其他五个属性(五个移动类型)将发生变化, 因此你需要对此做一些事情。解决方案?你刚刚添加到项目中的上述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()
}
这种动态类型使你能够更改该特定属性的值, 同时通知更改侦听器对象, 在这种情况下, 该对象将是”视图”。
现在, 更新实际的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属性。
现在, 由于你没有初始化动态对象, 因此出现了构建错误。
在PlayerScoreboardMoveEditorViewModelFromPlayer的init方法中, 将move属性的初始化替换为:
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并查看在视图的生命周期中预期会更改哪些值。
用动态版本替换时间, 分数, isFinished, isPaused定义:
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)
你现在应该明白了。
你正在使用这些对象的Dynamic <T>版本包装原始值(例如String, Int和Bool), 从而为你提供了轻量级的绑定机制。
你还有一个错误要修复。
在startTimer方法中, 将错误行替换为:
self.time.value = GameScoreboardEditorViewModelFromGame.timeRemainingPretty(for: self.game)
你已经将ViewModel升级为动态的, 就像播放器的ViewModel一样。但是你仍然需要更新你的视图(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个点按钮)时, 除了UI中的分数没有改变之外, 你几乎已经完成了操作。
这是因为你尚未将基础Game模型对象中的得分变化真正传播到ViewModel。
因此, 打开游戏模型对象进行一些检查。检查其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中监听此通知, 并在通知处理程序方法中更新动态得分值。
将此行添加到initWithGame方法的底部(不要忘记调用super.init()以避免出现错误):
super.init()
subscribeToNotifications()
在initWithGame方法下面, 添加deinit方法, 因为你要正确进行清理并避免NotificationCenter导致崩溃。
deinit {
unsubscribeFromNotifications()
}
最后, 添加这些方法的实现。在deinit方法下面添加此部分:
// 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
}
}
现在, 运行该应用程序, 然后单击播放器视图以更改分数。由于你已经连接了动态分数, 并且已在ViewModel中用View将其完成, 因此当你在ViewModel中更改分数值时, 一切都会正常。
如何进一步改善应用程序
尽管总有改进的余地, 但这超出了本教程的范围。
例如, 当游戏结束时(其中一支球队达到15分), 我们不会自动停止时间, 而只是隐藏玩家视图。
如果愿意, 你可以使用该应用程序并对其进行升级, 使其具有”游戏创建者”视图, 该视图将创建游戏, 分配团队名称, 分配玩家名称以及创建可用于呈现GameScoreboardEditorViewController的Game对象。
我们可以创建另一个”游戏列表”视图, 该视图使用UITableView来显示多个正在进行的游戏, 并在表格单元格中提供一些详细信息。在单元格选择中, 我们可以显示带有选定游戏的GameScoreboardEditorViewController。
GameLibrary已经实现。只需记住将该库引用传递给其初始化程序中的ViewModel对象。例如, “游戏创造者”的ViewModel需要通过初始值设定项传递GameLibrary的实例, 以便能够将创建的Game对象插入库中。 “游戏列表”的ViewModel还需要此引用来从库中获取所有游戏, 而UITableView则需要。
这个想法是将所有肮脏的(非UI)工作隐藏在ViewModel中, 并使UI(视图)仅对准备好的演示数据起作用。
现在怎么办?
习惯了MVVM之后, 你可以使用Bob叔叔的Clean Architecture规则进一步改进它。
另一本很好的读物是关于Android体系结构的三部分教程:
- Android体系结构:第1部分–每一个新的起点都是艰难的,
- Android体系结构:第2部分–干净的体系结构,
- Android体系结构:第2部分–简洁的体系结构。
示例是用Java(适用于Android)编写的, 如果你熟悉Java(与Swift距离更近, 那么Objective-C与Java距离更近), 你将获得有关如何在ViewModel对象中进一步重构代码的想法。他们不导入任何iOS模块(例如UIKit或CoreLocation)。
这些iOS模块可以隐藏在纯NSObjects的后面, 这有利于代码的可重用性。
MVVM是大多数iOS应用程序的不错选择, 希望你可以在下一个项目中尝试一下。或者, 在创建UIViewController时, 在当前项目中尝试一下。
相关:使用静态模式:Swift MVVM教程