Tutoriel Swift : Une introduction au modèle de conception MVVM

Publié: 2022-03-11

Vous démarrez donc un nouveau projet iOS, vous avez reçu du concepteur tous les documents .pdf et .sketch nécessaires, et vous avez déjà une vision de la façon dont vous allez créer cette nouvelle application.

Vous commencez à transférer des écrans d'interface utilisateur à partir des croquis du concepteur dans vos ViewController .swift , .xib et .storyboard .

UITextField ici, UITableView là, quelques UILabels et une pincée de UIButtons . IBOutlets et IBActions sont également inclus. Tout va bien, nous sommes toujours dans la zone UI.

Cependant, il est temps de faire quelque chose avec tous ces éléments d'interface utilisateur ; UIButtons recevra des touches du doigt, UILabels et UITableViews auront besoin de quelqu'un pour leur dire quoi afficher et dans quel format.

Du coup, vous avez plus de 3 000 lignes de code.

3 000 lignes de code Swift

Vous vous êtes retrouvé avec beaucoup de code spaghetti.

La première étape pour résoudre ce problème consiste à appliquer le modèle de conception Modèle-Vue-Contrôleur (MVC). Cependant, ce modèle a ses propres problèmes. Il y a le modèle de conception Model-View-ViewModel (MVVM) qui sauve la journée.

Gérer le code des spaghettis

En un rien de temps, votre ViewController de départ est devenu trop intelligent et trop massif.

Code réseau, code d'analyse des données, code d'ajustement des données pour la présentation de l'interface utilisateur, notifications d'état de l'application, changements d'état de l'interface utilisateur. Tout ce code emprisonné à l'intérieur de la if -ologie d'un seul fichier qui ne peut pas être réutilisé et qui ne tiendrait que dans ce projet.

Votre code ViewController est devenu le fameux code spaghetti.

Comment est-ce arrivé?

La raison probable est quelque chose comme ceci :

Vous étiez pressé de voir comment les données back-end se comportaient à l'intérieur du UITableView , vous avez donc mis quelques lignes de code réseau dans une méthode temporaire du ViewController juste pour récupérer ce .json du réseau. Ensuite, vous deviez traiter les données à l'intérieur de ce .json , vous avez donc écrit une autre méthode temporaire pour y parvenir. Ou, pire encore, vous l'avez fait de la même manière.

Le ViewController cessé de croître lorsque le code d'autorisation de l'utilisateur est arrivé. Ensuite, les formats de données ont commencé à changer, l'interface utilisateur a évolué et nécessitait des changements radicaux, et vous n'arrêtiez pas d'ajouter plus de if s dans une if -ology déjà massive.

Mais, comment se fait-il que UIViewController soit ce qui est devenu incontrôlable ?

Le UIViewController est l'endroit logique pour commencer à travailler sur votre code d'interface utilisateur. Il représente l'écran physique que vous voyez lorsque vous utilisez n'importe quelle application avec votre appareil iOS. Même Apple utilise UIViewControllers dans son application système principale lorsqu'il bascule entre différentes applications et ses interfaces utilisateur animées.

Apple fonde son abstraction d'interface utilisateur à l'intérieur de UIViewController , car il est au cœur du code d'interface utilisateur iOS et fait partie du modèle de conception MVC .

En relation: Les 10 erreurs les plus courantes que les développeurs iOS ne savent pas qu'ils font

Mise à niveau vers le modèle de conception MVC

Modèle de conception MVC

Dans le modèle de conception MVC, View est censé être inactif et n'affiche que les données préparées à la demande.

Le contrôleur doit travailler sur les données du modèle pour les préparer aux vues , qui affichent ensuite ces données.

View est également responsable de la notification au Contrôleur de toute action, telle que les contacts de l'utilisateur.

Comme mentionné, UIViewController est généralement le point de départ de la création d'un écran d'interface utilisateur. Notez que dans son nom, il contient à la fois la "vue" et le "contrôleur". Cela signifie qu'il "contrôle la vue". Cela ne signifie pas que le code "contrôleur" et "vue" doit aller à l'intérieur.

Ce mélange de code de vue et de contrôleur se produit souvent lorsque vous déplacez des IBOutlets de petites sous-vues à l'intérieur de UIViewController et que vous manipulez ces sous-vues directement à partir de UIViewController . Au lieu de cela, vous auriez dû encapsuler ce code dans une sous-classe UIView personnalisée.

