Lavorare con modelli statici: un tutorial Swift MVVM

Pubblicato: 2022-03-11

Oggi vedremo come le nuove possibilità tecniche e le aspettative dei nostri utenti per le applicazioni basate sui dati in tempo reale creino nuove sfide nel modo in cui strutturiamo i nostri programmi, in particolare le nostre applicazioni mobili. Sebbene questo articolo riguardi iOS e Swift, molti dei modelli e delle conclusioni sono ugualmente applicabili ad Android e alle applicazioni Web.

C'è stata un'importante evoluzione nel modo in cui funzionano le moderne app mobili negli ultimi anni. Grazie a un accesso a Internet più pervasivo e a tecnologie come le notifiche push e i WebSocket, l'utente di solito non è più l'unica fonte di eventi di runtime, e non necessariamente più il più importante, in molte delle app mobili di oggi.

Diamo un'occhiata più da vicino al modo in cui due modelli di progettazione Swift funzionano ciascuno con una moderna applicazione di chat: il modello classico modello-view-controller (MVC) e un modello immutabile semplificato modello-vista-modello (MVVM, a volte stilizzato "il modello ViewModel ”). Le app di chat sono un buon esempio perché hanno molte fonti di dati e devono aggiornare le loro interfacce utente in molti modi diversi ogni volta che vengono ricevuti dati.

La nostra applicazione di chat

L'applicazione che useremo come linea guida in questo tutorial Swift MVVM avrà la maggior parte delle funzionalità di base che conosciamo dalle applicazioni di chat come WhatsApp. Esaminiamo le funzionalità che implementeremo e confronteremo MVVM e MVC. L'applicazione:

  • Carica le chat precedentemente ricevute dal disco
  • Sincronizzerà le chat esistenti su una richiesta GET con il server
  • Riceverà notifiche push quando un nuovo messaggio viene inviato all'utente
  • Sarà connesso a un WebSocket una volta che saremo in una schermata di chat
  • Può POST un nuovo messaggio in una chat
  • Mostrerà una notifica in-app quando viene ricevuto un nuovo messaggio di una chat in cui non ci troviamo attualmente
  • Mostrerà immediatamente un nuovo messaggio quando riceviamo un nuovo messaggio per la chat corrente
  • Invierà un messaggio letto quando leggiamo un messaggio non letto
  • Riceverà un messaggio letto quando qualcuno legge il nostro messaggio
  • Aggiorna il badge del contatore dei messaggi non letti sull'icona dell'applicazione
  • Sincronizza tutti i messaggi ricevuti o modificati in Core Data

In questa applicazione demo, non ci sarà una vera implementazione di API, WebSocket o Core Data per mantenere l'implementazione del modello un po' più semplice. Invece, ho aggiunto un chatbot che inizierà a risponderti una volta avviata una conversazione. Tuttavia, tutti gli altri instradamenti e chiamate vengono implementati come se lo spazio di archiviazione e le connessioni fossero reali, comprese piccole pause asincrone prima del ritorno.

Sono state costruite le seguenti tre schermate:

Schermate Elenco chat, Crea chat e Messaggi.

MVC classico

Prima di tutto, c'è il modello MVC standard per la creazione di un'applicazione iOS. Questo è il modo in cui Apple struttura tutto il suo codice di documentazione e il modo in cui le API e gli elementi dell'interfaccia utente si aspettano di funzionare. È ciò che viene insegnato alla maggior parte delle persone quando seguono un corso iOS.

Spesso MVC viene accusato di portare a UIViewController gonfi di poche migliaia di righe di codice. Ma se viene applicato bene, con una buona separazione tra ogni livello, possiamo avere ViewController abbastanza sottili che agiscono solo come gestori intermedi tra View , Model e altri Controller .

Ecco il diagramma di flusso per l'implementazione MVC dell'app (tralasciando CreateViewController per chiarezza):

Diagramma di flusso dell'implementazione MVC, tralasciando CreateViewController per chiarezza.

Esaminiamo gli strati in dettaglio.

