Arbeiten mit statischen Mustern: Ein schnelles MVVM-Tutorial

Veröffentlicht: 2022-03-11

Heute werden wir sehen, wie neue technische Möglichkeiten und Erwartungen unserer Nutzer an echtzeitdatengesteuerte Anwendungen neue Herausforderungen bei der Strukturierung unserer Programme, insbesondere unserer mobilen Anwendungen, schaffen. Während es in diesem Artikel um iOS und Swift geht, sind viele der Muster und Schlussfolgerungen gleichermaßen auf Android- und Webanwendungen anwendbar.

In den letzten Jahren hat es eine wichtige Entwicklung in der Funktionsweise moderner mobiler Apps gegeben. Dank des allgegenwärtigeren Internetzugangs und Technologien wie Push-Benachrichtigungen und WebSockets ist der Benutzer in vielen der heutigen mobilen Apps normalerweise nicht mehr die einzige Quelle von Laufzeitereignissen – und nicht mehr unbedingt die wichtigste.

Schauen wir uns einmal genauer an, wie gut jeweils zwei Swift-Entwurfsmuster mit einer modernen Chat-Anwendung funktionieren: das klassische Model-View-Controller (MVC)-Muster und ein vereinfachtes unveränderliches Model-View-Viewmodel-Muster (MVVM, manchmal stilisiert „das ViewModel-Muster“) “). Chat-Apps sind ein gutes Beispiel, da sie viele Datenquellen haben und ihre Benutzeroberflächen bei jedem Datenempfang auf viele verschiedene Arten aktualisieren müssen.

Unsere Chat-Anwendung

Die Anwendung, die wir in diesem Swift MVVM-Tutorial als Richtlinie verwenden werden, verfügt über die meisten grundlegenden Funktionen, die wir von Chat-Anwendungen wie WhatsApp kennen. Lassen Sie uns die Funktionen durchgehen, die wir implementieren, und MVVM mit MVC vergleichen. Die Anwendung:

  • Lädt die zuvor empfangenen Chats von der Festplatte
  • Synchronisiert die bestehenden Chats über einen GET -Request mit dem Server
  • Erhält Push-Benachrichtigungen, wenn eine neue Nachricht an den Benutzer gesendet wird
  • Wird mit einem WebSocket verbunden, sobald wir uns in einem Chat-Bildschirm befinden
  • Kann eine neue Nachricht an einen Chat POST
  • Zeigt eine In-App-Benachrichtigung an, wenn eine neue Nachricht von einem Chat empfangen wird, in dem wir uns derzeit nicht befinden
  • Zeigt sofort eine neue Nachricht an, wenn wir eine neue Nachricht für den aktuellen Chat erhalten
  • Sendet eine gelesene Nachricht, wenn wir eine ungelesene Nachricht lesen
  • Wird eine gelesene Nachricht erhalten, wenn jemand unsere Nachricht liest
  • Aktualisiert das Zähler-Badge für ungelesene Nachrichten auf dem Anwendungssymbol
  • Synchronisiert alle empfangenen oder geänderten Nachrichten zurück zu Core Data

In dieser Demoanwendung wird es keine echte API-, WebSocket- oder Core Data-Implementierung geben, um die Modellimplementierung etwas einfacher zu halten. Stattdessen habe ich einen Chatbot hinzugefügt, der Ihnen antwortet, sobald Sie ein Gespräch beginnen. Alle anderen Routings und Aufrufe werden jedoch so implementiert, wie sie es wären, wenn die Speicherung und die Verbindungen real wären, einschließlich kleiner asynchroner Pausen vor der Rückkehr.

Die folgenden drei Bildschirme wurden erstellt:

Bildschirme Chat-Liste, Chat erstellen und Nachrichten.

Klassisches MVC

Zunächst einmal gibt es das Standard-MVC-Muster zum Erstellen einer iOS-Anwendung. Auf diese Weise strukturiert Apple seinen gesamten Dokumentationscode und die erwartete Funktionsweise von APIs und UI-Elementen. Das bekommen die meisten Leute beigebracht, wenn sie einen iOS-Kurs belegen.

