Swift 教程:MVVM 設計模式簡介
已發表: 2022-03-11因此,您正在開始一個新的 iOS 項目,您從設計師那裡收到了所有需要的.pdf
和.sketch
文檔,並且您已經對如何構建這個新應用程序有了一個構想。
您開始將 UI 屏幕從設計師的草圖轉移到您的ViewController
.swift
、 .xib
和.storyboard
文件中。
這裡是UITextField
,那裡是UITableView
,還有幾個UILabels
和一小撮UIButtons
。 IBOutlets
和IBActions
也包括在內。 一切都好,我們仍然在 UI 區域。
但是,是時候對所有這些 UI 元素做點什麼了; UIButtons
將接收手指觸摸, UILabels
和UITableViews
需要有人告訴他們要顯示什麼以及以什麼格式顯示。
突然間,你有超過 3,000 行代碼。
你最終得到了很多意大利麵條代碼。
解決此問題的第一步是應用模型-視圖-控制器(MVC) 設計模式。 但是,這種模式有其自身的問題。 模型-視圖-視圖模型(MVVM) 設計模式可以挽救這一天。
處理意大利麵條代碼
很快,你的起始ViewController
就變得太聰明和太龐大了。
網絡代碼、數據解析代碼、UI 展示的數據調整代碼、應用狀態通知、UI 狀態更改。 所有這些代碼都被囚禁在單個文件的if
-ology 中,不能重用並且只適合這個項目。
您的ViewController
代碼已成為臭名昭著的意大利麵條代碼。
那是怎麼發生的?
可能的原因是這樣的:
您急於查看後端數據在UITableView
中的行為方式,因此您將幾行網絡代碼放入ViewController
的temp方法中,以便從網絡中獲取該.json
。 接下來,您需要處理該.json
中的數據,因此您編寫了另一個臨時方法來完成此操作。 或者,更糟糕的是,您在相同的方法中執行了該操作。
當用戶授權代碼出現時, ViewController
繼續增長。 然後數據格式開始發生變化,UI 發展並需要一些根本性的變化,而你只是不斷地將更多if
s 添加到已經龐大的if
邏輯中。
但是, UIViewController
怎麼會失控呢?
UIViewController
是開始處理 UI 代碼的合乎邏輯的地方。 它代表您在 iOS 設備上使用任何應用程序時看到的物理屏幕。 甚至 Apple 在其主系統應用程序之間切換不同的應用程序及其動畫 UI 時也會使用UIViewControllers
。
Apple 將其 UI 抽象建立在UIViewController
中,因為它是 iOS UI 代碼的核心,也是MVC設計模式的一部分。
升級到 MVC 設計模式
在 MVC 設計模式中, View應該是不活動的,並且只按需顯示準備好的數據。
Controller應該處理Model數據以準備Views ,然後顯示該數據。
View還負責通知Controller任何操作,例如用戶觸摸。
如前所述, UIViewController
通常是構建 UI 屏幕的起點。 請注意,在其名稱中,它同時包含“視圖”和“控制器”。 這意味著它“控制視圖”。 這並不意味著“控制器”和“視圖”代碼都應該放在裡面。
當您在UIViewController
中移動小子視圖的IBOutlets
並直接從UIViewController
操作這些子視圖時,通常會發生這種視圖和控制器代碼的混合。 相反,您應該將該代碼包裝在自定義UIView
子類中。
很容易看出這可能會導致 View 和 Controller 代碼路徑交叉。
MVVM 救援
這就是MVVM模式派上用場的地方。
由於UIViewController
應該是 MVC 模式中的控制器,並且它已經對Views做了很多,我們可以將它們合併到我們的新模式 - MVVM的View中。
在 MVVM 設計模式中, Model與 MVC 模式中的相同。 它代表簡單的數據。
視圖由UIView
或UIViewController
對象表示,並帶有它們的.xib
和.storyboard
文件,它們應該只顯示準備好的數據。 (例如,我們不希望在視圖中包含NSDateFormatter
代碼。)
只有一個來自ViewModel的簡單格式化字符串。
ViewModel隱藏了所有異步網絡代碼、可視化呈現的數據準備代碼以及模型更改的代碼監聽。 所有這些都隱藏在一個定義良好的 API 後面,該 API 被建模以適應這個特定的View 。
使用 MVVM 的好處之一是測試。 由於ViewModel是純NSObject
(或struct
例如),並且它不與UIKit
代碼耦合,因此您可以在單元測試中更輕鬆地對其進行測試,而不會影響 UI 代碼。
現在,視圖( UIViewController
/ UIView
)變得更加簡單,而ViewModel充當了Model和View之間的粘合劑。
在 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對象( UIView
和UIViewController
)的佔位符。 該項目還包含一些定制對象,用於演示如何向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
現在,運行應用程序。 它應該看起來像這樣:
負責分數、時間和團隊名稱的中間視圖不再顯示在 Interface Builder 中設置的值。
現在,它顯示了 ViewModel 對象本身的值,該對像從實際的 Model 對象( Game
對象)中獲取數據。
優秀! 但是玩家的看法呢? 那些按鈕仍然沒有做任何事情。
您知道您有六個用於跟踪玩家移動的視圖。
您為此創建了一個名為PlayerScoreboardMoveEditorView
的單獨子視圖,它現在對真實數據不做任何事情,並顯示通過PlayerScoreboardMoveEditorView.xib
文件中的 Interface Builder 設置的靜態值。
你需要給它一些數據。
您將按照與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
中所做的那樣。
您需要為用戶可以進行的五種不同動作設置值,並且您需要在用戶觸摸其中一個操作按鈕時做出反應。 您還需要一個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
?
同樣, GameScoreboardEditorViewController
是GameScoreboardEditorViewController
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
對象的實際數據。
此時,您有一個使用 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() }
運行應用程序,然後單擊一些移動按鈕。 當您單擊操作按鈕時,您將看到播放器視圖中的計數器值如何變化。
您已完成PlayerScoreboardMoveEditorView
和PlayerScoreboardMoveEditorViewModel
。
這很簡單。
現在,您需要對主視圖 ( GameScoreboardEditorViewController
) 執行相同的操作。
首先,打開GameScoreboardEditorViewModel
並查看在視圖的生命週期中預期會更改哪些值。
用Dynamic
版本替換time
、 score
、 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。
因此,打開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()
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
.