Modello

Il livello del modello è solitamente il livello meno problematico in MVC. In questo caso, ho scelto di utilizzare ChatWebSocket , ChatModel e PushNotificationController per mediare tra gli oggetti Chat e Message , le origini dati esterne e il resto dell'applicazione. ChatModel è la fonte della verità all'interno dell'applicazione e funziona solo in memoria in questa applicazione demo. In un'applicazione reale, sarebbe probabilmente supportata da Core Data. Infine, ChatEndpoint gestisce tutte le chiamate HTTP.

Visualizzazione

Le visualizzazioni sono piuttosto grandi in quanto deve gestire molte responsabilità poiché ho separato con cura tutto il codice di visualizzazione da UIViewController s. Ho fatto quanto segue:

  • Utilizzato il modello di enum dello stato (molto consigliato) per definire lo stato in cui si trova attualmente la vista.
  • Aggiunte le funzioni che si collegano ai pulsanti e ad altri elementi dell'interfaccia che attivano l'azione (come toccare Invio durante l'inserimento del nome di un contatto).
  • Imposta i vincoli e richiama ogni volta il delegato.

Dopo aver inserito un UITableView nel mix, le visualizzazioni ora sono molto più grandi di UIViewController s, portando a preoccupanti oltre 300 righe di codice e molte attività miste in ChatView .

Controllore

Poiché tutta la logica di gestione del modello è stata spostata su ChatModel . Tutto il codice della vista, che potrebbe nascondersi qui in progetti separati e meno ottimali, ora vive nella vista, quindi gli UIViewController sono piuttosto sottili. Il controller di visualizzazione è completamente ignaro dell'aspetto dei dati del modello, di come vengono recuperati o di come dovrebbero essere visualizzati: si limita a coordinare. Nel progetto di esempio, nessuno degli UIViewController supera le 150 righe di codice.

Tuttavia, ViewController fa ancora le seguenti cose:

  • Essere un delegato per la vista e altri controller di vista
  • Istanziazione e push (o popping) dei controller di visualizzazione, se necessario
  • Invio e ricezione di chiamate da e verso ChatModel
  • Avvio e arresto di WebSocket a seconda della fase del ciclo del controller di visualizzazione
  • Prendere decisioni logiche come non inviare un messaggio se è vuoto
  • Aggiornamento della vista

È ancora molto, ma riguarda principalmente il coordinamento, l'elaborazione dei blocchi di richiamata e l'inoltro.

Benefici

  • Questo modello è compreso da tutti e promosso da Apple
  • Funziona con tutta la documentazione
  • Non sono necessari framework aggiuntivi

Svantaggi

  • I controller di visualizzazione hanno molte attività; molti di loro stanno fondamentalmente passando i dati avanti e indietro tra la vista e il livello del modello
  • Non molto adatto per gestire più origini di eventi
  • Le classi tendono a sapere molto sulle altre classi

Definizione del problema

Funziona molto bene purché l'applicazione segua le azioni dell'utente e risponda ad esse, come si potrebbe immaginare che un'applicazione come Adobe Photoshop o Microsoft Word funzionerebbe. L'utente esegue un'azione, l'interfaccia utente si aggiorna, ripetere.

Ma le applicazioni moderne sono connesse, spesso in più di un modo. Ad esempio, interagisci tramite un'API REST, ricevi notifiche push e, in alcuni casi, ti connetti anche a un WebSocket.

Con ciò, improvvisamente il controller di visualizzazione deve gestire più fonti di informazioni e ogni volta che viene ricevuto un messaggio esterno senza che l'utente lo attivi, come ricevere un messaggio tramite WebSocket, le fonti di informazioni devono ritrovare la strada giusta visualizzare i controller. Ciò richiede molto codice solo per incollare insieme ogni parte per eseguire quello che è fondamentalmente lo stesso compito.

Fonti di dati esterne

Diamo un'occhiata a cosa succede quando riceviamo un messaggio 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 } }

Dobbiamo esaminare manualmente la pila di controller di visualizzazione per capire se esiste un controller di visualizzazione che deve aggiornarsi dopo aver ricevuto una notifica push. In questo caso, vogliamo aggiornare anche le schermate che implementano UpdatedChatDelegate , che, in questo caso, è solo il ChatsViewController . Lo facciamo anche per sapere se dobbiamo sopprimere la notifica perché stiamo già guardando la Chat per cui è stata pensata. In tal caso, finalmente consegniamo il messaggio al controller di visualizzazione. È abbastanza chiaro che PushNotificationController bisogno di sapere troppo sull'applicazione per poter svolgere il suo lavoro.

Se ChatWebSocket consegnasse messaggi anche ad altre parti dell'applicazione, invece di avere una relazione uno-a-uno con ChatViewController , ci troveremmo ad affrontare lo stesso problema lì.

È chiaro che dobbiamo scrivere codice abbastanza invasivo ogni volta che aggiungiamo un'altra fonte esterna. Questo codice è anche piuttosto fragile, poiché fa molto affidamento sulla struttura dell'applicazione e delega i dati che passano indietro nella gerarchia per funzionare.

Delegati

Il pattern MVC aggiunge anche ulteriore complessità al mix una volta aggiunti altri controller di visualizzazione. Questo perché i controller di visualizzazione tendono a conoscersi l'un l'altro tramite delegati, inizializzatori e, nel caso degli storyboard prepareForSegue durante il passaggio di dati e riferimenti. Ogni controller di visualizzazione gestisce le proprie connessioni al modello o ai controller di mediazione e inviano e ricevono aggiornamenti.

Inoltre, le viste comunicano ai controller delle viste tramite i delegati. Anche se questo funziona, significa che ci sono molti passaggi che dobbiamo compiere per passare i dati e mi ritrovo sempre a rifattorizzare molto i callback e controllare se i delegati sono davvero impostati.

È possibile interrompere un controller di visualizzazione modificando il codice in un altro, come i dati non aggiornati nel ChatsListViewController perché ChatViewController non chiama più updated(chat: Chat) . Soprattutto negli scenari più complessi, è una seccatura mantenere tutto sincronizzato.

Separazione tra vista e modello

Rimuovendo tutto il codice relativo alla visualizzazione dal controller di visualizzazione a customView s e spostando tutto il codice relativo al modello su controller specializzati, il controller di visualizzazione è piuttosto snello e separato. Tuttavia, rimane ancora un problema: c'è un divario tra ciò che la vista vuole visualizzare e i dati che risiedono nel modello. Un buon esempio è ChatListView . Quello che vogliamo visualizzare è un elenco di celle che ci dicono con chi stiamo parlando, qual era l'ultimo messaggio, la data dell'ultimo messaggio e quanti messaggi non letti sono rimasti nella Chat :

Contatore messaggi non letti nella schermata Chat.

Tuttavia, stiamo passando un modello che non sa cosa vogliamo vedere. Invece, è solo una Chat con un contatto, contenente messaggi:

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

Ora è possibile aggiungere rapidamente del codice extra che ci porterà l'ultimo messaggio e il conteggio dei messaggi, ma la formattazione delle date nelle stringhe è un'attività che appartiene saldamente al livello di visualizzazione:

 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 }

