Swiftチュートリアル:MVVMデザインパターンの概要
公開: 2022-03-11つまり、新しいiOSプロジェクトを開始し、デザイナーから必要なすべての.pdf
および.sketch
ドキュメントを受け取り、この新しいアプリをどのように構築するかについてのビジョンをすでに持っています。
デザイナーのスケッチからViewController
の.swift
、 .xib
、および.storyboard
ファイルへのUI画面の転送を開始します。
ここにUITextField
、そこにUITableView
、さらにいくつかのUILabels
とピンチのUIButtons
。 IBOutlets
とIBActions
も含まれています。 すべて良いです、私たちはまだUIゾーンにいます。
ただし、これらすべてのUI要素を使用して何かを行うときが来ました。 UIButtons
は指で触れるだけで、 UILabels
とUITableViews
には、何をどの形式で表示するかを指示する人が必要です。
突然、3,000行を超えるコードが作成されました。
あなたはたくさんのスパゲッティコードになってしまいました。
これを解決するための最初のステップは、 Model-View-Controller (MVC)デザインパターンを適用することです。 ただし、このパターンには独自の問題があります。 1日を節約するModel-View-ViewModel (MVVM)デザインパターンがあります。
スパゲッティコードの取り扱い
あっという間に、 ViewController
の起動がスマートになりすぎて大規模になりすぎました。
ネットワークコード、データ解析コード、UIプレゼンテーションのデータ調整コード、アプリの状態通知、UIの状態変化。 再利用できず、このプロジェクトにのみ適合する単一ファイルの-ologyのif
、そのすべてのコードは内部に閉じ込められます。
ViewController
コードは悪名高いスパゲッティコードになりました。
どうしてこうなりました?
考えられる理由は次のようなものです。
UITableView
内でバックエンドデータがどのように動作しているかを確認するために急いでいたので、ネットワークからその.json
をフェッチするためだけに、 ViewController
のtempメソッド内に数行のネットワークコードを配置しました。 次に、その.json
内のデータを処理する必要があったため、それを実現するためにさらに別の一時メソッドを作成しました。 または、さらに悪いことに、同じメソッド内でそれを行いました。
ユーザー認証コードが登場したとき、 ViewController
は成長を続けました。 その後、データ形式が変化し始め、UIが進化し、いくつかの根本的な変更が必要になりました。そして、すでに大規模なif
-ologyにif
を追加し続けました。
しかし、 UIViewController
が手に負えなくなったのでしょうか。
UIViewController
は、UIコードの作業を開始するための論理的な場所です。 これは、iOSデバイスでアプリを使用しているときに表示される物理的な画面を表します。 Appleでさえ、異なるアプリとアニメーション化されたUIを切り替えるときに、メインシステムアプリでUIViewControllers
を使用します。
AppleはUI抽象化をUIViewController
内に基づいています。これは、iOS UIコードのコアであり、 MVCデザインパターンの一部であるためです。
MVCデザインパターンへのアップグレード
MVCデザインパターンでは、 Viewは非アクティブであると想定されており、準備されたデータのみをオンデマンドで表示します。
コントローラはモデルデータを処理してビュー用に準備する必要があります。ビューはそのデータを表示します。
Viewは、ユーザーのタッチなどのアクションについてコントローラーに通知する役割も果たします。
前述のように、 UIViewController
は通常、UI画面を構築するための開始点です。 その名前には、「ビュー」と「コントローラー」の両方が含まれていることに注意してください。 これは、「ビューを制御する」ことを意味します。 「コントローラー」と「ビュー」の両方のコードが内部に含まれる必要があるという意味ではありません。
ビューとコントローラーコードのこの混合は、 UIViewController
内の小さなサブビューのIBOutlets
を移動し、 UIViewController
から直接それらのサブビューを操作するときによく発生します。 代わりに、そのコードをカスタムUIView
サブクラス内にラップする必要があります。
これにより、ビューとコントローラーのコードパスが交差する可能性があることは簡単にわかります。
MVVMを救助する
ここでMVVMパターンが役に立ちます。
UIViewController
はMVCパターンのコントローラーであると想定されており、すでにビューで多くのことを行っているため、新しいパターンのビューであるMVVMにそれらをマージできます。
MVVMデザインパターンでは、モデルはMVCパターンと同じです。 単純なデータを表します。
ビューは、 UIView
またはUIViewController
オブジェクトで表され、 .storyboard
.xib
が付属しており、準備されたデータのみを表示する必要があります。 (たとえば、ビュー内にNSDateFormatter
コードは必要ありません。)
ViewModelからの単純なフォーマットされた文字列のみ。
ViewModelは、すべての非同期ネットワークコード、視覚的な表示のためのデータ準備コード、およびモデルの変更をリッスンするコードを非表示にします。 これらはすべて、この特定のビューに適合するようにモデル化された、明確に定義されたAPIの背後に隠されています。
MVVMを使用する利点の1つは、テストです。 ViewModelは純粋なNSObject
(またはstruct
など)であり、 UIKit
コードと結合されていないため、UIコードに影響を与えることなく、単体テストでより簡単にテストできます。
これで、 View ( UIViewController
/ UIView
)がはるかにシンプルになり、 ViewModelはモデルとビューの間の接着剤として機能します。
SwiftでのMVVMの適用
MVVMの動作を示すために、このチュートリアル用に作成されたサンプルXcodeプロジェクトをここからダウンロードして調べることができます。 このプロジェクトでは、Swift3とXcode8.1を使用しています。
プロジェクトには、StarterとFinishedの2つのバージョンがあります。
完成したバージョンは完成したミニアプリケーションであり、 Starterは同じプロジェクトですが、メソッドとオブジェクトが実装されていません。
まず、 Starterプロジェクトをダウンロードして、このチュートリアルに従うことをお勧めします。 後で使用するためにプロジェクトのクイックリファレンスが必要な場合は、完成したプロジェクトをダウンロードしてください。
チュートリアルプロジェクトの紹介
チュートリアルプロジェクトは、ゲーム中のプレーヤーのアクションを追跡するためのバスケットボールアプリケーションです。
これは、ユーザーの動きとピックアップゲームの全体的なスコアのクイックトラッキングに使用されます。
15のスコア(少なくとも2ポイントの差がある)に達するまで、2つのチームがプレーします。 各プレイヤーは1ポイントから2ポイントを獲得でき、各プレイヤーはアシスト、リバウンド、ファウルを行うことができます。
プロジェクト階層は次のようになります。
モデル
Game.swift
- ゲームロジックが含まれ、全体的なスコアを追跡し、各プレーヤーの動きを追跡します。
-
Team.swift
- チーム名とプレーヤーリストが含まれています(各チームに3人のプレーヤー)。
-
Player.swift
- 名前を持つシングルプレイヤー。
意見
HomeViewController.swift
-
GameScoreboardEditorViewController
を提示するルートビューコントローラー
-
GameScoreboardEditorViewController.swift
-
Main.storyboard
のInterfaceBuilderビューで補足されます。 - このチュートリアルの対象となる画面。
-
-
PlayerScoreboardMoveEditorView.swift
-
PlayerScoreboardMoveEditorView.xib
のInterfaceBuilderビューで補足 - 上記のビューのサブビューも、MVVMデザインパターンを使用しています。
-
ViewModel
-
ViewModel
グループは空です。これは、このチュートリアルで作成するものです。
ダウンロードされたXcodeプロジェクトには、 Viewオブジェクト( UIView
およびUIViewController
)のプレースホルダーが既に含まれています。 プロジェクトには、 ViewModelオブジェクト( Services
グループ)にデータを提供する方法の1つをデモするために作成されたカスタムメイドのオブジェクトも含まれています。
Extensions
グループには、このチュートリアルの範囲外であり、自明であるUIコードの便利な拡張機能が含まれています。
この時点でアプリを実行すると、完成したUIが表示されますが、ユーザーがボタンを押しても何も起こりません。
これは、ビューとIBActions
を作成したのは、それらをアプリロジックに接続せず、UI要素にモデルからのデータ(後で学習するようにGame
オブジェクトから)を入力しないためです。
ビューとモデルをViewModelで接続する
MVVMデザインパターンでは、Viewはモデルについて何も知らないはずです。 Viewが知っているのは、ViewModelの操作方法だけです。
ビューを調べることから始めます。
GameScoreboardEditorViewController.swift
ファイルでは、 fillUI
メソッドはこの時点では空です。 これは、UIにデータを入力する場所です。 これを実現するには、 ViewController
にデータを提供する必要があります。 これは、ViewModelオブジェクトを使用して行います。
まず、このViewController
に必要なすべてのデータを含むViewModelオブジェクトを作成します。
空になるViewModelXcodeプロジェクトグループに移動し、 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が初期化子を介して機能するために必要なすべてを提供したことに注意してください。
このViewModelの下のモデルであるGame
オブジェクトを提供しました。
ここでアプリを実行しても、このViewModelデータをビュー自体に接続していないため、アプリは機能しません。
したがって、 GameScoreboardEditorViewController.swift
ファイルに戻り、 viewModel
という名前のパブリックプロパティを作成します。
タイプGameScoreboardEditorViewModel
にします。
GameScoreboardEditorViewController.swift
内のviewDidLoad
メソッドの直前に配置します。
var viewModel: GameScoreboardEditorViewModel? { didSet { fillUI() } }
次に、 fillUI
メソッドを実装する必要があります。
このメソッドが、 viewModel
プロパティオブザーバー( didSet
)とviewDidLoad
メソッドの2つの場所からどのように呼び出されるかに注目してください。 これは、 ViewController
を作成し、ViewModelをビューにアタッチする前に( viewDidLoad
メソッドが呼び出される前に)割り当てることができるためです。
一方、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
次に、アプリを実行します。 次のようになります。
スコア、時間、およびチーム名を担当する中央のビューには、InterfaceBuilderで設定された値が表示されなくなりました。
これで、実際のModelオブジェクト( Game
オブジェクト)からデータを取得するViewModelオブジェクト自体からの値が表示されます。
優れた! しかし、プレイヤーの見解はどうですか? それらのボタンはまだ何もしません。
プレーヤーの動きの追跡には6つのビューがあることを知っています。
そのためにPlayerScoreboardMoveEditorView
という名前の別のサブビューを作成しました。これは、現時点では実際のデータとは関係なく、 PlayerScoreboardMoveEditorView.xib
ファイル内のInterfaceBuilderを介して設定された静的な値を表示します。
あなたはそれにいくつかのデータを与える必要があります。
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プロトコルは、親ビューであるGameScoreboardEditorViewController
で行ったのと同じように、 PlayerScoreboardMoveEditorView
に合うように設計されています。
ユーザーが実行できる5つの異なる動きの値が必要であり、ユーザーがアクションボタンの1つに触れたときに反応する必要があります。 プレーヤー名の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
はPlayerScoreboardMoveEditorViewの親ビューであり、 PlayerScoreboardMoveEditorView
がGameScoreboardEditorViewController
オブジェクトの作成をPlayerScoreboardMoveEditorViewModel
します。
最初にGameScoreboardEditorViewModel
を展開する必要があります。
GameScoreboardEditorViewMode
lを開き、次の2つのプロパティを追加します。
var homePlayers: [PlayerScoreboardMoveEditorViewModel] { get } var awayPlayers: [PlayerScoreboardMoveEditorViewModel] { get }
また、 initWithGame
メソッドのすぐ上にある次の2つのプロパティでGameScoreboardEditorViewModelFromGame
を更新します。
let homePlayers: [PlayerScoreboardMoveEditorViewModel] let awayPlayers: [PlayerScoreboardMoveEditorViewModel]
initWithGame
内にこれらの2行を追加します。
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
)をホームアンドアウェイプレーヤー配列で更新しました。 これらの2つの配列を埋める必要があります。
これは、この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 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を持つ同じサブビュー(プレーヤービュー)の6つのインスタンスもあります。
ただし、お気づきかもしれませんが、UIに表示できるデータは( fillUI
メソッドで)1回のみであり、そのデータは静的です。
ビューのデータがそのビューの存続期間中に変更されない場合は、この方法でMVVMを使用するための優れたクリーンなソリューションがあります。
ViewModelを動的にする
データが変更されるため、ViewModelを動的にする必要があります。
これが意味するのは、モデルが変更されると、ViewModelはそのパブリックプロパティ値を変更する必要があるということです。 UIを更新するビューに変更を伝播します。
これを行う方法はたくさんあります。
モデルが変更されると、ViewModelが最初に通知されます。
変更内容をビューまで伝達するには、何らかのメカニズムが必要です。
一部のオプションには、かなり大きなライブラリであり、慣れるまでに時間がかかるRxSwiftが含まれます。
ViewModelは、プロパティ値が変更されるたびにNSNotification
を起動する可能性がありますが、これにより、通知のサブスクライブやビューの割り当て解除時のサブスクライブ解除など、追加の処理が必要な多くのコードが追加されます。
Key-Value-Observing(KVO)は別のオプションですが、ユーザーはそのAPIが派手ではないことを確認します。
このチュートリアルでは、Swiftジェネリックとクロージャーを使用します。これらは、バインディング、ジェネリック、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のプロパティに使用します。
まず、 PlayerScoreboardMoveEditorView
とそのViewModel、 PlayerScoreboardMoveEditorViewModel
から始めます。
PlayerScoreboardMoveEditorViewModel
を開き、そのプロパティを確認します。
playerName
は変更される予定がないため、そのままにしておくことができます。
他の5つのプロパティ(5つの移動タイプ)は変更されるため、それについて何かを行う必要があります。 ソリューション? プロジェクトに追加した上記のDynamic
クラス。
PlayerScoreboardMoveEditorViewModel
内で、移動カウントを表す5つの文字列の定義を削除し、次のように置き換えます。
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
タイプを使用すると、その特定のプロパティの値を変更すると同時に、change-listenerオブジェクト(この場合はビュー)に通知できます。
ここで、実際の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メソッド内で、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
メソッドを開きます( Dynamic
オブジェクトタイプにString
値を割り当てようとしているため、この時点でこのメソッドにビルドエラーが表示されるはずです)。
「エラーのある」行を置き換えます。
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 }
次に、移動アクションを表す5つのメソッドを実装します(ボタンアクションセクション)。
@IBAction func onePointAction(_ sender: Any) { viewModel?.onePointMove() } @IBAction func twoPointsAction(_ sender: Any) { viewModel?.twoPointsMove() } @IBAction func assistAction(_ sender: Any) { viewModel?.assistMove() } @IBAction func reboundAction(_ sender: Any) { viewModel?.reboundMove() } @IBAction func foulAction(_ sender: Any) { viewModel?.foulMove() }
アプリを実行し、いくつかの移動ボタンをクリックします。 アクションボタンをクリックすると、プレーヤービュー内のカウンター値がどのように変化するかがわかります。
これで、 PlayerScoreboardMoveEditorView
とPlayerScoreboardMoveEditorViewModel
が完成しました。
これは簡単でした。
ここで、メインビュー( GameScoreboardEditorViewController
)で同じことを行う必要があります。
まず、 GameScoreboardEditorViewModel
を開き、ビューのライフサイクル中に変更されると予想される値を確認します。
time
、 score
、 isFinished
、 isPaused
定義をDynamic
バージョンに置き換えます。
import Foundation protocol GameScoreboardEditorViewModel { var homeTeam: String { get } var awayTeam: String { get } var time: Dynamic<String> { get } var score: Dynamic<String> { get } var isFinished: Dynamic<Bool> { get } var isPaused: Dynamic<Bool> { get } func togglePause() var homePlayers: [PlayerScoreboardMoveEditorViewModel] { get } var awayPlayers: [PlayerScoreboardMoveEditorViewModel] { get } }
ViewModel実装( GameScoreboardEditorViewModelFromGame
)に移動し、プロトコルで宣言されたプロパティで同じことを行います。
これを置き換えます:
var time: String var score: String var isFinished: Bool var isPaused: Bool
次のように:
let time: Dynamic<String> let score: Dynamic<String> let isFinished: Dynamic<Bool> let isPaused: Dynamic<Bool>
ViewModelのタイプをBool
からDynamic< String
Dynamic<String>
およびDynamic<Bool>
に変更したため、いくつかのエラーが発生します。
それを修正しましょう。
次のように置き換えて、 togglePause
メソッドを修正します。
func togglePause() { if isPaused.value { startTimer() } else { pauseTimer() } self.isPaused.value = !isPaused.value }
唯一の変更点は、プロパティ値をプロパティに直接設定しなくなったことに注意してください。 代わりに、オブジェクトのvalue
プロパティに設定します。
次に、これを置き換えてinitWithGame
メソッドを修正します。
self.time = GameScoreboardEditorViewModelFromGame.timeRemainingPretty(game) self.score = GameScoreboardEditorViewModelFromGame.scorePretty(game) self.isFinished = game.isFinished self.isPaused = true
次のように:
self.time = Dynamic(GameScoreboardEditorViewModelFromGame.timeRemainingPretty(for: game)) self.score = Dynamic(GameScoreboardEditorViewModelFromGame.scorePretty(for: game)) self.isFinished = Dynamic(game.isFinished) self.isPaused = Dynamic(true)
あなたは今ポイントを取得する必要があります。
String
、 Int
、 Bool
などのプリミティブ値を、これらのオブジェクトのDynamic<T>
バージョンでラップしているため、軽量のバインディングメカニズムが提供されます。
修正するエラーがもう1つあります。
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] }
唯一の違いは、4つの動的プロパティを変更し、それぞれに変更リスナーを追加したことです。
この時点で、アプリを実行している場合は、 [開始/一時停止]ボタンを切り替えると、ゲームタイマーが開始および一時停止します。 これは、ゲーム中のタイムアウトに使用されます。
ポイントボタンの1つ( 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) }
この方法は2つの重要なことを行います。
まず、両方のチームのスコアに基づいてゲームが終了した場合、 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
.