Travailler avec des modèles statiques : un didacticiel Swift MVVM

Publié: 2022-03-11

Aujourd'hui, nous allons voir comment les nouvelles possibilités techniques et les attentes de nos utilisateurs pour les applications en temps réel basées sur les données créent de nouveaux défis dans la manière dont nous structurons nos programmes, en particulier nos applications mobiles. Bien que cet article concerne iOS et Swift, de nombreux modèles et conclusions s'appliquent également aux applications Android et Web.

Il y a eu une évolution importante dans le fonctionnement des applications mobiles modernes au cours des dernières années. Grâce à un accès Internet plus répandu et à des technologies telles que les notifications push et les WebSockets, l'utilisateur n'est généralement plus la seule source d'événements d'exécution - et plus nécessairement la plus importante - dans de nombreuses applications mobiles d'aujourd'hui.

Examinons de plus près à quel point deux modèles de conception Swift fonctionnent chacun avec une application de chat moderne : le modèle classique modèle-vue-contrôleur (MVC) et un modèle immuable simplifié modèle-vue-vuemodèle (MVVM, parfois stylisé "le modèle ViewModel ”). Les applications de chat en sont un bon exemple, car elles disposent de nombreuses sources de données et doivent mettre à jour leurs interfaces utilisateur de différentes manières chaque fois que des données sont reçues.

Notre application de chat

L'application que nous allons utiliser comme guide dans ce tutoriel Swift MVVM va avoir la plupart des fonctionnalités de base que nous connaissons des applications de chat comme WhatsApp. Passons en revue les fonctionnalités que nous allons implémenter et comparons MVVM à MVC. L'application:

  • Chargera les chats précédemment reçus à partir du disque
  • Synchronisera les discussions existantes via une requête GET avec le serveur
  • Recevra des notifications push lorsqu'un nouveau message est envoyé à l'utilisateur
  • Sera connecté à un WebSocket une fois que nous serons dans un écran de chat
  • Peut POST un nouveau message dans un chat
  • Affichera une notification dans l'application lorsqu'un nouveau message est reçu d'un chat dans lequel nous ne sommes pas actuellement
  • Affichera un nouveau message immédiatement lorsque nous recevrons un nouveau message pour le chat en cours
  • Enverra un message lu lorsque nous lirons un message non lu
  • Recevra un message de lecture lorsque quelqu'un lira notre message
  • Met à jour le badge du compteur de messages non lus sur l'icône de l'application
  • Synchronise tous les messages reçus ou modifiés vers Core Data

Dans cette application de démonstration, il n'y aura pas de véritable implémentation d'API, WebSocket ou Core Data pour simplifier un peu l'implémentation du modèle. Au lieu de cela, j'ai ajouté un chatbot qui commencera à vous répondre une fois que vous aurez commencé une conversation. Cependant, tous les autres routages et appels sont implémentés comme ils le seraient si le stockage et les connexions étaient réels, y compris de petites pauses asynchrones avant le retour.

Les trois écrans suivants ont été construits :

Écrans Liste de discussion, Créer une discussion et Messages.

MVC classique

Tout d'abord, il y a le modèle MVC standard pour créer une application iOS. C'est ainsi qu'Apple structure tout son code de documentation et que les API et les éléments de l'interface utilisateur s'attendent à fonctionner. C'est ce que la plupart des gens apprennent lorsqu'ils suivent un cours iOS.

On reproche souvent à MVC de conduire à des UIViewController gonflés de quelques milliers de lignes de code. Mais s'il est bien appliqué, avec une bonne séparation entre chaque couche, nous pouvons avoir des ViewController assez fins qui n'agissent que comme des gestionnaires intermédiaires entre les View , les Model et les autres Controller .

Voici l'organigramme de l'implémentation MVC de l'application (en omettant le CreateViewController pour plus de clarté) :

Organigramme d'implémentation MVC, en omettant le CreateViewController pour plus de clarté.

Passons en revue les couches en détail.

Modèle

La couche modèle est généralement la couche la moins problématique dans MVC. Dans ce cas, j'ai choisi d'utiliser ChatWebSocket , ChatModel et PushNotificationController pour assurer la médiation entre les objets Chat et Message , les sources de données externes et le reste de l'application. ChatModel est la source de vérité dans l'application et ne fonctionne qu'en mémoire dans cette application de démonstration. Dans une application réelle, il serait probablement soutenu par Core Data. Enfin, ChatEndpoint gère tous les appels HTTP.

Voir

Les vues sont assez volumineuses car elles doivent gérer de nombreuses responsabilités puisque j'ai soigneusement séparé tout le code de vue des UIViewController s. J'ai fait ce qui suit :

  • Utilisez le modèle d' enum d'état (très recommandé) pour définir l'état actuel de la vue.
  • Ajout des fonctions liées aux boutons et autres éléments d'interface déclencheurs d'action (comme appuyer sur Retour tout en saisissant un nom de contact.)
  • Configurez les contraintes et rappelez le délégué à chaque fois.

Une fois que vous avez lancé un UITableView dans le mélange, les vues sont maintenant beaucoup plus grandes que les UIViewController s, ce qui conduit à plus de 300 lignes de code inquiétantes et à de nombreuses tâches mixtes dans le ChatView .

Manette

Comme toute la logique de gestion du modèle a été déplacée vers ChatModel . Tout le code de la vue - qui pourrait se cacher ici dans des projets moins optimaux et séparés - vit maintenant dans la vue, de sorte que les UIViewController sont assez minces. Le contrôleur de vue est complètement inconscient de l'apparence des données du modèle, de la manière dont elles sont récupérées ou de la manière dont elles doivent être affichées - il se contente de les coordonner. Dans l'exemple de projet, aucun des UIViewController ne dépasse 150 lignes de code.

Cependant, le ViewController fait toujours les choses suivantes :

  • Être un délégué pour la vue et d'autres contrôleurs de vue
  • Instanciation et push (ou popping) des contrôleurs de vue si nécessaire
  • Envoyer et recevoir des appels vers et depuis le ChatModel
  • Démarrage et arrêt du WebSocket en fonction de l'étape du cycle du contrôleur de vue
  • Prendre des décisions logiques comme ne pas envoyer de message s'il est vide
  • Mise à jour de la vue

C'est encore beaucoup, mais il s'agit principalement de coordination, de traitement des blocs de rappel et de transfert.

Avantages

  • Ce schéma est compris de tous et promu par Apple
  • Fonctionne avec toute la documentation
  • Aucun cadre supplémentaire nécessaire

Inconvénients

  • Les contrôleurs de vue ont beaucoup de tâches ; beaucoup d'entre eux transmettent essentiellement des données entre la vue et la couche de modèle
  • Pas très apte à gérer plusieurs sources d'événements
  • Les classes ont tendance à en savoir beaucoup sur les autres classes

Définition du problème

Cela fonctionne très bien tant que l'application suit les actions de l'utilisateur et y répond, comme vous pouvez imaginer qu'une application comme Adobe Photoshop ou Microsoft Word fonctionnerait. L'utilisateur effectue une action, l'interface utilisateur se met à jour, répétez.

Mais les applications modernes sont connectées, souvent de plusieurs manières. Par exemple, vous interagissez via une API REST, recevez des notifications push et, dans certains cas, vous vous connectez également à un WebSocket.

Avec cela, le contrôleur de vue doit soudainement gérer plus de sources d'informations, et chaque fois qu'un message externe est reçu sans que l'utilisateur ne le déclenche, comme recevoir un message via le WebSocket, les sources d'informations doivent retrouver leur chemin vers la droite. afficher les contrôleurs. Cela nécessite beaucoup de code juste pour coller chaque partie ensemble pour effectuer ce qui est fondamentalement la même tâche.

Sources de données externes

Voyons ce qui se passe lorsque nous recevons un message push :

 class PushNotificationController { class func received(notification: UNNotification, whenProcessed result: (_ shouldShow: Bool) -> Void) { let shouldShowNotification: Bool defer { result(shouldShowNotification) } let content = notification.request.content let date = DateParser.date(from: content.subtitle) ?? Date() let sender: Message.Sender = .other(name: content.title) let pushedMessage = Message(with: sender, message: content.body, state: .sent, sendDate: date) ChatModel.received(message: pushedMessage, by: content.title) if let chatViewController = chatViewController(handling: content.title) { chatViewController.received(message: pushedMessage) shouldShowNotification = false } else { shouldShowNotification = true } updateChats(for: content.title) } private static func updateChats(for contact: String) { guard let chat = ChatModel.loadedChats.first(where: { (chat) -> Bool in chat.contact == contact }) else { return assertionFailure("Chat for received message should always exist") } BaseNavigationViewController.navigationController?.viewControllers.forEach({ (viewController) in switch viewController { case let chatsViewController as UpdatedChatDelegate: chatsViewController.updated(chat: chat) default: break } }) } private static func chatViewController(handling contact: String) -> ChatViewController? { guard let lastViewController = BaseNavigationViewController.navigationController?.viewControllers.last as? ChatViewController, lastViewController.chat.contact == contact else { return nil } return lastViewController } }

Nous devons parcourir manuellement la pile de contrôleurs de vue pour déterminer s'il y a un contrôleur de vue qui doit se mettre à jour après avoir reçu une notification push. Dans ce cas, nous souhaitons également mettre à jour les écrans qui implémentent le UpdatedChatDelegate , qui, dans ce cas, n'est que le ChatsViewController . Nous le faisons également pour savoir si nous devons supprimer la notification car nous examinons déjà le Chat elle était destinée. Dans ce cas, nous livrons finalement le message au contrôleur de vue à la place. Il est assez clair que PushNotificationController besoin d'en savoir trop sur l'application pour pouvoir faire son travail.

Si le ChatWebSocket transmettait également des messages à d'autres parties de l'application, au lieu d'avoir une relation un à un avec le ChatViewController , nous serions confrontés au même problème.

Il est clair que nous devons écrire du code assez invasif chaque fois que nous ajoutons une autre source externe. Ce code est également assez fragile, car il s'appuie fortement sur la structure de l'application et délègue la transmission des données dans la hiérarchie pour fonctionner.

Délégués

Le modèle MVC ajoute également une complexité supplémentaire au mélange une fois que nous avons ajouté d'autres contrôleurs de vue. En effet, les contrôleurs de vue ont tendance à se connaître via des délégués, des initialiseurs et, dans le cas des storyboards prepareForSegue lors de la transmission de données et de références. Chaque contrôleur de vue gère ses propres connexions au modèle ou aux contrôleurs médiateurs, et ils envoient et reçoivent des mises à jour.

En outre, les vues communiquent avec les contrôleurs de vue via des délégués. Bien que cela fonctionne, cela signifie que nous devons suivre de nombreuses étapes pour transmettre les données, et je me retrouve toujours à refactoriser beaucoup autour des rappels et à vérifier si les délégués sont vraiment définis.

Il est possible de casser un contrôleur de vue en modifiant le code dans un autre, comme les données obsolètes dans le ChatsListViewController car le ChatViewController n'appelle plus updated(chat: Chat) . Surtout dans des scénarios plus complexes, il est difficile de tout synchroniser.

Séparation entre la vue et le modèle

En supprimant tout le code lié à la vue du contrôleur de vue vers customView s et en déplaçant tout le code lié au modèle vers des contrôleurs spécialisés, le contrôleur de vue est assez léger et séparé. Cependant, il reste un problème : il y a un écart entre ce que la vue veut afficher et les données qui résident dans le modèle. Un bon exemple est le ChatListView . Ce que nous voulons afficher, c'est une liste de cellules qui nous disent avec qui nous parlons, quel était le dernier message, la date du dernier message et combien de messages non lus restent dans le Chat :

Compteur de messages non lus dans l'écran de chat.

Cependant, nous adoptons un modèle qui ne sait pas ce que nous voulons voir. Au lieu de cela, il s'agit simplement d'un Chat avec un contact, contenant des messages :

 class Chat { let contact: String var messages: [Message] init(with contact: String, messages: [Message] = []) { self.contact = contact self.messages = messages } }

Il est maintenant possible d'ajouter rapidement du code supplémentaire qui nous donnera le dernier message et le nombre de messages, mais le formatage des dates en chaînes est une tâche qui appartient fermement à la couche vue :

 var unreadMessages: Int { return messages.filter { switch ($0.sender, $0.state) { case (.user, _), (.other, .read): return false case (.other, .sending), (.other, .sent): return true } }.count } var lastMessage: Date? { return messages.last?.sendDate }

Donc finalement on formate la date dans le ChatItemTableViewCell quand on l'affiche :

 func configure(with chat: Chat) { participant.text = chat.contact lastMessage.text = chat.messages.last?.message ?? "" lastMessageDate.text = chat.lastMessage.map { lastMessageDate in DateRenderer.string(from: lastMessageDate) } ?? "" show(unreadMessageCount: chat.unreadMessages) }

Même dans un exemple assez simple, il est assez clair qu'il existe une tension entre ce dont la vue a besoin et ce que le modèle fournit.

MVVM piloté par les événements statiques, alias une version pilotée par les événements statiques du "modèle ViewModel"

Static MVVM fonctionne avec des modèles de vue, mais au lieu de créer un trafic bidirectionnel à travers eux, un peu comme nous avions l'habitude de le faire via notre contrôleur de vue avec MVC, nous créons des modèles de vue immuables qui mettent à jour l'interface utilisateur chaque fois que l'interface utilisateur doit changer en réponse à un événement. .

Un événement peut être déclenché par presque n'importe quelle partie du code, tant qu'il est capable de fournir les données associées requises par l'événement enum . Par exemple, la réception de l'événement received(new: Message) peut être déclenchée par une notification push, le WebSocket ou un appel réseau normal.

Voyons cela dans un schéma :

Organigramme de mise en œuvre de MVVM.

À première vue, cela semble être un peu plus complexe que l'exemple MVC classique, car il y a beaucoup plus de classes impliquées pour accomplir exactement la même chose. Mais à y regarder de plus près, aucune des relations n'est plus bidirectionnelle.

Plus important encore, chaque mise à jour de l'interface utilisateur est déclenchée par un événement, il n'y a donc qu'un seul chemin à travers l'application pour tout ce qui se passe. Il est immédiatement clair à quels événements vous pouvez vous attendre. Il est également clair où vous devez en ajouter un nouveau si nécessaire, ou ajouter un nouveau comportement lorsque vous répondez à des événements existants.

Après le refactoring, je me suis retrouvé avec beaucoup de nouvelles classes, comme je l'ai montré ci-dessus. Vous pouvez trouver mon implémentation de la version statique de MVVM sur GitHub. Cependant, lorsque je compare les modifications avec l'outil cloc , il devient clair qu'il n'y a en fait pas beaucoup de code supplémentaire :

Modèle Des dossiers Vierge Commenter Code
MVC 30 386 217 1807
MVVM 51 442 359 1981

Il n'y a qu'une augmentation de 9 % des lignes de code. Plus important encore, la taille moyenne de ces fichiers est passée de 60 lignes de code à seulement 39.

Graphiques circulaires de lignes de code. Afficher les contrôleurs : MVC 287 contre MVVM 154 ou 47 % de moins ; Vues : MVC 523 contre MVVM 392 ou 26 % de moins.

Également crucial, les plus grosses baisses peuvent être trouvées dans les fichiers qui sont généralement les plus volumineux dans MVC : les vues et les contrôleurs de vue. Les vues ne représentent que 74 % de leur taille d'origine et les contrôleurs de vue ne représentent plus que 53 % de leur taille d'origine.

Il convient également de noter qu'une grande partie du code supplémentaire est du code de bibliothèque qui aide à attacher des blocs à des boutons et à d'autres objets dans l'arborescence visuelle, sans nécessiter les modèles classiques @IBAction ou délégués de MVC.

Explorons les différentes couches de cette conception une par une.

Événement

L'événement est toujours un enum , généralement avec des valeurs associées. Souvent, ils chevaucheront l'une des entités de votre modèle, mais pas nécessairement. Dans ce cas, l'application est divisée en deux enum d'événements principales : ChatEvent et MessageEvent . ChatEvent est pour toutes les mises à jour sur les objets de chat eux-mêmes :

 enum ChatEvent { case started case loaded(chats: [Chat]) case creating(chat: Chat) case created(chat: Chat) case createChatFailed(reason: String) }

L'autre traite de tous les événements liés aux messages :

 enum MessageEvent { case sending(message: Message, contact: String, previousMessages: [Message]) case sent(message: Message, contact: String) case failedSending(message: Message, contact: String, reason: String) case received(message: Message, contact: String) case userReads(messagesSentBy: String) case userRead(othersMessages: [Message], sentBy: String) case otherRead(yourMessage: Message, reader: String) }

Il est important de limiter vos *Event enum s à une taille raisonnable. Si vous avez besoin de 10 cas ou plus, c'est généralement un signe que vous essayez de couvrir plus d'un sujet.

Remarque : Le concept enum est extrêmement puissant dans Swift. J'ai tendance à beaucoup utiliser les enum avec des valeurs associées, car elles peuvent éliminer beaucoup d'ambiguïté que vous auriez autrement avec des valeurs facultatives.

Tutoriel Swift MVVM : Routeur d'événements

Le routeur d'événements est le point d'entrée de chaque événement qui se produit dans l'application. Toute classe pouvant fournir la valeur associée peut créer un événement et l'envoyer au routeur d'événements. Ils peuvent donc être déclenchés par n'importe quel type de source, par exemple :

  • L'utilisateur passe à un contrôleur de vue particulier
  • L'utilisateur appuyant sur un certain bouton
  • L'application démarre
  • Des événements externes tels que :
    • Une requête réseau renvoyée avec un échec ou de nouvelles données
    • Notifications push
    • Messages WebSocket

Le routeur d'événements doit en savoir le moins possible sur la source de l'événement et de préférence rien du tout. Aucun des événements de cet exemple d'application n'a d'indicateur d'où ils viennent, il est donc très facile de mélanger n'importe quel type de source de message. Par exemple, le WebSocket déclenche le même événement received(message: Message, contact: String) qu'une nouvelle notification push.

Les événements sont (vous l'avez déjà deviné) acheminés vers les classes qui doivent poursuivre le traitement de ces événements. Généralement, les seules classes appelées sont la couche de modèle (si des données doivent être ajoutées, modifiées ou supprimées) et le gestionnaire d'événements. Je discuterai des deux un peu plus loin, mais la principale caractéristique du routeur d'événements est de donner un point d'accès facile à tous les événements et de transmettre le travail à d'autres classes. Voici le ChatEventRouter comme exemple :

 class ChatEventRouter { static func route(event: ChatEvent) { switch event { case .loaded(let chats): ChatEventHandler.loaded(chats: chats) case .creatingChat(let contact): let chat = ChatModel.create(chatWith: contact) ChatEndpoint.create(chat: chat) ChatEventHandler.creatingChat() case .created(let chat): ChatEventHandler.created(chat: chat) case .createChatFailed(let reason): ChatEventHandler.failedCreatingChat(reason: reason) } } }

Il se passe assez peu de choses ici : la seule chose que nous faisons est de mettre à jour le modèle et de transmettre l'événement au ChatEventHandler afin que l'interface utilisateur soit mise à jour.

Tutoriel Swift MVVM : contrôleur de modèle

C'est exactement la même classe que celle que nous utilisons dans MVC, car elle fonctionnait déjà assez bien. Il représente l'état de l'application et est généralement sauvegardé par Core Data ou une bibliothèque de stockage locale.

Les couches de modèle, si elles sont correctement implémentées dans MVC, nécessitent très rarement une refactorisation pour s'adapter à différents modèles. Le changement le plus important est que la modification du modèle se produit à partir de moins de classes, ce qui rend un peu plus clair où les changements se produisent.

Dans une autre approche de ce modèle, vous pouvez observer les modifications apportées au modèle et vous assurer qu'elles sont gérées. Dans ce cas, j'ai choisi de ne laisser que les *EventRouter et *Endpoint changer le modèle, il y a donc une responsabilité claire de l'endroit et du moment où le modèle est mis à jour. En revanche, si nous observions des changements, nous devions écrire du code supplémentaire pour propager des événements ne modifiant pas le modèle, tels que des erreurs, via ChatEventHandler , ce qui rendrait moins évident le flux des événements dans l'application.

Tutoriel Swift MVVM : Gestionnaire d'événements

Le gestionnaire d'événements est l'endroit où les vues ou les contrôleurs de vue peuvent s'enregistrer (et se désenregistrer) en tant qu'écouteurs pour recevoir des modèles de vue mis à jour, qui sont créés chaque fois que le ChatEventRouter appelle une fonction sur le ChatEventHandler .

Vous pouvez voir qu'il reflète à peu près tous les états d'affichage que nous utilisions auparavant dans MVC. Si vous souhaitez d'autres types de mises à jour de l'interface utilisateur, telles que le son ou le déclenchement du moteur Taptic, elles peuvent également être effectuées à partir d'ici.

 protocol ChatListListening: class { func updated(list: ChatListViewModel) } protocol CreateChatListening: class { func updated(create: CreateChatViewModel) } class ChatEventHandler { private static var chatListListening: [ChatListListening?] = [] private static var createChatListening: [CreateChatListening?] = [] class func add(listener: ChatListListening) { weak var weakListener = listener chatListListening.append(weakListener) } class func remove(listener: ChatListListening) { chatListListening = chatListListening.filter { $0 !== listener } } class func add(listener: CreateChatListening) { weak var weakListener = listener createChatListening.append(weakListener) listener.updated(create: CreateChatViewModelBuilder.build(isSending: false, error: nil)) } class func remove(listener: CreateChatListening) { createChatListening = createChatListening.filter { $0 !== listener } } class func started() { ChatEndpoint.fetchChats() let loadingViewModel = ChatListViewModelBuilder.buildLoading() chatListListening.forEach { $0?.updated(list: loadingViewModel) } } class func loaded(chats: [Chat]) { let chatList = ChatListViewModelBuilder.build(for: chats) chatListListening.forEach { $0?.updated(list: chatList) } } class func creatingChat() { let createChat = CreateChatViewModelBuilder.build(isSending: true, error: nil) createChatListening.forEach { $0?.updated(create: createChat) } } class func failedCreatingChat(reason: String) { let createChat = CreateChatViewModelBuilder.build(isSending: false, error: reason) createChatListening.forEach { $0?.updated(create: createChat) } } class func created(chat: Chat) { let createChat = CreateChatViewModelBuilder.build(isSending: false, error: nil) createChatListening.forEach { $0?.updated(create: createChat) } updateAllChatLists() let chatViewController = ChatViewController(for: chat) BaseNavigationViewController.pushViewController(chatViewController, animated: true, removePreviousFromStack: true) } class func updateAllChatLists() { let chatListViewModel = ChatListViewModelBuilder.build(for: ChatModel.allChats()) chatListListening.forEach { $0?.updated(list: chatListViewModel) } } }

Cette classe ne fait rien de plus que de s'assurer que le bon écouteur peut obtenir le bon modèle de vue chaque fois qu'un certain événement s'est produit. Les nouveaux écouteurs peuvent obtenir un modèle de vue immédiatement lorsqu'ils sont ajoutés si cela est nécessaire pour configurer leur état initial. Assurez-vous toujours d'ajouter une référence weak à la liste pour éviter les cycles de rétention.

Tutoriel Swift MVVM : Afficher le modèle

Voici l'une des plus grandes différences entre ce que font beaucoup de modèles MVVM et ce que fait la variante statique. Dans ce cas, le modèle de vue est immuable au lieu de se définir comme un intermédiaire permanent à double sens entre le modèle et la vue. Pourquoi ferions nous cela? Arrêtons-nous un instant pour l'expliquer.

L'un des aspects les plus importants de la création d'une application qui fonctionne bien dans tous les cas possibles est de s'assurer que l'état de l'application est correct. Si l'interface utilisateur ne correspond pas au modèle ou contient des données obsolètes, tout ce que nous faisons peut entraîner l'enregistrement de données erronées ou le blocage de l'application ou son comportement inattendu.

L'un des objectifs de l'application de ce modèle est que nous n'avons pas d'état dans l'application à moins que cela ne soit absolument nécessaire. Qu'est-ce que l'état, exactement ? L'état est essentiellement chaque endroit où nous stockons une représentation d'un type particulier de données. Un type spécial d'état est l'état dans lequel se trouve actuellement votre interface utilisateur, ce que nous ne pouvons bien sûr pas empêcher avec une application pilotée par l'interface utilisateur. Les autres types d'états sont tous liés aux données. Si nous avons une copie d'un tableau de Chat s sauvegardant notre UITableView dans l'écran Chat List, c'est un exemple d'état en double. Un modèle de vue bidirectionnel traditionnel serait un autre exemple de doublon des Chat s de nos utilisateurs.

En transmettant un modèle de vue immuable qui est actualisé à chaque changement de modèle, nous éliminons ce type d'état dupliqué, car après s'être appliqué à l'interface utilisateur, il n'est plus utilisé. Ensuite, nous n'avons que les deux seuls types d'état que nous ne pouvons pas éviter - l'interface utilisateur et le modèle - et ils sont parfaitement synchronisés l'un avec l'autre.

Ainsi, le modèle de vue ici est assez différent de certaines applications MVVM. Il sert uniquement de magasin de données immuable pour tous les indicateurs, valeurs, blocs et autres valeurs dont la vue a besoin pour refléter l'état du modèle, mais il ne peut en aucun cas être mis à jour par la vue.

Par conséquent, il peut s'agir d'une simple struct immuable. Pour garder cette struct aussi simple que possible, nous allons l'instancier avec un générateur de modèle de vue. L'une des choses intéressantes à propos d'un modèle de vue est qu'il obtient des indicateurs de comportement comme shouldShowBusy et shouldShowError qui remplacent le mécanisme d' enum d'état précédemment trouvé dans la vue. Voici les données de ChatItemTableViewCell que nous avions analysées auparavant :

 struct ChatListItemViewModel { let contact: String let message: String let lastMessageDate: String let unreadMessageCount: Int let itemTapped: () -> Void }

Étant donné que le générateur de modèle de vue s'occupe déjà des valeurs exactes et des actions dont la vue a besoin, toutes les données sont préformatées. Une autre nouveauté est un bloc qui sera déclenché une fois qu'un élément est tapé. Voyons comment il est créé par le générateur de modèle de vue.

Afficher le générateur de modèles

Le générateur de modèles de vue peut créer des instances de modèles de vue, transformant des entrées telles que Chat s ou Message s en modèles de vue parfaitement adaptés à une certaine vue. L'une des choses les plus importantes qui se produisent dans le générateur de modèle de vue est de déterminer ce qui se passe réellement à l'intérieur des blocs du modèle de vue. Les blocs attachés par le générateur de modèle de vue doivent être extrêmement courts, appelant les fonctions d'autres parties de l'architecture dès que possible. Ces blocs ne doivent pas avoir de logique métier.

 class ChatListItemViewModelBuilder { class func build(for chat: Chat) -> ChatListItemViewModel { let lastMessageText = chat.messages.last?.message ?? "" let lastMessageDate = (chat.messages.last?.sendDate).map { DateRenderer.string(from: $0) } ?? "" let unreadMessageCount = ChatModel.unreadMessages(for: chat.contact).count return ChatListItemViewModel(contact: chat.contact, message: lastMessageText, lastMessageDate: lastMessageDate, unreadMessageCount: unreadMessageCount, itemTapped: { show(chat: chat) }) } private class func show(chat: Chat) { let chatViewController = ChatViewController(for: chat) BaseNavigationViewController.pushViewController(chatViewController, animated: true) } }

Maintenant, tout le préformatage se produit au même endroit et le comportement est également décidé ici. C'est une classe assez importante dans cette hiérarchie et il peut être intéressant de voir comment les différents constructeurs de l'application de démonstration ont été implémentés et traitent des scénarios plus compliqués.

Tutoriel Swift MVVM : Afficher le contrôleur

Le contrôleur de vue dans cette architecture fait très peu. Il installera et démolira tout ce qui concerne sa vue. Il est préférable de le faire car il obtient tous les rappels de cycle de vie nécessaires pour ajouter et supprimer des écouteurs au bon moment.

Parfois, il doit mettre à jour un élément de l'interface utilisateur qui n'est pas couvert par la vue racine, comme le titre ou un bouton dans la barre de navigation. C'est pourquoi j'enregistre généralement toujours le contrôleur de vue en tant qu'écouteur du routeur d'événements si j'ai un modèle de vue qui couvre l'ensemble de la vue pour le contrôleur de vue donné ; Je transmets ensuite le modèle de vue à la vue. Mais il est également possible d'enregistrer directement n'importe quel UIView en tant qu'auditeur s'il y a une partie de l'écran qui a un taux de mise à jour différent, par exemple un téléscripteur en direct en haut d'une page sur une certaine entreprise.

Le code du ChatsViewController est maintenant si court qu'il prend moins d'une page. Ce qui reste est de remplacer la vue de base, d'ajouter et de supprimer le bouton d'ajout de la barre de navigation, de définir le titre, de s'ajouter en tant qu'écouteur et d'implémenter le protocole ChatListListening :

 class ChatsViewController: UIViewController { private lazy var customView: ChatsView = { let customView = ChatsView() return customView }() private var addButton: UIBarButtonItem = UIBarButtonItem(barButtonSystemItem: .add, target: nil, action: nil) override func loadView() { view = customView } override func viewDidLoad() { super.viewDidLoad() ChatEventHandler.add(listener: self) ChatEventRouter.route(event: .started) title = "Chats" } override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) navigationItem.rightBarButtonItem = addButton } override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) navigationItem.rightBarButtonItem = nil } } extension ChatsViewController: ChatListListening { func updated(list: ChatListViewModel) { addButton.action(block: { _ in list.addChat() }) customView.display(viewModel: list) } }