Quindi alla fine formattiamo la data in ChatItemTableViewCell quando la visualizziamo:

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

Anche in un esempio abbastanza semplice, è abbastanza chiaro che c'è una tensione tra ciò di cui la vista ha bisogno e ciò che fornisce il modello.

MVVM statico guidato da eventi, noto anche come una versione statica guidata da eventi sul "modello ViewModel"

MVVM statico funziona con i modelli di visualizzazione, ma invece di creare traffico bidirezionale attraverso di essi, proprio come facevamo tramite il nostro controller di visualizzazione con MVC, creiamo modelli di visualizzazione immutabili che aggiornano l'interfaccia utente ogni volta che l'interfaccia utente deve cambiare in risposta a un evento .

Un evento può essere attivato da quasi tutte le parti del codice, purché sia ​​in grado di fornire i dati associati richiesti dall'evento enum . Ad esempio, la ricezione dell'evento received(new: Message) può essere attivata da una notifica push, dal WebSocket o da una normale chiamata di rete.

Vediamolo in un diagramma:

Diagramma di flusso di implementazione MVVM.

A prima vista, sembra essere un po' più complesso del classico esempio MVC, poiché ci sono molte più classi coinvolte per ottenere esattamente la stessa cosa. Ma a un esame più attento, nessuna delle relazioni è più bidirezionale.