Il est facile de voir que cela pourrait entraîner le croisement des chemins de code View et Controller.

MVVM à la rescousse

C'est là que le modèle MVVM est utile.

Étant donné que UIViewController est censé être un Controller dans le modèle MVC, et qu'il fait déjà beaucoup avec les Views , nous pouvons les fusionner dans la vue de notre nouveau modèle - MVVM .

Modèle de conception MVVM

Dans le modèle de conception MVVM, Model est le même que dans le modèle MVC. Il représente des données simples.

La vue est représentée par les objets UIView ou UIViewController , accompagnés de leurs fichiers .xib et .storyboard , qui ne doivent afficher que des données préparées. (Nous ne voulons pas avoir de code NSDateFormatter , par exemple, dans la vue.)

Seule une chaîne simple et formatée provenant de ViewModel .

ViewModel masque tout le code réseau asynchrone, le code de préparation des données pour la présentation visuelle et le code écoutant les modifications du modèle . Tous ces éléments sont cachés derrière une API bien définie, modélisée pour s'adapter à cette vue particulière.

L'un des avantages de l'utilisation de MVVM est le test. Étant donné que ViewModel est un NSObject pur (ou une struct par exemple) et qu'il n'est pas couplé au code UIKit , vous pouvez le tester plus facilement dans vos tests unitaires sans que cela n'affecte le code de l'interface utilisateur.

Maintenant, la vue ( UIViewController / UIView ) est devenue beaucoup plus simple tandis que ViewModel agit comme le ciment entre le modèle et la vue .

Application de MVVM dans Swift

MVVM dans Swift

Pour vous montrer MVVM en action, vous pouvez télécharger et examiner l'exemple de projet Xcode créé pour ce didacticiel ici. Ce projet utilise Swift 3 et Xcode 8.1.

Il existe deux versions du projet : Starter et Finished.

La version Finished est une mini application terminée, où Starter est le même projet mais sans les méthodes et les objets implémentés.

Tout d'abord, je vous propose de télécharger le projet Starter , et de suivre ce tutoriel. Si vous avez besoin d'une référence rapide du projet pour plus tard, téléchargez le projet terminé .

Présentation du projet de didacticiel

Le projet de tutoriel est une application de basket-ball pour le suivi des actions des joueurs pendant le match.

Demande de basket-ball

Il est utilisé pour le suivi rapide des mouvements de l'utilisateur et du score global dans un jeu de ramassage.

Deux équipes jouent jusqu'à ce que le score de 15 (avec au moins deux points d'écart) soit atteint. Chaque joueur peut marquer un point à deux points, et chaque joueur peut assister, rebondir et commettre une faute.

La hiérarchie du projet ressemble à ceci :

Hiérarchie du projet

Modèle

  • Game.swift
    • Contient la logique du jeu, suit le score global, suit les mouvements de chaque joueur.
  • Team.swift
    • Contient le nom de l'équipe et la liste des joueurs (trois joueurs dans chaque équipe).
  • Player.swift
    • Un seul joueur avec un nom.

Voir

  • HomeViewController.swift
    • Contrôleur de vue racine, qui présente le GameScoreboardEditorViewController
  • GameScoreboardEditorViewController.swift
    • Complété avec la vue Interface Builder dans Main.storyboard .
    • Écran d'intérêt pour ce tutoriel.
  • PlayerScoreboardMoveEditorView.swift
    • Complété avec la vue Interface Builder dans PlayerScoreboardMoveEditorView.xib
    • La sous-vue de la vue ci-dessus utilise également le modèle de conception MVVM.

AfficherModèle

  • Le groupe ViewModel est vide, c'est ce que vous allez construire dans ce tutoriel.

Le projet Xcode téléchargé contient déjà des espaces réservés pour les objets View ( UIView et UIViewController ). Le projet contient également des objets personnalisés conçus pour démontrer l'un des moyens de fournir des données aux objets ViewModel (groupe Services ).

Le groupe Extensions contient des extensions utiles pour le code de l'interface utilisateur qui n'entrent pas dans le cadre de ce didacticiel et sont explicites.

Si vous exécutez l'application à ce stade, elle affichera l'interface utilisateur terminée, mais rien ne se passe lorsqu'un utilisateur appuie sur les boutons.

