Swift 教程:MVVM 设计模式简介

已发表: 2022-03-11

因此,您正在开始一个新的 iOS 项目,您从设计师那里收到了所有需要的.pdf.sketch文档,并且您已经对如何构建这个新应用程序有了一个构想。

您开始将 UI 屏幕从设计师的草图转移到您的ViewController .swift.xib.storyboard文件中。

这里是UITextField ,那里是UITableView ,还有几个UILabels和一小撮UIButtonsIBOutletsIBActions也包括在内。 一切都好,我们仍然在 UI 区域。

但是,是时候对所有这些 UI 元素做点什么了; UIButtons将接收手指触摸, UILabelsUITableViews需要有人告诉他们要显示什么以及以什么格式显示。

突然间,你有超过 3,000 行代码。

3,000 行 Swift 代码

你最终得到了很多意大利面条代码。

解决此问题的第一步是应用模型-视图-控制器(MVC) 设计模式。 但是,这种模式有其自身的问题。 模型-视图-视图模型(MVVM) 设计模式可以挽救这一天。

处理意大利面条代码

很快,你的起始ViewController就变得太聪明和太庞大了。

网络代码、数据解析代码、UI 展示的数据调整代码、应用状态通知、UI 状态更改。 所有这些代码都被囚禁在单个文件的if -ology 中,不能重用并且只适合这个项目。

您的ViewController代码已成为臭名昭著的意大利面条代码。

那是怎么发生的?

可能的原因是这样的:

您急于查看后端数据在UITableView中的行为方式,因此您将几行网络代码放入ViewControllertemp方法中,以便从网络中获取该.json 。 接下来,您需要处理该.json中的数据,因此您编写了另一个临时方法来完成此操作。 或者,更糟糕的是,您在相同的方法中执行了该操作。

当用户授权代码出现时, ViewController继续增长。 然后数据格式开始发生变化,UI 发展并需要一些根本性的变化,而你只是不断地将更多if s 添加到已经庞大的if逻辑中。

但是, UIViewController怎么会失控呢?

UIViewController是开始处理 UI 代码的合乎逻辑的地方。 它代表您在 iOS 设备上使用任何应用程序时看到的物理屏幕。 甚至 Apple 在其主系统应用程序之间切换不同的应用程序及其动画 UI 时也会使用UIViewControllers

Apple 将其 UI 抽象建立在UIViewController中,因为它是 iOS UI 代码的核心,也是MVC设计模式的一部分。

相关: iOS 开发人员不知道的 10 个最常见错误

升级到 MVC 设计模式

MVC 设计模式

在 MVC 设计模式中, View应该是不活动的,并且只按需显示准备好的数据。

Controller应该处理Model数据以准备Views ,然后显示该数据。

View还负责通知Controller任何操作,例如用户触摸。

如前所述, UIViewController通常是构建 UI 屏幕的起点。 请注意,在其名称中,它同时包含“视图”和“控制器”。 这意味着它“控制视图”。 这并不意味着“控制器”和“视图”代码都应该放在里面。

当您在UIViewController中移动小子视图的IBOutlets并直接从UIViewController操作这些子视图时,通常会发生这种视图和控制器代码的混合。 相反,您应该将该代码包装在自定义UIView子类中。

很容易看出这可能会导致 View 和 Controller 代码路径交叉。

MVVM 救援

这就是MVVM模式派上用场的地方。

由于UIViewController应该是 MVC 模式中的控制器,并且它已经对Views做了很多,我们可以将它们合并到我们的新模式 - MVVMView中。

MVVM 设计模式

在 MVVM 设计模式中, Model与 MVC 模式中的相同。 它代表简单的数据。

视图UIViewUIViewController对象表示,并带有它们的.xib.storyboard文件,它们应该只显示准备好的数据。 (例如,我们不希望在视图中包含NSDateFormatter代码。)

只有一个来自ViewModel的简单格式化字符串。