Il ne reste plus rien à faire ailleurs, car le ChatsViewController est réduit à son strict minimum.

Tutoriel Swift MVVM : Afficher

La vue dans l'architecture MVVM immuable peut encore être assez lourde, car elle a toujours une liste de tâches, mais j'ai réussi à la dépouiller des responsabilités suivantes par rapport à l'architecture MVC :

  • Déterminer ce qui doit changer en réponse à un nouvel état
  • Implémentation des délégués et des fonctions pour les actions
  • Gérer les déclencheurs de vue à vue comme les gestes et les animations déclenchées
  • Transformer les données de manière à ce qu'elles puissent être affichées (comme Date s to String s)

Surtout le dernier point a un avantage assez important. Dans MVC, lorsque la vue ou le contrôleur de vue est responsable de la transformation des données pour l'affichage, il le fera toujours sur le thread principal car il est très difficile de séparer les véritables modifications de l'interface utilisateur qui doivent se produire sur ce thread des choses qui sont pas obligé de courir dessus. Et l'exécution d'un code non modifié par l'interface utilisateur sur le thread principal peut entraîner une application moins réactive.

Au lieu de cela, avec ce modèle MVVM, tout depuis le bloc qui est déclenché par un tap jusqu'au moment où le modèle de vue est construit et sera transmis à l'auditeur - nous pouvons exécuter tout cela sur un thread séparé et ne plonger que dans le thread principal dans le end pour effectuer les mises à jour de l'interface utilisateur. Si notre application passe moins de temps sur le thread principal, elle fonctionnera plus facilement.