Ancora più importante è che ogni aggiornamento dell'interfaccia utente viene attivato da un evento, quindi c'è un solo percorso attraverso l'app per tutto ciò che accade. È immediatamente chiaro quali eventi puoi aspettarti. È anche chiaro dove dovresti aggiungerne uno nuovo, se necessario, o aggiungere un nuovo comportamento quando rispondi a eventi esistenti.

Dopo il refactoring, ho finito con molte nuove classi, come ho mostrato sopra. Puoi trovare la mia implementazione della versione statica di MVVM su GitHub. Tuttavia, quando confronto le modifiche con lo strumento cloc , diventa chiaro che in realtà non c'è molto codice extra:

Modello File Vuoto Commento Codice
MVC 30 386 217 1807
MVVM 51 442 359 1981

C'è solo un aumento del 9 percento nelle righe di codice. Ancora più importante, la dimensione media di questi file è scesa da 60 righe di codice a solo 39.

Grafici a torta con linee di codice. Visualizza i controller: MVC 287 vs MVVM 154 o 47% in meno; Visualizzazioni: MVC 523 vs MVVM 392 o il 26% in meno.

Inoltre, i cali maggiori si trovano nei file che sono in genere i più grandi in MVC: le viste e i controller di visualizzazione. Le viste sono solo il 74% delle loro dimensioni originali e i controller di visualizzazione ora sono solo il 53% delle loro dimensioni originali.

Va anche notato che gran parte del codice aggiuntivo è il codice della libreria che aiuta ad allegare blocchi a pulsanti e altri oggetti nell'albero visivo, senza richiedere il classico @IBAction di MVC o modelli di delega.

Esploriamo uno per uno i diversi livelli di questo design.

Evento

L'evento è sempre un enum , in genere con valori associati. Spesso si sovrapporranno a una delle entità nel tuo modello, ma non necessariamente. In questo caso, l'applicazione è suddivisa in due enum di eventi principali: ChatEvent e MessageEvent . ChatEvent è per tutti gli aggiornamenti sugli oggetti chat stessi:

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

L'altro si occupa di tutti gli eventi relativi ai messaggi:

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

È importante limitare i tuoi *Event enum s a una dimensione ragionevole. Se hai bisogno di 10 o più casi, di solito è un segno che stai cercando di coprire più di un argomento.

Nota: il concetto di enum è estremamente potente in Swift. Tendo a usare molto enum s con valori associati, poiché possono eliminare molte ambiguità che altrimenti avresti con valori opzionali.

Tutorial Swift MVVM: router di eventi

Il router degli eventi è il punto di ingresso per ogni evento che si verifica nell'applicazione. Qualsiasi classe in grado di fornire il valore associato può creare un evento e inviarlo al router di eventi. Quindi possono essere attivati ​​da qualsiasi tipo di fonte, ad esempio:

  • L'utente che passa a un particolare controller di visualizzazione
  • L'utente tocca un determinato pulsante
  • Avvio dell'applicazione
  • Eventi esterni come:
    • Una richiesta di rete restituita con un errore o nuovi dati
    • Notifiche push
    • Messaggi WebSocket

Il router degli eventi dovrebbe sapere il meno possibile sull'origine dell'evento e preferibilmente nulla. Nessuno degli eventi in questa applicazione di esempio ha alcun indicatore da dove provengono, quindi è molto facile combinare qualsiasi tipo di origine del messaggio. Ad esempio, WebSocket attiva lo stesso evento - received(message: Message, contact: String) - come una nuova notifica push.