Oft wird MVC dafür verantwortlich gemacht, dass es zu aufgeblähten UIViewController s mit einigen tausend Codezeilen führt. Aber wenn es gut angewendet wird, mit einer guten Trennung zwischen den einzelnen Schichten, können wir ziemlich schlanke ViewController s haben, die nur wie Zwischenmanager zwischen den View , Model und anderen Controller fungieren.

Hier ist das Flussdiagramm für die MVC-Implementierung der App (wobei der CreateViewController aus Gründen der Übersichtlichkeit weggelassen wird):

Flussdiagramm der MVC-Implementierung, wobei der CreateViewController aus Gründen der Übersichtlichkeit weggelassen wurde.

Gehen wir die Ebenen im Detail durch.

Modell

Die Modellebene ist normalerweise die am wenigsten problematische Ebene in MVC. In diesem Fall habe ich mich für ChatWebSocket , ChatModel und PushNotificationController entschieden, um zwischen den Chat und Message -Objekten, den externen Datenquellen und dem Rest der Anwendung zu vermitteln. ChatModel ist die Quelle der Wahrheit innerhalb der Anwendung und funktioniert in dieser Demoanwendung nur im Arbeitsspeicher. In einer realen Anwendung würde es wahrscheinlich von Core Data unterstützt. Schließlich verarbeitet ChatEndpoint alle HTTP-Aufrufe.

Sicht

Die Ansichten sind ziemlich groß, da sie viele Verantwortlichkeiten bewältigen müssen, da ich den gesamten Ansichtscode sorgfältig von den UIViewController s getrennt habe. Ich habe Folgendes getan:

  • Verwenden Sie das (sehr empfehlenswerte) State- enum -Muster, um zu definieren, in welchem ​​​​Zustand sich die Ansicht gerade befindet.
  • Funktionen hinzugefügt, die mit den Schaltflächen und anderen aktionsauslösenden Elementen der Benutzeroberfläche verbunden werden (z. B. Tippen auf Zurück während der Eingabe eines Kontaktnamens).
  • Richten Sie die Einschränkungen ein und rufen Sie den Delegierten jedes Mal zurück.

Sobald Sie eine UITableView in den Mix werfen, sind die Ansichten jetzt viel größer als die UIViewController s, was zu besorgniserregenden über 300 Codezeilen und vielen gemischten Aufgaben in der ChatView .

Regler

Da die gesamte Modellbehandlungslogik nach ChatModel . Der gesamte Ansichtscode – der hier in weniger optimalen, getrennten Projekten lauern könnte – lebt jetzt in der Ansicht, sodass die UIViewController s ziemlich schlank sind. Der Ansichtscontroller ist sich völlig unbewusst darüber, wie die Modelldaten aussehen, wie sie abgerufen werden oder wie sie angezeigt werden sollen – er koordiniert nur. Im Beispielprojekt geht keiner der UIViewController s über 150 Codezeilen hinaus.

Der ViewController macht jedoch immer noch die folgenden Dinge:

  • Stellvertreter für die Ansicht und andere Ansichtscontroller sein
  • View-Controller bei Bedarf instanziieren und pushen (oder poppen).
  • Senden und Empfangen von Anrufen zum und vom ChatModel
  • Starten und Stoppen des WebSocket abhängig von der Phase des View-Controller-Zyklus
  • Treffen Sie logische Entscheidungen, wie z. B. das Nichtsenden einer Nachricht, wenn sie leer ist
  • Aktualisierung der Ansicht

Das ist immer noch viel, aber es geht hauptsächlich um die Koordination, die Verarbeitung von Callback-Blöcken und die Weiterleitung.

Leistungen

  • Dieses Muster wird von allen verstanden und von Apple gefördert
  • Funktioniert mit allen Unterlagen
  • Keine zusätzlichen Frameworks erforderlich

Nachteile

  • View-Controller haben viele Aufgaben; Viele von ihnen tauschen im Grunde genommen Daten zwischen der Ansicht und der Modellebene aus
  • Nicht sehr geeignet, um mehrere Ereignisquellen zu verarbeiten
  • Klassen wissen in der Regel viel über andere Klassen

Problem Definition

