Swift教程:MVVM设计模式简介

本文概述

因此, 你开始一个新的iOS项目, 从设计人员那里收到了所有需要的.pdf和.sketch文档, 并且你已经对如何构建此新应用程序有一个愿景。

你开始将UI屏幕从设计师的素描转移到ViewController的.swift, .xib和.storyboard文件中。

这里的UITextField, 那里的UITableView, 更多的UILabel和一些UIButton。 IBOutlets和IBAction也包括在内。很好, 我们仍然在UI区域中。

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

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

3,000行Swift代码

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

解决此问题的第一步是应用模型视图控制器(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设计模式

在MVC设计模式中, View应该处于非活动状态, 并且仅按需显示准备的数据。

Controller应该处理Model数据, 以将其准备好用于View, 然后显示该数据。

View还负责将任何操作(例如用户触摸)通知控制器。

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

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

显而易见, 这可能导致View和Controller代码路径交叉。

MVVM抢救

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

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

MVVM设计模式

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

视图由UIView或UIViewController对象以及它们的.xib和.storyboard文件表示, 它们仅应显示准备好的数据。 (例如, 我们不希望在视图中包含NSDateFormatter代码。)

仅来自ViewModel的简单格式化字符串。

ViewModel隐藏所有异步联网代码, 用于可视化表示的数据准备代码以及用于侦听Model更改的代码。所有这些都隐藏在精心定义的API后面, 该API建模为适合该特定View。

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

现在, 视图(UIViewController / UIView)变得更加简单, 而视图模型充当模型和视图之间的粘合剂。

在Swift中应用MVVM

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

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

iOS应用

负责得分, 时间和团队名称的中间视图不再显示在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对象中的实际数据。

iOS应用

至此, 你已经拥有使用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()
}

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

iOS应用

你已经完成了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教程

微信公众号
手机浏览(小程序)
0
分享到:
没有账号? 忘记密码?