Gli eventi vengono (avete già indovinato) indirizzati alle classi che devono elaborare ulteriormente questi eventi. Di solito, le uniche classi che vengono chiamate sono il livello del modello (se è necessario aggiungere, modificare o rimuovere i dati) e il gestore dell'evento. Parlerò di entrambi un po' più avanti, ma la caratteristica principale del router di eventi è fornire un punto di accesso facile a tutti gli eventi e inoltrare il lavoro ad altre classi. Ecco il ChatEventRouter come esempio:

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

C'è abbastanza poco da fare qui: l'unica cosa che stiamo facendo è aggiornare il modello e inoltrare l'evento a ChatEventHandler in modo che l'interfaccia utente venga aggiornata.

Tutorial Swift MVVM: controller modello

Questa è esattamente la stessa classe che usiamo in MVC, poiché funzionava già abbastanza bene. Rappresenta lo stato dell'applicazione e di solito sarebbe supportato da Core Data o da una libreria di archiviazione locale.

I livelli del modello, se implementati correttamente in MVC, richiedono molto raramente il refactoring per adattarsi a modelli diversi. Il cambiamento più grande è che la modifica del modello avviene da un minor numero di classi, rendendo un po' più chiaro dove si verificano i cambiamenti.

In una versione alternativa di questo modello, puoi osservare le modifiche al modello e assicurarti che vengano gestite. In questo caso, ho scelto di lasciare che solo le *EventRouter e *Endpoint modifichino il modello, quindi c'è una chiara responsabilità di dove e quando il modello viene aggiornato. Al contrario, se stessimo osservando le modifiche, dovremmo scrivere codice aggiuntivo per propagare eventi che non modificano il modello come gli errori tramite ChatEventHandler , il che renderebbe meno ovvio il modo in cui gli eventi fluiscono attraverso l'applicazione.

Tutorial Swift MVVM: gestore di eventi

Il gestore eventi è il luogo in cui le viste o i controller di visualizzazione possono registrarsi (e annullare la registrazione) come listener per ricevere modelli di visualizzazione aggiornati, che vengono compilati ogni volta che ChatEventRouter chiama una funzione in ChatEventHandler .

Puoi vedere che riflette approssimativamente tutti gli stati di visualizzazione che abbiamo usato prima in MVC. Se desideri altri tipi di aggiornamenti dell'interfaccia utente, come il suono o l'attivazione del motore Taptic, possono essere eseguiti anche da qui.

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

Questa classe non fa altro che assicurarsi che l'ascoltatore giusto possa ottenere il modello di visualizzazione corretto ogni volta che si verifica un determinato evento. I nuovi ascoltatori possono ottenere un modello di visualizzazione immediatamente quando vengono aggiunti, se necessario per configurare il loro stato iniziale. Assicurati sempre di aggiungere un riferimento weak all'elenco per evitare cicli di conservazione.

Tutorial Swift MVVM: Visualizza modello

Ecco una delle maggiori differenze tra ciò che fanno molti modelli MVVM e ciò che fa la variante statica. In questo caso, il modello di visualizzazione è immutabile invece di configurarsi come intermedio permanente a due vie tra il modello e la visualizzazione. Perché dovremmo farlo? Fermiamoci per spiegarlo un momento.

Uno degli aspetti più importanti della creazione di un'applicazione che funzioni bene in tutti i casi possibili è assicurarsi che lo stato dell'applicazione sia corretto. Se l'interfaccia utente non corrisponde al modello o ha dati obsoleti, tutto ciò che facciamo potrebbe comportare il salvataggio di dati errati o l'arresto anomalo dell'applicazione o il comportamento imprevisto.