Dies funktioniert sehr gut, solange die Anwendung den Aktionen des Benutzers folgt und darauf reagiert, wie Sie es sich bei einer Anwendung wie Adobe Photoshop oder Microsoft Word vorstellen würden. Der Benutzer führt eine Aktion aus, die Benutzeroberfläche wird aktualisiert, wiederholen.

Moderne Anwendungen sind jedoch oft auf mehr als eine Weise miteinander verbunden. Beispielsweise interagieren Sie über eine REST-API, erhalten Push-Benachrichtigungen und stellen in einigen Fällen auch eine Verbindung zu einem WebSocket her.

Damit muss der View-Controller plötzlich mit mehr Informationsquellen umgehen, und immer wenn eine externe Nachricht empfangen wird, ohne dass der Benutzer sie auslöst – wie beim Empfang einer Nachricht über den WebSocket – müssen die Informationsquellen ihren Weg zurück nach rechts finden Controller anzeigen. Dies erfordert eine Menge Code, nur um alle Teile zusammenzukleben, um im Grunde dieselbe Aufgabe auszuführen.

Externe Datenquellen

Schauen wir uns an, was passiert, wenn wir eine Push-Nachricht erhalten:

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

Wir müssen den Stapel der View-Controller manuell durchsuchen, um herauszufinden, ob es einen View-Controller gibt, der sich selbst aktualisieren muss, nachdem wir eine Push-Benachrichtigung erhalten haben. In diesem Fall möchten wir auch die Bildschirme aktualisieren, die den UpdatedChatDelegate implementieren, der in diesem Fall nur der ChatsViewController ist. Wir tun dies auch, um zu wissen, ob wir die Benachrichtigung unterdrücken sollten, weil wir uns bereits den Chat ansehen, für den sie bestimmt war. In diesem Fall liefern wir die Nachricht stattdessen schließlich an den View-Controller. Es ist ziemlich klar, dass PushNotificationController viel zu viel über die Anwendung wissen muss, um seine Arbeit erledigen zu können.

Wenn der ChatWebSocket auch Nachrichten an andere Teile der Anwendung liefern würde, anstatt eine Eins-zu-Eins-Beziehung zum ChatViewController zu haben, würden wir dort auf das gleiche Problem stoßen.

Es ist klar, dass wir jedes Mal ziemlich invasiven Code schreiben müssen, wenn wir eine weitere externe Quelle hinzufügen. Dieser Code ist auch ziemlich spröde, da er sich stark auf die Anwendungsstruktur und die Übergabe von Daten durch Delegierte an die Hierarchie stützt, um zu funktionieren.

Delegierte

Das MVC-Muster fügt dem Mix auch zusätzliche Komplexität hinzu, sobald wir andere View-Controller hinzufügen. Dies liegt daran, dass Ansichtscontroller beim Übergeben von Daten und Referenzen in der Regel durch Delegaten, Initialisierer und – im Fall von Storyboards prepareForSegue . Jeder Ansichtscontroller handhabt seine eigenen Verbindungen zu den Modell- oder vermittelnden Controllern, und sie senden und empfangen Aktualisierungen.

Außerdem kommunizieren die Ansichten über Delegaten mit den Ansichtscontrollern. Dies funktioniert zwar, bedeutet aber, dass wir ziemlich viele Schritte unternehmen müssen, um die Daten herumzureichen, und ich finde mich immer wieder dabei, wie ich viel um Callbacks herum umgestalte und prüfe, ob Delegaten wirklich festgelegt sind.

Es ist möglich, einen View-Controller zu unterbrechen, indem der Code in einem anderen geändert wird, z. B. veraltete Daten im ChatsListViewController , da der ChatViewController nicht mehr updated(chat: Chat) aufruft. Besonders in komplexeren Szenarien ist es mühsam, alles synchron zu halten.

Trennung zwischen Ansicht und Modell

Durch das Entfernen des gesamten ansichtsbezogenen Codes aus dem Ansichtscontroller in customView s und das Verschieben des gesamten modellbezogenen Codes in spezialisierte Controller ist der Ansichtscontroller ziemlich schlank und getrennt. Es bleibt jedoch noch ein Problem: Es gibt eine Lücke zwischen dem, was die Ansicht anzeigen möchte, und den Daten, die sich im Modell befinden. Ein gutes Beispiel ist die ChatListView . Was wir anzeigen möchten, ist eine Liste von Zellen, die uns sagen, mit wem wir sprechen, was die letzte Nachricht war, das Datum der letzten Nachricht und wie viele ungelesene Nachrichten im Chat verblieben sind:

