Swiftチュートリアル:MVVMデザインパターンの概要

公開: 2022-03-11

つまり、新しいiOSプロジェクトを開始し、デザイナーから必要なすべての.pdfおよび.sketchドキュメントを受け取り、この新しいアプリをどのように構築するかについてのビジョンをすでに持っています。

デザイナーのスケッチからViewController.swift.xib 、および.storyboardファイルへのUI画面の転送を開始します。

ここにUITextField 、そこにUITableView 、さらにいくつかのUILabelsとピンチのUIButtonsIBOutletsIBActionsも含まれています。 すべて良いです、私たちはまだUIゾーンにいます。

ただし、これらすべてのUI要素を使用して何かを行うときが来ました。 UIButtonsは指で触れるだけで、 UILabelsUITableViewsには、何をどの形式で表示するかを指示する人が必要です。

突然、3,000行を超えるコードが作成されました。

3,000行のSwiftコード

あなたはたくさんのスパゲッティコードになってしまいました。

これを解決するための最初のステップは、 Model-View-Controller (MVC)デザインパターンを適用することです。 ただし、このパターンには独自の問題があります。 1日を節約するModel-View-ViewModel (MVVM)デザインパターンがあります。

スパゲッティコードの取り扱い

あっという間に、 ViewControllerの起動がスマートになりすぎて大規模になりすぎました。

ネットワークコード、データ解析コード、UIプレゼンテーションのデータ調整コード、アプリの状態通知、UIの状態変化。 再利用できず、このプロジェクトにのみ適合する単一ファイルの-ologyのif 、そのすべてのコードは内部に閉じ込められます。

ViewControllerコードは悪名高いスパゲッティコードになりました。

どうしてこうなりました?

考えられる理由は次のようなものです。

UITableView内でバックエンドデータがどのように動作しているかを確認するために急いでいたので、ネットワークからその.jsonをフェッチするためだけに、 ViewControllertempメソッド内に数行のネットワークコードを配置しました。 次に、その.json内のデータを処理する必要があったため、それを実現するためにさらに別の一時メソッドを作成しました。 または、さらに悪いことに、同じメソッド内でそれを行いました。

ユーザー認証コードが登場したとき、 ViewControllerは成長を続けました。 その後、データ形式が変化し始め、UIが進化し、いくつかの根本的な変更が必要になりました。そして、すでに大規模なif -ologyにifを追加し続けました。

しかし、 UIViewControllerが手に負えなくなったのでしょうか。

UIViewControllerは、UIコードの作業を開始するための論理的な場所です。 これは、iOSデバイスでアプリを使用しているときに表示される物理的な画面を表します。 Appleでさえ、異なるアプリとアニメーション化されたUIを切り替えるときに、メインシステムアプリでUIViewControllersを使用します。

AppleはUI抽象化をUIViewController内に基づいています。これは、iOS UIコードのコアであり、 MVCデザインパターンの一部であるためです。

関連: iOS開発者が犯していることを知らない10の最も一般的な間違い

MVCデザインパターンへのアップグレード

MVCデザインパターン

MVCデザインパターンでは、 Viewは非アクティブであると想定されており、準備されたデータのみをオンデマンドで表示します。

コントローラモデルデータを処理してビュー用に準備する必要があります。ビューはそのデータを表示します。

Viewは、ユーザーのタッチなどのアクションについてコントローラーに通知する役割も果たします。

前述のように、 UIViewControllerは通常、UI画面を構築するための開始点です。 その名前には、「ビュー」と「コントローラー」の両方が含まれていることに注意してください。 これは、「ビューを制御する」ことを意味します。 「コントローラー」と「ビュー」の両方のコードが内部に含まれる必要があるという意味ではありません。

ビューとコントローラーコードのこの混合は、 UIViewController内の小さなサブビューのIBOutletsを移動し、 UIViewControllerから直接それらのサブビューを操作するときによく発生します。 代わりに、そのコードをカスタムUIViewサブクラス内にラップする必要があります。

これにより、ビューとコントローラーのコードパスが交差する可能性があることは簡単にわかります。

MVVMを救助する

ここでMVVMパターンが役に立ちます。

UIViewControllerはMVCパターンのコントローラーであると想定されており、すでにビューで多くのことを行っているため、新しいパターンのビューであるMVVMにそれらをマージできます。

MVVMデザインパターン

MVVMデザインパターンでは、モデルはMVCパターンと同じです。 単純なデータを表します。

ビューは、 UIViewまたはUIViewControllerオブジェクトで表され、 .storyboard .xibが付属しており、準備されたデータのみを表示する必要があります。 (たとえば、ビュー内にNSDateFormatterコードは必要ありません。)

ViewModelからの単純なフォーマットされた文字列のみ。

ViewModelは、すべての非同期ネットワークコード、視覚的な表示のためのデータ準備コード、およびモデルの変更をリッスンするコードを非表示にします。 これらはすべて、この特定のビューに適合するようにモデル化された、明確に定義されたAPIの背後に隠されています。

MVVMを使用する利点の1つは、テストです。 ViewModelは純粋なNSObject (またはstructなど)であり、 UIKitコードと結合されていないため、UIコードに影響を与えることなく、単体テストでより簡単にテストできます。

これで、 ViewUIViewController / UIView )がはるかにシンプルになり、 ViewModelモデルビューの間の接着剤として機能します。

SwiftでのMVVMの適用

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

次に、アプリを実行します。 次のようになります。

iOSアプリ

スコア、時間、およびチーム名を担当する中央のビューには、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内のプロパティとして設定する必要があります。

HomeViewControllerGameScoreboardEditorViewControllerviewModelプロパティの設定をどのように担当したか覚えていますか?

同様に、 GameScoreboardEditorViewControllerはPlayerScoreboardMoveEditorViewの親ビューであり、 PlayerScoreboardMoveEditorViewGameScoreboardEditorViewControllerオブジェクトの作成を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オブジェクトからの実際のデータであるかどうかを確認します。

iOSアプリ

この時点で、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() }

アプリを実行し、いくつかの移動ボタンをクリックします。 アクションボタンをクリックすると、プレーヤービュー内のカウンター値がどのように変化するかがわかります。

iOSアプリ

これで、 PlayerScoreboardMoveEditorViewPlayerScoreboardMoveEditorViewModelが完成しました。

これは簡単でした。

ここで、メインビュー( GameScoreboardEditorViewController )で同じことを行う必要があります。

まず、 GameScoreboardEditorViewModelを開き、ビューのライフサイクル中に変更されると予想される値を確認します。

timescoreisFinishedisPaused定義を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)

あなたは今ポイントを取得する必要があります。

StringIntBoolなどのプリミティブ値を、これらのオブジェクトの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 .

Related: Working With Static Patterns: A Swift MVVM Tutorial