Uno degli obiettivi dell'applicazione di questo modello è che non abbiamo uno stato nell'applicazione a meno che non sia assolutamente necessario. Cos'è lo stato, esattamente? Lo stato è fondamentalmente ogni luogo in cui memorizziamo una rappresentazione di un particolare tipo di dati. Un tipo speciale di stato è lo stato in cui si trova attualmente l'interfaccia utente, che ovviamente non possiamo impedire con un'applicazione basata sull'interfaccia utente. Gli altri tipi di stato sono tutti relativi ai dati. Se disponiamo di una copia di un array di Chat che esegue il backup di UITableView nella schermata Elenco chat, questo è un esempio di stato duplicato. Un tradizionale modello di visualizzazione a due vie sarebbe un altro esempio di duplicato delle Chat dei nostri utenti.

Passando un modello di visualizzazione immutabile che viene aggiornato a ogni modifica del modello, eliminiamo questo tipo di stato duplicato, perché dopo essersi applicato all'interfaccia utente, non viene più utilizzato. Quindi abbiamo solo gli unici due tipi di stato che non possiamo evitare, interfaccia utente e modello, e sono perfettamente sincronizzati tra loro.

Quindi il modello di visualizzazione qui è abbastanza diverso da alcune applicazioni MVVM. Serve solo come archivio dati immutabile per tutti i flag, valori, blocchi e altri valori richiesti dalla vista per riflettere lo stato del modello, ma non può essere aggiornato in alcun modo dalla vista.

Pertanto può essere una semplice struct . Per mantenere questa struct il più semplice possibile, la creeremo un'istanza con un generatore di modelli di visualizzazione. Una delle cose interessanti di un modello di visualizzazione è che ottiene flag comportamentali come shouldShowBusy e shouldShowError che sostituiscono il meccanismo di enum dello stato precedentemente trovato nella vista. Ecco i dati per ChatItemTableViewCell che avevamo analizzato prima:

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

Poiché il generatore di modelli di visualizzazione si occupa già dei valori e delle azioni esatti necessari alla visualizzazione, tutti i dati sono preformattati. Un'altra novità è un blocco che verrà attivato una volta toccato un oggetto. Vediamo come viene realizzato dal generatore di modelli di visualizzazione.

Visualizza Model Builder

Il generatore di modelli di visualizzazione può creare istanze di modelli di visualizzazione, trasformando input come Chat o Message in modelli di visualizzazione perfettamente adattati per una determinata visualizzazione. Una delle cose più importanti che accadono nel generatore di modelli di visualizzazione è determinare cosa accade effettivamente all'interno dei blocchi nel modello di visualizzazione. I blocchi allegati dal generatore di modelli di visualizzazione dovrebbero essere estremamente brevi, richiamando le funzioni di altre parti dell'architettura il prima possibile. Tali blocchi non dovrebbero avere alcuna logica aziendale.

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

Ora tutta la preformattazione avviene nello stesso posto e anche il comportamento viene deciso qui. È una classe piuttosto importante in questa gerarchia e può essere interessante vedere come sono stati implementati i diversi builder nell'applicazione demo e come affrontare scenari più complicati.

Tutorial Swift MVVM: Visualizza controller

Il controller di visualizzazione in questa architettura fa molto poco. Installerà e abbatterà tutto ciò che riguarda la sua vista. È meglio farlo perché ottiene tutti i callback del ciclo di vita necessari per aggiungere e rimuovere ascoltatori al momento giusto.

A volte è necessario aggiornare un elemento dell'interfaccia utente che non è coperto dalla vista principale, come il titolo o un pulsante nella barra di navigazione. Ecco perché di solito registro ancora il controller di visualizzazione come listener del router di eventi se ho un modello di visualizzazione che copre l'intera visualizzazione per il controller di visualizzazione specificato; Inoltro il modello di visualizzazione alla visualizzazione in seguito. Ma va bene anche registrare qualsiasi UIView come listener direttamente se c'è una parte dello schermo che ha una frequenza di aggiornamento diversa, ad esempio un ticker di azioni live in cima a una pagina su una determinata azienda.