Zähler für ungelesene Nachrichten im Chat-Bildschirm.

Wir passieren jedoch ein Modell, das nicht weiß, was wir sehen wollen. Stattdessen ist es nur ein Chat mit einem Kontakt, der Nachrichten enthält:

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

Jetzt ist es möglich, schnell zusätzlichen Code hinzuzufügen, der uns die letzte Nachricht und die Anzahl der Nachrichten liefert, aber das Formatieren von Datumsangaben in Zeichenfolgen ist eine Aufgabe, die fest zur Ansichtsebene gehört:

 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 }

Schließlich formatieren wir das Datum in der ChatItemTableViewCell , wenn wir es anzeigen:

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

Selbst in einem ziemlich einfachen Beispiel ist es ziemlich klar, dass es eine Spannung gibt zwischen dem, was die Ansicht benötigt, und dem, was das Modell bereitstellt.

Static Event-driven MVVM, auch bekannt als Static Event-driven Take on „the ViewModel Pattern“

Statisches MVVM arbeitet mit Ansichtsmodellen, aber anstatt bidirektionalen Datenverkehr durch sie zu erstellen – ähnlich wie früher durch unseren Ansichtscontroller mit MVC – erstellen wir unveränderliche Ansichtsmodelle, die die Benutzeroberfläche jedes Mal aktualisieren, wenn sich die Benutzeroberfläche als Reaktion auf ein Ereignis ändern muss .

Ein Ereignis kann von fast jedem Teil des Codes ausgelöst werden, solange er die zugehörigen Daten bereitstellen kann, die für das Ereignis enum erforderlich sind. Beispielsweise kann der Empfang des Ereignisses „ received(new: Message) “ durch eine Push-Benachrichtigung, den WebSocket oder einen normalen Netzwerkaufruf ausgelöst werden.

Sehen wir es uns in einem Diagramm an:

Flussdiagramm der MVVM-Implementierung.

Auf den ersten Blick scheint es etwas komplexer zu sein als das klassische MVC-Beispiel, da viel mehr Klassen beteiligt sind, um genau dasselbe zu erreichen. Aber bei näherer Betrachtung ist keine der Beziehungen mehr bidirektional.

Noch wichtiger ist, dass jedes Update der Benutzeroberfläche durch ein Ereignis ausgelöst wird, sodass es für alles, was passiert, nur einen Weg durch die App gibt. Es ist sofort klar, welche Events Sie erwarten können. Es ist auch klar, wo Sie bei Bedarf ein neues hinzufügen oder ein neues Verhalten hinzufügen sollten, wenn Sie auf vorhandene Ereignisse reagieren.

Nach dem Refactoring hatte ich viele neue Klassen, wie ich oben gezeigt habe. Meine Implementierung der statischen MVVM-Version finden Sie auf GitHub. Wenn ich jedoch die Änderungen mit dem cloc Tool vergleiche, wird deutlich, dass es eigentlich gar nicht so viel zusätzlichen Code gibt:

Muster Dateien Leer Kommentar Code
MVC 30 386 217 1807
MVVM 51 442 359 1981

Es gibt nur eine 9-prozentige Zunahme der Codezeilen. Noch wichtiger ist, dass die durchschnittliche Größe dieser Dateien von 60 Codezeilen auf nur 39 gesunken ist.

Codezeilen-Kreisdiagramme. Controller anzeigen: MVC 287 vs. MVVM 154 oder 47 % weniger; Aufrufe: MVC 523 vs. MVVM 392 oder 26 % weniger.

Entscheidend ist auch, dass die größten Verluste in den Dateien zu finden sind, die in MVC normalerweise die größten sind: die Ansichten und Ansichtscontroller. Die Ansichten haben nur noch 74 Prozent ihrer ursprünglichen Größe und die Ansichts-Controller haben jetzt nur noch 53 Prozent ihrer ursprünglichen Größe.