En effet, vous avez uniquement créé des vues et IBActions sans les connecter à la logique de l'application et sans remplir les éléments de l'interface utilisateur avec les données du modèle (à partir de l'objet Game , comme nous le verrons plus tard).

Connexion de la vue et du modèle avec ViewModel

Dans le modèle de conception MVVM, View ne doit rien savoir du modèle. La seule chose que View sait, c'est comment travailler avec un ViewModel.

Commencez par examiner votre vue.

Dans le fichier GameScoreboardEditorViewController.swift , la méthode fillUI est vide à ce stade. C'est l'endroit où vous souhaitez remplir l'interface utilisateur avec des données. Pour ce faire, vous devez fournir des données pour le ViewController . Vous faites cela avec un objet ViewModel.

Tout d'abord, créez un objet ViewModel qui contient toutes les données nécessaires pour ce ViewController .

Accédez au groupe de projet ViewModel Xcode, qui sera vide, créez un fichier GameScoreboardEditorViewModel.swift et faites-en un protocole.

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

L'utilisation de protocoles comme celui-ci garde les choses agréables et propres ; vous devez uniquement définir les données que vous utiliserez.

Ensuite, créez une implémentation pour ce protocole.

Créez un nouveau fichier, appelé GameScoreboardEditorViewModelFromGame.swift , et faites de cet objet une sous-classe de NSObject .

Rendez-le également conforme au protocole 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)") } }

Notez que vous avez fourni tout le nécessaire pour que le ViewModel fonctionne via l'initialiseur.

Vous lui avez fourni l'objet Game , qui est le modèle sous ce ViewModel.

Si vous exécutez l'application maintenant, cela ne fonctionnera toujours pas car vous n'avez pas connecté ces données ViewModel à la vue elle-même.

Revenez donc au fichier GameScoreboardEditorViewController.swift et créez une propriété publique nommée viewModel .

Faites-en du type GameScoreboardEditorViewModel .

Placez-le juste avant la méthode viewDidLoad dans GameScoreboardEditorViewController.swift .

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

Ensuite, vous devez implémenter la méthode fillUI .

Remarquez comment cette méthode est appelée à partir de deux endroits, l'observateur de la propriété viewModel ( didSet ) et la méthode viewDidLoad . En effet, nous pouvons créer un ViewController et lui attribuer un ViewModel avant de l'attacher à une vue (avant que la méthode viewDidLoad ne soit appelée).

D'un autre côté, vous pouvez attacher la vue de ViewController à une autre vue et appeler viewDidLoad , mais si viewModel n'est pas défini à ce moment-là, rien ne se passera.

C'est pourquoi vous devez d'abord vérifier si tout est configuré pour que vos données remplissent l'interface utilisateur. Il est important de protéger votre code contre une utilisation inattendue.

Allez donc dans la méthode fillUI et remplacez-la par le code suivant :

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

Maintenant, implémentez la méthode pauseButtonPress :

 @IBAction func pauseButtonPress(_ sender: AnyObject) { viewModel?.togglePause() }

Tout ce que vous avez à faire maintenant est de définir la propriété viewModel réelle sur ce ViewController . Vous faites cela "de l'extérieur".

Ouvrez le fichier HomeViewController.swift et décommentez le ViewModel ; créez et configurez des lignes dans la méthode showGameScoreboardEditorViewController :

 // uncomment this when view model is implemented let viewModel = GameScoreboardEditorViewModelFromGame(withGame: game) controller.viewModel = viewModel

Maintenant, lancez l'application. Ça devrait ressembler a quelque chose comme ca:

Application iOS

La vue du milieu, qui est responsable du score, du temps et des noms d'équipe, n'affiche plus les valeurs définies dans Interface Builder.

Maintenant, il affiche les valeurs de l'objet ViewModel lui-même, qui obtient ses données de l'objet Model réel (objet Game ).

Excellent! Mais qu'en est-il des vues des joueurs ? Ces boutons ne font toujours rien.

Vous savez que vous disposez de six vues pour le suivi des mouvements des joueurs.

Vous avez créé une sous-vue distincte, nommée PlayerScoreboardMoveEditorView pour cela, qui ne fait rien avec les données réelles pour l'instant et affiche les valeurs statiques qui ont été définies via le constructeur d'interface dans le fichier PlayerScoreboardMoveEditorView.xib .

Vous devez lui donner des données.