Une fois que le modèle de vue applique le nouvel état à la vue, il est autorisé à s'évaporer au lieu de s'attarder comme une autre couche d'état. Tout ce qui pourrait déclencher un événement est attaché à un élément de la vue et nous ne communiquerons pas avec le modèle de vue.

Une chose est importante à retenir : vous n'êtes pas obligé de mapper un modèle de vue via un contrôleur de vue vers une vue. Comme mentionné précédemment, des parties de la vue peuvent être gérées par d'autres modèles de vue, en particulier lorsque les taux de mise à jour varient. Imaginez qu'une feuille de calcul Google soit modifiée par différentes personnes tout en gardant un volet de chat ouvert pour les collaborateurs. Il n'est pas très utile d'actualiser le document chaque fois qu'un message de chat arrive.

Un exemple bien connu est une implémentation type-to-find où la zone de recherche est mise à jour avec des résultats plus précis à mesure que nous saisissons plus de texte. Voici comment j'implémenterais la saisie semi-automatique dans la classe CreateAutocompleteView : tout l'écran est servi par le CreateViewModel mais la zone de texte écoute le AutocompleteContactViewModel à la place.

Un autre exemple est l'utilisation d'un validateur de formulaire, qui peut soit être construit comme une "boucle locale" (attacher ou supprimer des états d'erreur aux champs et déclarer un formulaire valide) ou être réalisé en déclenchant un événement.

Les modèles de vue statiques immuables offrent une meilleure séparation

En utilisant une implémentation MVVM statique, nous avons finalement réussi à séparer complètement toutes les couches car le modèle de vue fait désormais le pont entre le modèle et la vue. Nous avons également simplifié la gestion des événements qui n'étaient pas causés par l'action de l'utilisateur et supprimé de nombreuses dépendances entre les différentes parties de notre application. La seule chose qu'un contrôleur de vue fait est de s'enregistrer (et de se désenregistrer) auprès des gestionnaires d'événements en tant qu'auditeur pour les événements qu'il souhaite recevoir.

Avantages:

  • Les implémentations de vue et de contrôleur de vue ont tendance à être beaucoup plus légères
  • Les classes sont plus spécialisées et séparées
  • Les événements peuvent être déclenchés facilement depuis n'importe quel endroit
  • Les événements suivent un chemin prévisible à travers le système
  • L'état n'est mis à jour qu'à partir d'un seul endroit
  • App can be more performant as it's easier to do work off the main thread
  • Views receive tailor-made view models and are perfectly separated from the models

Downsides:

  • A full view model is created and sent every time the UI needs to update, often overwriting the same button text with the same button text, and replacing blocks with blocks that do exactly the same
  • Requires some helper extensions to make button taps and other UI events work well with the blocks in the view model
  • Event enum s can easily grow pretty large in complex scenarios and might be hard to split up

The great thing is that this is a pure Swift pattern: It does not require a third-party Swift MVVM framework, nor does it exclude the use of classic MVC, so you can easily add new features or refactor problematic parts of your application today without being forced to rewrite your whole application.

There are other approaches to combat large view controllers that provide better separation as well. I couldn't include them all in full detail to compare them, but let's take a brief look at some of the alternatives:

  • Some form of the MVVM pattern
  • Some form of Reactive (using RxSwift, sometimes combined with MVVM)
  • The model-view-presenter pattern (MVP)
  • The view-interactor-presenter-entity-router pattern (VIPER)

Traditional MVVM replaces most of the view controller code with a view model that is just a regular class and can be tested more easily in isolation. Since it needs to be a bi-directional bridge between the view and the model it often implements some form of Observables. That's why you often see it used together with a framework like RxSwift.

MVP and VIPER deal with extra abstraction layers between the model and the view in a more traditional way, while Reactive really remodels the way data and events flow through your application.

The Reactive style of programming is gaining a lot of popularity lately and actually is pretty close to the static MVVM approach with events, as explained in this article. The major difference is that it usually requires a framework, and a lot of your code is specifically geared towards that framework.

MVP is a pattern where both the view controller and the view are considered to be the view layer. The presenter transforms the model and passes it to the view layer, while I transform the data into a view model first. Since the view can be abstracted to a protocol, it's much easier to test.

VIPER takes the presenter from MVP, adds a separate “interactor” for business logic, calls the model layer “entity,” and has a router for navigation purposes (and to complete the acronym). It can be considered a more detailed and decoupled form of MVP.


So there you have it: static event-driven MVVM explained. I look forward to hearing from you in the comments below!

Related: Swift Tutorial: An Introduction to the MVVM Design Pattern