Es sollte auch beachtet werden, dass ein Großteil des zusätzlichen Codes Bibliothekscode ist, der dabei hilft, Blöcke an Schaltflächen und andere Objekte in der visuellen Struktur anzuhängen, ohne dass die klassische @IBAction von MVC oder Delegate-Muster erforderlich sind.

Lassen Sie uns die verschiedenen Ebenen dieses Designs nacheinander untersuchen.

Fall

Das Ereignis ist immer eine enum , normalerweise mit zugehörigen Werten. Oft überschneiden sie sich mit einer der Entitäten in Ihrem Modell, aber nicht unbedingt. In diesem Fall wird die Anwendung in zwei Hauptereignisaufzählungen ChatEvent enum MessageEvent . ChatEvent ist für alle Updates zu den Chat-Objekten selbst:

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

Der andere befasst sich mit allen nachrichtenbezogenen Ereignissen:

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

Es ist wichtig, Ihre *Event - enum auf eine angemessene Größe zu beschränken. Wenn Sie 10 oder mehr Fälle benötigen, ist dies normalerweise ein Zeichen dafür, dass Sie versuchen, mehr als ein Thema abzudecken.

Hinweis: Das enum Konzept ist in Swift extrem leistungsfähig. Ich neige dazu, enum mit zugehörigen Werten häufig zu verwenden, da sie viel Mehrdeutigkeit beseitigen können, die Sie sonst mit optionalen Werten hätten.

Swift MVVM-Tutorial: Ereignisrouter

Der Ereignisrouter ist der Einstiegspunkt für alle Ereignisse, die in der Anwendung auftreten. Jede Klasse, die den zugeordneten Wert bereitstellen kann, kann ein Ereignis erstellen und an den Ereignisrouter senden. Sie können also von jeder Art von Quelle ausgelöst werden, z.

  • Der Benutzer wechselt zu einem bestimmten Ansichtscontroller
  • Der Benutzer tippt auf eine bestimmte Schaltfläche
  • Die Anwendung startet
  • Externe Veranstaltungen wie:
    • Eine Netzwerkanforderung, die mit einem Fehler oder neuen Daten zurückkehrt
    • Mitteilungen
    • WebSocket-Meldungen

Der Event-Router sollte so wenig wie möglich über die Quelle des Events wissen und am besten gar nichts. Keines der Ereignisse in dieser Beispielanwendung hat einen Hinweis darauf, woher sie kommen, daher ist es sehr einfach, jede Art von Nachrichtenquelle einzumischen. Zum Beispiel löst der WebSocket das gleiche Ereignis – Received received(message: Message, contact: String) – als neue Push-Benachrichtigung aus.

Ereignisse werden (Sie haben es bereits erraten) an die Klassen weitergeleitet, die diese Ereignisse weiterverarbeiten müssen. Normalerweise sind die einzigen Klassen, die aufgerufen werden, die Modellschicht (wenn Daten hinzugefügt, geändert oder entfernt werden müssen) und der Event-Handler. Ich werde beide etwas weiter unten besprechen, aber das Hauptmerkmal des Ereignisrouters besteht darin, einen einfachen Zugangspunkt zu allen Ereignissen zu bieten und die Arbeit an andere Klassen weiterzuleiten. Hier ist der ChatEventRouter als Beispiel:

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

Hier ist ziemlich wenig los: Wir aktualisieren lediglich das Modell und leiten das Ereignis an den ChatEventHandler , damit die Benutzeroberfläche aktualisiert wird.

Swift MVVM-Tutorial: Modellcontroller

Dies ist genau die gleiche Klasse, die wir in MVC verwenden, da sie bereits ziemlich gut funktioniert hat. Es stellt den Status der Anwendung dar und wird normalerweise von Core Data oder einer lokalen Speicherbibliothek unterstützt.

Modellschichten müssen – wenn sie korrekt in MVC implementiert sind – sehr selten umgestaltet werden, um sie an unterschiedliche Muster anzupassen. Die größte Änderung besteht darin, dass die Änderung des Modells von weniger Klassen aus erfolgt, wodurch etwas klarer wird, wo Änderungen vorgenommen werden.