Il codice per ChatsViewController ora è così breve che richiede meno di una pagina. Ciò che resta è sovrascrivere la vista di base, aggiungere e rimuovere il pulsante Aggiungi dalla barra di navigazione, impostare il titolo, aggiungersi come listener e implementare il protocollo 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) } }

Non è rimasto nulla che possa essere fatto altrove, poiché ChatsViewController è ridotto al minimo.

Esercitazione Swift MVVM: Visualizza

La vista nell'architettura MVVM immutabile può essere ancora piuttosto pesante, poiché ha ancora un elenco di attività, ma sono riuscito a privarlo delle seguenti responsabilità rispetto all'architettura MVC:

  • Determinare cosa deve cambiare in risposta a un nuovo stato
  • Implementazione di delegati e funzioni per le azioni
  • Gestisci i trigger da vista a vista come i gesti e le animazioni attivate
  • Trasformare i dati in modo tale che possano essere mostrati (come Date s a String s)

Soprattutto l'ultimo punto ha un grande vantaggio. In MVC, quando il controller di visualizzazione o visualizzazione è responsabile della trasformazione dei dati per la visualizzazione, lo farà sempre nel thread principale poiché è molto difficile separare le vere modifiche all'interfaccia utente che devono verificarsi su questo thread da cose che sono non è necessario correre su di esso. E l'esecuzione di codice non di modifica dell'interfaccia utente sul thread principale può portare a un'applicazione meno reattiva.

Invece, con questo pattern MVVM, tutto dal blocco che viene attivato con un tocco fino al momento in cui il modello di visualizzazione viene creato e verrà passato all'ascoltatore: possiamo eseguire tutto questo su un thread separato e immergerci solo nel thread principale nel fine per eseguire gli aggiornamenti dell'interfaccia utente. Se la nostra applicazione trascorre meno tempo sul thread principale, funzionerà in modo più fluido.

Una volta che il modello di vista applica il nuovo stato alla vista, può evaporare invece di indugiare come un altro livello di stato. Tutto ciò che potrebbe attivare un evento è allegato a un elemento nella vista e non verrà comunicato al modello della vista.

È importante ricordare una cosa: non sei obbligato a mappare un modello di vista tramite un controller di vista su una vista. Come accennato in precedenza, parti della vista possono essere gestite da altri modelli di vista, soprattutto quando le velocità di aggiornamento variano. Considera un foglio Google modificato da persone diverse mantenendo un riquadro della chat aperto per i collaboratori: non è molto utile aggiornare il documento ogni volta che arriva un messaggio di chat.

Un esempio noto è un'implementazione type-to-find in cui la casella di ricerca viene aggiornata con risultati più accurati man mano che inseriamo più testo. Questo è il modo in cui implementerei il completamento automatico nella classe CreateAutocompleteView : l'intero schermo è servito da CreateViewModel ma la casella di testo ascolta invece AutocompleteContactViewModel .

Un altro esempio è l'utilizzo di un validatore di moduli, che può essere creato come un "loop locale" (collegando o rimuovendo stati di errore ai campi e dichiarando valido un modulo) o tramite l'attivazione di un evento.

I modelli di visualizzazione immutabile statica forniscono una migliore separazione

Utilizzando un'implementazione statica di MVVM siamo riusciti a separare finalmente tutti i livelli completamente perché il modello di visualizzazione ora fa da ponte tra il modello e la vista. Abbiamo anche semplificato la gestione degli eventi che non erano causati dall'azione dell'utente e rimosso molte delle dipendenze tra le diverse parti della nostra applicazione. L'unica cosa che fa un controller di visualizzazione è registrarsi (e annullare la registrazione) ai gestori di eventi come listener per gli eventi che desidera ricevere.

Benefici:

  • Visualizza e visualizza le implementazioni del controller tendono ad essere molto più leggere
  • Le classi sono più specializzate e separate
  • Gli eventi possono essere attivati ​​facilmente da qualsiasi luogo
  • Gli eventi seguono un percorso prevedibile attraverso il sistema
  • Lo stato viene aggiornato solo da un posto
  • 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