ViewModel隐藏了所有异步网络代码、可视化呈现的数据准备代码以及模型更改的代码监听。 所有这些都隐藏在一个定义良好的 API 后面,该 API 被建模以适应这个特定的View

使用 MVVM 的好处之一是测试。 由于ViewModel是纯NSObject (或struct例如),并且它不与UIKit代码耦合,因此您可以在单元测试中更轻松地对其进行测试,而不会影响 UI 代码。

现在,视图UIViewController / UIView )变得更加简单,而ViewModel充当了ModelView之间的粘合剂。

在 Swift 中应用 MVVM

Swift 中的 MVVM

为了向您展示 MVVM 的实际应用,您可以在此处下载并检查为本教程创建的示例 Xcode 项目。 该项目使用 Swift 3 和 Xcode 8.1。

该项目有两个版本:Starter 和 Finished。

Finished版本是一个完整的迷你应用程序,其中Starter是同一个项目,但没有实现方法和对象。

首先,我建议您下载Starter项目,然后按照本教程进行操作。 如果您以后需要该项目的快速参考,请下载已完成的项目。

教程项目介绍

教程项目是一个篮球应用程序,用于在比赛期间跟踪球员的动作。

篮球应用

它用于快速跟踪用户动作和皮卡游戏中的总分。

两队比赛直到得分达到 15(至少有两分之差)。 每个球员可以得分一分到两分,每个球员可以助攻、篮板和犯规。

项目层次结构如下所示:

项目层次结构

模型

  • Game.swift
    • 包含游戏逻辑,跟踪总分,跟踪每个玩家的动作。
  • Team.swift
    • 包含球队名称和球员名单(每队三名球员)。
  • Player.swift
    • 一个有名字的玩家。

看法

  • HomeViewController.swift
    • 根视图控制器,显示GameScoreboardEditorViewController
  • GameScoreboardEditorViewController.swift
    • Main.storyboard中补充了 Interface Builder 视图。
    • 本教程感兴趣的屏幕。
  • PlayerScoreboardMoveEditorView.swift
    • PlayerScoreboardMoveEditorView.xib中补充了 Interface Builder 视图
    • 上述视图的子视图,也使用了MVVM设计模式。

视图模型

  • ViewModel组是空的,这是您将在本教程中构建的。

下载的 Xcode 项目已经包含View对象( UIViewUIViewController )的占位符。 该项目还包含一些定制对象,用于演示如何向ViewModel对象( Services组)提供数据的方法之一。

Extensions组包含 UI 代码的有用扩展,这些扩展不在本教程的范围内,并且是不言自明的。

如果您此时运行应用程序,它将显示完成的 UI,但当用户按下按钮时什么也没有发生。

这是因为您只创建了视图和IBActions ,而没有将它们连接到应用程序逻辑,也没有使用来自模型的数据(来自Game对象,我们稍后将了解)填充 UI 元素。

用 ViewModel 连接视图和模型

在 MVVM 设计模式中,View 不应该对 Model 有任何了解。 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 下的模型。

如果您现在运行该应用程序,它仍然无法工作,因为您还没有将此 ViewModel 数据连接到 View 本身。

所以,回到GameScoreboardEditorViewController.swift文件,并创建一个名为viewModel的公共属性。

使其类型为GameScoreboardEditorViewModel

将它放在GameScoreboardEditorViewController.swift中的viewDidLoad方法之前。

 var viewModel: GameScoreboardEditorViewModel? { didSet { fillUI() } }

接下来,您需要实现fillUI方法。