Als Alternative zu diesem Muster könnten Sie Änderungen am Modell beobachten und sicherstellen, dass sie gehandhabt werden. In diesem Fall habe ich mich entschieden, einfach nur die *EventRouter und *Endpoint -Klassen das Modell ändern zu lassen, sodass eine klare Verantwortung dafür besteht, wo und wann das Modell aktualisiert wird. Wenn wir dagegen Änderungen beobachten würden, müssten wir zusätzlichen Code schreiben, um nicht modellverändernde Ereignisse wie Fehler über den ChatEventHandler , wodurch weniger offensichtlich wäre, wie die Ereignisse durch die Anwendung fließen.

Swift MVVM-Tutorial: Ereignishandler

Der Ereignishandler ist der Ort, an dem sich die Ansichten oder Ansichtscontroller als Listener registrieren (und deregistrieren) können, um aktualisierte Ansichtsmodelle zu erhalten, die erstellt werden, wenn der ChatEventRouter eine Funktion für den ChatEventHandler .

Sie können sehen, dass es ungefähr alle Ansichtszustände widerspiegelt, die wir zuvor in MVC verwendet haben. Wenn Sie andere Arten von UI-Updates wünschen – wie Sound oder das Auslösen der Taptic-Engine – können Sie dies auch von hier aus tun.

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

Diese Klasse tut nichts weiter, als sicherzustellen, dass der richtige Zuhörer das richtige Ansichtsmodell erhält, wenn ein bestimmtes Ereignis eingetreten ist. Neue Zuhörer können sofort nach dem Hinzufügen ein Ansichtsmodell erhalten, wenn dies zum Einrichten ihres Anfangszustands erforderlich ist. Stellen Sie immer sicher, dass Sie der Liste einen weak Verweis hinzufügen, um Aufbewahrungszyklen zu vermeiden.

Swift MVVM-Tutorial: Modell anzeigen

Hier ist einer der größten Unterschiede zwischen dem, was viele MVVM-Muster tun, und dem, was die statische Variante tut. In diesem Fall ist das Ansichtsmodell unveränderlich, anstatt sich selbst als permanentes, in beide Richtungen gebundenes Zwischenprodukt zwischen Modell und Ansicht einzurichten. Warum sollten wir das tun? Machen wir eine Pause, um es einen Moment zu erklären.

Einer der wichtigsten Aspekte beim Erstellen einer Anwendung, die in allen möglichen Fällen gut funktioniert, ist sicherzustellen, dass der Status der Anwendung korrekt ist. Wenn die Benutzeroberfläche nicht mit dem Modell übereinstimmt oder veraltete Daten enthält, kann alles, was wir tun, dazu führen, dass fehlerhafte Daten gespeichert werden oder die Anwendung abstürzt oder sich auf unerwartete Weise verhält.

Eines der Ziele bei der Anwendung dieses Musters ist, dass wir keinen Zustand in der Anwendung haben, es sei denn, es ist absolut notwendig. Was ist Staat genau? Staat ist im Grunde jeder Ort, an dem wir eine Darstellung einer bestimmten Art von Daten speichern. Eine besondere Art von Zustand ist der Zustand, in dem sich Ihre Benutzeroberfläche gerade befindet, was wir bei einer UI-gesteuerten Anwendung natürlich nicht verhindern können. Die anderen Zustandstypen sind alle datenbezogen. Wenn wir eine Kopie eines Arrays von Chat haben, die unsere UITableView im Chatlistenbildschirm sichern, ist dies ein Beispiel für einen Duplikatstatus. Ein traditionelles bidirektionales Ansichtsmodell wäre ein weiteres Beispiel für ein Duplikat der Chat unserer Benutzer.

Indem wir ein unveränderliches Ansichtsmodell übergeben, das bei jeder Modelländerung aktualisiert wird, eliminieren wir diese Art von doppeltem Status, da es nicht mehr verwendet wird, nachdem es sich auf die Benutzeroberfläche angewendet hat. Dann haben wir nur die beiden einzigen Zustandstypen, die wir nicht vermeiden können – UI und Modell – und sie sind perfekt aufeinander abgestimmt.