Vous le ferez de la même manière que vous l'avez fait avec GameScoreboardEditorViewController et GameScoreboardEditorViewModel .

Ouvrez le groupe ViewModel dans le projet Xcode et définissez le nouveau protocole ici.

Créez un nouveau fichier nommé PlayerScoreboardMoveEditorViewModel.swift et insérez-y le code suivant :

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

Ce protocole ViewModel a été conçu pour s'adapter à votre PlayerScoreboardMoveEditorView , tout comme vous l'avez fait dans la vue parent, GameScoreboardEditorViewController .

Vous devez avoir des valeurs pour les cinq mouvements différents qu'un utilisateur peut effectuer et vous devez réagir lorsque l'utilisateur touche l'un des boutons d'action. Vous avez également besoin d'une String pour le nom du joueur.

Après avoir fait cela, créez une classe concrète qui implémente ce protocole, comme vous l'avez fait avec la vue parent ( GameScoreboardEditorViewController ).

Créez ensuite une implémentation de ce protocole : créez un nouveau fichier, nommez-le PlayerScoreboardMoveEditorViewModelFromPlayer.swift et faites de cet objet une sous-classe de NSObject . Rendez-le également conforme au protocole 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))" } }

Maintenant, vous devez avoir un objet qui créera cette instance "de l'extérieur" et le définira comme une propriété à l'intérieur de PlayerScoreboardMoveEditorView .

Rappelez-vous comment HomeViewController était responsable de la définition de la propriété viewModel sur le GameScoreboardEditorViewController ?

De la même manière, GameScoreboardEditorViewController est une vue parente de votre PlayerScoreboardMoveEditorView et GameScoreboardEditorViewController sera responsable de la création des objets PlayerScoreboardMoveEditorViewModel .

Vous devez d'abord développer votre GameScoreboardEditorViewModel .

Ouvrez GameScoreboardEditorViewMode l et ajoutez ces deux propriétés :

 var homePlayers: [PlayerScoreboardMoveEditorViewModel] { get } var awayPlayers: [PlayerScoreboardMoveEditorViewModel] { get }

Mettez également à jour GameScoreboardEditorViewModelFromGame avec ces deux propriétés juste au-dessus de la méthode initWithGame :

 let homePlayers: [PlayerScoreboardMoveEditorViewModel] let awayPlayers: [PlayerScoreboardMoveEditorViewModel]

Ajoutez ces deux lignes dans initWithGame :

 self.homePlayers = GameScoreboardEditorViewModelFromGame.playerViewModels(from: game.homeTeam.players, game: game) self.awayPlayers = GameScoreboardEditorViewModelFromGame.playerViewModels(from: game.awayTeam.players, game: game)

Et bien sûr, ajoutez la méthode playerViewModelsWithPlayers manquante :

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

Génial!

Vous avez mis à jour votre ViewModel ( GameScoreboardEditorViewModel ) avec le tableau des joueurs à domicile et à l'extérieur. Vous devez encore remplir ces deux tableaux.

Vous ferez cela au même endroit où vous avez utilisé ce viewModel pour remplir l'interface utilisateur.

Ouvrez GameScoreboardEditorViewController et accédez à la méthode fillUI . Ajoutez ces lignes à la fin de la méthode :

 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]

Pour le moment, vous avez des erreurs de construction car vous n'avez pas ajouté la propriété viewModel réelle dans PlayerScoreboardMoveEditorView .