注意这个方法是如何从两个地方调用的, viewModel属性观察者( didSet )和viewDidLoad方法。 这是因为我们可以在将其附加到视图之前(在调用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

现在,运行应用程序。 它应该看起来像这样:

iOS 应用

负责分数、时间和团队名称的中间视图不再显示在 Interface Builder 中设置的值。

现在,它显示了 ViewModel 对象本身的值,该对象从实际的 Model 对象( Game对象)中获取数据。

优秀! 但是玩家的看法呢? 那些按钮仍然没有做任何事情。

您知道您有六个用于跟踪玩家移动的视图。

您为此创建了一个名为PlayerScoreboardMoveEditorView的单独子视图,它现在对真实数据不做任何事情,并显示通过PlayerScoreboardMoveEditorView.xib文件中的 Interface Builder 设置的静态值。

你需要给它一些数据。

您将按照与GameScoreboardEditorViewControllerGameScoreboardEditorViewModel相同的方式进行操作。

在 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中所做的那样。

您需要为用户可以进行的五种不同动作设置值,并且您需要在用户触摸其中一个操作按钮时做出反应。 您还需要一个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是如何负责设置 GameScoreboardEditorViewController 上的viewModel属性的GameScoreboardEditorViewController

同样, GameScoreboardEditorViewControllerGameScoreboardEditorViewController PlayerScoreboardMoveEditorView负责创建PlayerScoreboardMoveEditorViewModel对象。

您需要先扩展您的GameScoreboardEditorViewModel

打开GameScoreboardEditorViewMode l 并添加这两个属性:

 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属性。

init method inside the上方添加以下代码。

 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对象的实际数据。

iOS 应用

此时,您有一个使用 MVVM 设计模式的功能应用程序。

它很好地从视图中隐藏了模型,并且您的视图比您使用 MVC 习惯的要简单得多。

至此,您已经创建了一个包含 View 及其 ViewModel 的应用程序。

该视图还具有与其 ViewModel 相同的子视图(播放器视图)的六个实例。

但是,您可能会注意到,您只能在 UI 中显示一次数据(在fillUI方法中),并且该数据是静态的。

如果您在视图中的数据在该视图的生命周期内不会发生变化,那么您有一个很好且干净的解决方案来以这种方式使用 MVVM。

使 ViewModel 动态化

因为你的数据会改变,你需要让你的 ViewModel 动态化。

这意味着当 Model 改变时,ViewModel 应该改变它的公共属性值; 它会将更改传播回视图,该视图将更新 UI。

有很多方法可以做到这一点。

当 Model 发生变化时,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 } }

您将在 ViewModel 中将此类用于您希望在 View 生命周期中更改的属性。

首先,从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将这些属性声明为常量,因为您不会更改实际属性。 您将更改Dynamic对象的value属性。

现在,由于您没有初始化Dynamic对象,因此存在构建错误。

PlayerScoreboardMoveEditorViewModelFromPlayer的 init 方法中,将移动属性的初始化替换为:

 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() }

运行应用程序,然后单击一些移动按钮。 当您单击操作按钮时,您将看到播放器视图中的计数器值如何变化。

iOS 应用

您已完成PlayerScoreboardMoveEditorViewPlayerScoreboardMoveEditorViewModel

这很简单。

现在,您需要对主视图 ( GameScoreboardEditorViewController ) 执行相同的操作。

首先,打开GameScoreboardEditorViewModel并查看在视图的生命周期中预期会更改哪些值。

Dynamic版本替换timescoreisFinishedisPaused定义:

 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 的类型从StringBool更改为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>版本包装原始值,例如StringIntBool ,这为您提供了轻量级绑定机制。

您还有一个错误要修复。

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] }

唯一的区别是您更改了四个动态属性并为每个属性添加了更改侦听器。

此时,如果您运行您的应用程序,切换“开始/暂停”按钮将启动和暂停游戏计时器。 这用于比赛期间的暂停。

当您按下其中一个点按钮( 12点按钮)时,您几乎完成了,除了 UI 中的分数没有变化。

这是因为您还没有真正将底层Game模型对象中的分数变化传播到 ViewModel。

因此,打开Game模型对象进行一些检查。 检查它的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() }

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 .

Related: Working With Static Patterns: A Swift MVVM Tutorial