Das Ansichtsmodell hier unterscheidet sich also erheblich von einigen MVVM-Anwendungen. Es dient nur als unveränderlicher Datenspeicher für alle Flags, Werte, Blöcke und andere Werte, die die Ansicht benötigt, um den Zustand des Modells widerzuspiegeln, kann jedoch in keiner Weise von der Ansicht aktualisiert werden.

Daher kann es sich um eine einfache unveränderliche struct . Um diese struct so einfach wie möglich zu halten, werden wir sie mit einem View Model Builder instanziieren. Eines der interessanten Dinge an einem Ansichtsmodell ist, dass es Verhaltensflags wie shouldShowBusy und shouldShowError , die den zuvor in der Ansicht gefundenen Zustandsaufzählungsmechanismus enum . Hier sind die Daten für die ChatItemTableViewCell , die wir zuvor analysiert hatten:

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

Da sich der View Model Builder bereits um die genauen Werte und Aktionen kümmert, die die Ansicht benötigt, sind alle Daten vorformatiert. Ebenfalls neu ist ein Block, der ausgelöst wird, sobald ein Gegenstand angetippt wird. Mal sehen, wie es vom View Model Builder erstellt wird.

Modellgenerator anzeigen

Der Ansichtsmodell-Generator kann Instanzen von Ansichtsmodellen erstellen und Eingaben wie Chat oder Message in Ansichtsmodelle umwandeln, die perfekt auf eine bestimmte Ansicht zugeschnitten sind. Eines der wichtigsten Dinge, die im Ansichtsmodell-Generator passieren, ist zu bestimmen, was tatsächlich innerhalb der Blöcke im Ansichtsmodell passiert. Vom View Model Builder angefügte Blöcke sollten extrem kurz sein und so schnell wie möglich Funktionen anderer Teile der Architektur aufrufen. Solche Blöcke sollten keine Geschäftslogik haben.

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

Jetzt findet die gesamte Vorformatierung an der gleichen Stelle statt und das Verhalten wird auch hier entschieden. Es ist eine ziemlich wichtige Klasse in dieser Hierarchie, und es kann interessant sein zu sehen, wie die verschiedenen Builder in der Demoanwendung implementiert wurden und mit komplizierteren Szenarien umgehen.

Swift MVVM-Tutorial: Controller anzeigen

Der Ansichtscontroller in dieser Architektur tut sehr wenig. Es wird alles, was mit seiner Ansicht zu tun hat, auf- und abbauen. Dies ist am besten geeignet, da alle Lebenszyklus-Callbacks abgerufen werden, die zum Hinzufügen und Entfernen von Listenern zum richtigen Zeitpunkt erforderlich sind.

Manchmal muss ein UI-Element aktualisiert werden, das nicht von der Root-Ansicht abgedeckt wird, z. B. der Titel oder eine Schaltfläche in der Navigationsleiste. Aus diesem Grund registriere ich den Ansichtscontroller normalerweise immer noch als Listener für den Ereignisrouter, wenn ich ein Ansichtsmodell habe, das die gesamte Ansicht für den angegebenen Ansichtscontroller abdeckt. Ich leite das Ansichtsmodell anschließend an die Ansicht weiter. Aber es ist auch in Ordnung, jedes UIView direkt als Listener zu registrieren, wenn es einen Teil des Bildschirms gibt, der eine andere Aktualisierungsrate hat, zB ein Live-Börsenticker oben auf einer Seite über ein bestimmtes Unternehmen.

Der Code für den ChatsViewController ist jetzt so kurz, dass er weniger als eine Seite benötigt. Was übrig bleibt, ist das Überschreiben der Basisansicht, das Hinzufügen und Entfernen der Hinzufügen-Schaltfläche aus der Navigationsleiste, das Festlegen des Titels, das Hinzufügen als Listener und das Implementieren des ChatListListening Protokolls:

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

An anderer Stelle kann nichts mehr getan werden, da der ChatsViewController auf sein absolutes Minimum reduziert wird.

Swift MVVM-Tutorial: Ansicht