Ajoutez le code suivant au-dessus de la init method inside the PlayerScoreboardMoveEditorView`.

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

Et implémentez la méthode 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 }

Enfin, exécutez l'application et voyez comment les données dans les éléments de l'interface utilisateur sont les données réelles de l'objet Game .

Application iOS

À ce stade, vous disposez d'une application fonctionnelle qui utilise le modèle de conception MVVM.

Il cache bien le modèle de la vue, et votre vue est beaucoup plus simple que celle à laquelle vous êtes habitué avec le MVC.

Jusqu'à présent, vous avez créé une application qui contient la vue et son ViewModel.

Cette vue a également six instances de la même sous-vue (vue du lecteur) avec son ViewModel.

Cependant, comme vous pouvez le remarquer, vous ne pouvez afficher les données dans l'interface utilisateur qu'une seule fois (dans la méthode fillUI ) et ces données sont statiques.

Si vos données dans les vues ne changent pas pendant la durée de vie de cette vue, vous disposez d'une bonne solution propre pour utiliser MVVM de cette manière.

Rendre le ViewModel dynamique

Parce que vos données vont changer, vous devez rendre votre ViewModel dynamique.

Cela signifie que lorsque le modèle change, ViewModel doit modifier ses valeurs de propriété publiques ; cela propagerait le changement à la vue, qui est celle qui mettra à jour l'interface utilisateur.

Il existe de nombreuses façons de procéder.

Lorsque le modèle change, ViewModel est averti en premier.

Vous avez besoin d'un mécanisme pour propager ce qui change jusqu'à la vue.

Certaines des options incluent RxSwift, qui est une assez grande bibliothèque et prend un certain temps pour s'y habituer.

ViewModel peut déclencher des NSNotification à chaque changement de valeur de propriété, mais cela ajoute beaucoup de code qui nécessite une gestion supplémentaire, comme l'abonnement aux notifications et le désabonnement lorsque la vue est désallouée.

Key-Value-Observing (KVO) est une autre option, mais les utilisateurs confirmeront que son API n'est pas sophistiquée.

Dans ce didacticiel, vous utiliserez les génériques et les fermetures Swift, qui sont bien décrits dans l'article Liaisons, Génériques, Swift et MVVM.

Revenons maintenant à l'exemple d'application.

Accédez au groupe de projets ViewModel et créez un nouveau fichier 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 } }

Vous utiliserez cette classe pour les propriétés de vos ViewModels que vous prévoyez de modifier au cours du cycle de vie de la vue.

Tout d'abord, commencez par PlayerScoreboardMoveEditorView et son ViewModel, PlayerScoreboardMoveEditorViewModel .

Ouvrez PlayerScoreboardMoveEditorViewModel et regardez ses propriétés.

Étant donné que le playerName ne devrait pas changer, vous pouvez le laisser tel quel.

Les cinq autres propriétés (cinq types de mouvement) changeront, vous devez donc faire quelque chose à ce sujet. La solution? La classe Dynamic mentionnée ci-dessus que vous venez d'ajouter au projet.

Dans PlayerScoreboardMoveEditorViewModel supprimez les définitions de cinq chaînes représentant le nombre de mouvements et remplacez-les par ceci :

 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 }

Voici à quoi devrait ressembler le protocole 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() }

Ce type Dynamic vous permet de modifier la valeur de cette propriété particulière et, en même temps, de notifier l'objet change-listener, qui, dans ce cas, sera la vue.

Maintenant, mettez à jour l'implémentation actuelle de ViewModel PlayerScoreboardMoveEditorViewModelFromPlayer .

Remplacez ceci :

 var onePointMoveCount: String var twoPointMoveCount: String var assistMoveCount: String var reboundMoveCount: String var foulMoveCount: String

avec ce qui suit :

 let onePointMoveCount: Dynamic<String> let twoPointMoveCount: Dynamic<String> let assistMoveCount: Dynamic<String> let reboundMoveCount: Dynamic<String> let foulMoveCount: Dynamic<String>

Remarque : Vous pouvez déclarer ces propriétés en tant que constantes avec let , car vous ne modifierez pas la propriété réelle. Vous allez modifier la propriété value de l'objet Dynamic .

Maintenant, il y a des erreurs de construction parce que vous n'avez pas initialisé vos objets Dynamic .

Dans la méthode init de PlayerScoreboardMoveEditorViewModelFromPlayer , remplacez l'initialisation des propriétés de déplacement par ceci :

 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))")

Dans PlayerScoreboardMoveEditorViewModelFromPlayer accédez à la méthode makeMove et remplacez-la par le code suivant :

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

Comme vous pouvez le voir, vous avez créé des instances de la classe Dynamic et lui avez attribué des valeurs String . Lorsque vous devez mettre à jour les données, ne modifiez pas la propriété Dynamic elle-même ; plutôt mettre à jour sa propriété de value .

Génial! PlayerScoreboardMoveEditorViewModel est maintenant dynamique.

Utilisons-le et allons à la vue qui écoutera réellement ces changements.

Ouvrez PlayerScoreboardMoveEditorView et sa méthode fillUI (vous devriez voir des erreurs de construction dans cette méthode à ce stade puisque vous essayez d'attribuer une valeur String au type d'objet Dynamic .)

Remplacez les lignes « erronées » :

 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

avec ce qui suit :

 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 }

Ensuite, implémentez les cinq méthodes qui représentent les actions de déplacement (section Button Action ) :

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

Exécutez l'application et cliquez sur certains boutons de déplacement. Vous verrez comment les valeurs des compteurs dans les vues du joueur changent lorsque vous cliquez sur le bouton d'action.

Application iOS

Vous avez terminé avec PlayerScoreboardMoveEditorView et PlayerScoreboardMoveEditorViewModel .

C'était simple.

Maintenant, vous devez faire la même chose avec votre vue principale ( GameScoreboardEditorViewController ).

Tout d'abord, ouvrez GameScoreboardEditorViewModel et voyez quelles valeurs devraient changer au cours du cycle de vie de la vue.

Remplacez les définitions time , score , isFinished , isPaused par les versions 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 } }

Accédez à l'implémentation de ViewModel ( GameScoreboardEditorViewModelFromGame ) et faites de même avec les propriétés déclarées dans le protocole.

Remplacez ceci :

 var time: String var score: String var isFinished: Bool var isPaused: Bool

avec ce qui suit :

 let time: Dynamic<String> let score: Dynamic<String> let isFinished: Dynamic<Bool> let isPaused: Dynamic<Bool>

Vous obtiendrez maintenant quelques erreurs, car vous avez changé le type de ViewModel de String et Bool en Dynamic<String> et Dynamic<Bool> .

Réparons ça.

Corrigez la méthode togglePause en la remplaçant par ce qui suit :

 func togglePause() { if isPaused.value { startTimer() } else { pauseTimer() } self.isPaused.value = !isPaused.value }

Remarquez que le seul changement est que vous ne définissez plus la valeur de la propriété directement sur la propriété. Au lieu de cela, vous le définissez sur la propriété value de l'objet.

Maintenant, corrigez la méthode initWithGame en remplaçant ceci :

 self.time = GameScoreboardEditorViewModelFromGame.timeRemainingPretty(game) self.score = GameScoreboardEditorViewModelFromGame.scorePretty(game) self.isFinished = game.isFinished self.isPaused = true

avec ce qui suit :

 self.time = Dynamic(GameScoreboardEditorViewModelFromGame.timeRemainingPretty(for: game)) self.score = Dynamic(GameScoreboardEditorViewModelFromGame.scorePretty(for: game)) self.isFinished = Dynamic(game.isFinished) self.isPaused = Dynamic(true)

Vous devriez comprendre le point maintenant.

Vous encapsulez les valeurs primitives, telles que String , Int et Bool , avec les versions Dynamic<T> de ces objets, qui vous donnent le mécanisme de liaison léger.

Vous avez une autre erreur à corriger.

Dans la méthode startTimer , remplacez la ligne d'erreur par :

 self.time.value = GameScoreboardEditorViewModelFromGame.timeRemainingPretty(for: self.game)

Vous avez mis à jour votre ViewModel pour qu'il soit dynamique, tout comme vous l'avez fait avec le ViewModel du lecteur. Mais vous devez toujours mettre à jour votre vue ( GameScoreboardEditorViewController ).

Remplacez toute la méthode fillUI par ceci :

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

La seule différence est que vous avez modifié vos quatre propriétés dynamiques et ajouté des écouteurs de modification à chacune d'elles.

À ce stade, si vous exécutez votre application, basculer le bouton Démarrer/Pause démarrera et mettra en pause le chronomètre de jeu. Ceci est utilisé pour les temps morts pendant le jeu.

Vous avez presque terminé sauf que le score ne change pas dans l'interface utilisateur, lorsque vous appuyez sur l'un des boutons de point (bouton 1 et 2 points).

C'est parce que vous n'avez pas vraiment propagé les changements de score dans l'objet de modèle de Game sous-jacent jusqu'au ViewModel.

Alors, ouvrez l'objet modèle de Game pour un petit examen. Vérifiez sa méthode 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) }

Cette méthode fait deux choses importantes.

Tout d'abord, il définit la propriété isFinished sur true si le jeu est terminé en fonction des scores des deux équipes.

Après cela, il publie une notification indiquant que le score a changé. Vous écouterez cette notification dans GameScoreboardEditorViewModelFromGame et mettrez à jour la valeur du score dynamique dans la méthode du gestionnaire de notification.

Add this line at the bottom of initWithGame method (don't forget the super.init() call to avoid errors):

 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.

Et maintenant?

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