Die Ansicht in der unveränderlichen MVVM-Architektur kann immer noch ziemlich schwer sein, da sie immer noch eine Liste von Aufgaben hat, aber ich habe es geschafft, sie im Vergleich zur MVC-Architektur von den folgenden Verantwortlichkeiten zu befreien:

  • Bestimmen, was als Reaktion auf einen neuen Zustand geändert werden muss
  • Implementieren von Delegaten und Funktionen für Aktionen
  • Behandeln Sie View-to-View-Trigger wie Gesten und ausgelöste Animationen
  • Daten so transformieren, dass sie angezeigt werden können (wie Date s in String s)

Gerade der letzte Punkt hat einen recht großen Vorteil. Wenn in MVC die Ansicht oder der Ansichtscontroller für die Transformation der Daten für die Anzeige verantwortlich ist, wird dies immer im Hauptthread durchgeführt, da es sehr schwierig ist, echte Änderungen an der Benutzeroberfläche, die in diesem Thread erforderlich sind, von den tatsächlichen Änderungen zu trennen nicht erforderlich, um darauf zu laufen. Und wenn Code ohne UI-Änderung im Hauptthread ausgeführt wird, kann dies zu einer weniger reaktionsschnellen Anwendung führen.

Stattdessen wird mit diesem MVVM-Muster alles von dem Block, der durch einen Tap ausgelöst wird, bis zu dem Moment, in dem das Ansichtsmodell erstellt und an den Listener weitergegeben wird – wir können dies alles in einem separaten Thread ausführen und nur in den Hauptthread eintauchen end für die Durchführung von UI-Updates. Wenn unsere Anwendung weniger Zeit mit dem Hauptthread verbringt, läuft sie reibungsloser.

Sobald das Ansichtsmodell den neuen Zustand auf die Ansicht anwendet, kann er verdampfen, anstatt als weitere Zustandsebene zu verbleiben. Alles, was ein Ereignis auslösen könnte, wird an ein Element in der Ansicht angehängt, und wir kommunizieren nicht zurück an das Ansichtsmodell.

Eines ist wichtig zu beachten: Sie sind nicht gezwungen, ein Ansichtsmodell über einen Ansichtscontroller einer Ansicht zuzuordnen. Wie bereits erwähnt, können Teile der Ansicht von anderen Ansichtsmodellen verwaltet werden, insbesondere wenn die Aktualisierungsraten variieren. Stellen Sie sich ein Google Sheet vor, das von verschiedenen Personen bearbeitet wird, während ein Chat-Fenster für Mitbearbeiter offen bleibt – es ist nicht sehr nützlich, das Dokument immer dann zu aktualisieren, wenn eine Chat-Nachricht eintrifft.

Ein bekanntes Beispiel ist eine Type-to-Find-Implementierung, bei der das Suchfeld mit genaueren Ergebnissen aktualisiert wird, wenn wir mehr Text eingeben. So würde ich die automatische Vervollständigung in der Klasse CreateAutocompleteView implementieren: Der gesamte Bildschirm wird vom CreateViewModel , aber das Textfeld hört stattdessen auf das AutocompleteContactViewModel .

Ein weiteres Beispiel ist die Verwendung eines Formularvalidierers, der entweder als „lokale Schleife“ (Anhängen oder Entfernen von Fehlerzuständen an Felder und Erklären eines Formulars für gültig) oder durch Auslösen eines Ereignisses erstellt werden kann.

Statische, unveränderliche Ansichtsmodelle bieten eine bessere Trennung

Durch die Verwendung einer statischen MVVM-Implementierung ist es uns gelungen, endlich alle Schichten vollständig zu trennen, da das Ansichtsmodell jetzt eine Brücke zwischen dem Modell und der Ansicht bildet. Wir haben auch die Verwaltung von Ereignissen vereinfacht, die nicht durch Benutzeraktionen verursacht wurden, und viele Abhängigkeiten zwischen den verschiedenen Teilen unserer Anwendung entfernt. Das Einzige, was ein View-Controller tut, ist, sich bei den Event-Handlern als Listener für die Ereignisse zu registrieren (und abzumelden), die er empfangen möchte.

Leistungen:

  • View- und View-Controller-Implementierungen sind in der Regel viel leichter
  • Die Klassen sind spezialisierter und getrennter
  • Events können einfach von jedem Ort aus ausgelöst werden
  • Ereignisse folgen einem vorhersagbaren Pfad durch das System
  • Zustand wird nur von einer Stelle aktualisiert
  • 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