Lucrul cu modele statice: un tutorial MVVM rapid
Publicat: 2022-03-11Astăzi vom vedea cum noile posibilități tehnice și așteptări de la utilizatorii noștri pentru aplicațiile bazate pe date în timp real creează noi provocări în modul în care ne structurem programele, în special aplicațiile noastre mobile. Deși acest articol este despre iOS și Swift, multe dintre modele și concluzii sunt aplicabile în mod egal pentru Android și aplicațiile web.
A existat o evoluție importantă în modul în care funcționează aplicațiile mobile moderne în ultimii ani. Datorită accesului la internet mai răspândit și tehnologiilor precum notificările push și WebSockets, utilizatorul nu mai este, de obicei, singura sursă de evenimente de rulare – și nu mai este neapărat cea mai importantă – în multe dintre aplicațiile mobile de astăzi.
Să aruncăm o privire mai atentă la cât de bine funcționează două modele de design Swift fiecare cu o aplicație de chat modernă: modelul clasic model-view-controller (MVC) și un model imuabil simplificat model-view-model (MVVM, uneori stilizat „modelul ViewModel". ”). Aplicațiile de chat sunt un bun exemplu, deoarece au multe surse de date și trebuie să își actualizeze interfețele de utilizator în multe moduri diferite ori de câte ori sunt primite date.
Aplicația noastră de chat
Aplicația pe care o vom folosi ca ghid în acest tutorial Swift MVVM va avea majoritatea caracteristicilor de bază pe care le cunoaștem din aplicațiile de chat precum WhatsApp. Să trecem peste caracteristicile pe care le vom implementa și să comparăm MVVM vs MVC. Aplicația:
- Va încărca conversațiile primite anterior de pe disc
- Va sincroniza conversațiile existente prin intermediul unei cereri
GET
cu serverul - Va primi notificări push atunci când un nou mesaj este trimis utilizatorului
- Va fi conectat la un WebSocket odată ce ne aflăm într-un ecran de chat
- Poate
POST
un mesaj nou într-un chat - Va afișa o notificare în aplicație când se primește un mesaj nou despre un chat în care nu ne aflăm în prezent
- Va afișa un mesaj nou imediat când primim un mesaj nou pentru chatul curent
- Va trimite un mesaj citit atunci când citim un mesaj necitit
- Vom primi un mesaj citit când cineva va citi mesajul nostru
- Actualizează insigna contorului de mesaje necitite de pe pictograma aplicației
- Sincronizează toate mesajele primite sau modificate înapoi în Core Data
În această aplicație demonstrativă, nu va exista o implementare reală API, WebSocket sau Core Data pentru a menține implementarea modelului puțin mai simplă. În schimb, am adăugat un chatbot care va începe să vă răspundă odată ce începeți o conversație. Cu toate acestea, toate celelalte rutări și apeluri sunt implementate așa cum ar fi dacă stocarea și conexiunile ar fi reale, inclusiv mici pauze asincrone înainte de întoarcere.
Au fost construite următoarele trei ecrane:
MVC clasic
În primul rând, există modelul MVC standard pentru construirea unei aplicații iOS. Acesta este modul în care Apple își structurează întregul cod de documentație și modul în care se așteaptă să funcționeze API-urile și elementele UI. Este ceea ce majoritatea oamenilor sunt învățați atunci când urmează un curs iOS.
Adesea, MVC este acuzat că a condus la UIViewController
-uri umflate de câteva mii de linii de cod. Dar dacă este aplicat bine, cu o bună separare între fiecare strat, putem avea ViewController
-uri destul de subțiri care acționează doar ca manageri intermediari între View
-uri, Model
-uri și alte Controller
-uri.
Iată diagrama fluxului pentru implementarea MVC a aplicației (lăsând deoparte CreateViewController
pentru claritate):
Să trecem peste straturi în detaliu.
Model
Stratul model este de obicei cel mai puțin problematic strat din MVC. În acest caz, am optat să folosesc ChatWebSocket
, ChatModel
și PushNotificationController
pentru a media între obiectele Chat
și Message
, sursele externe de date și restul aplicației. ChatModel
este sursa adevărului în cadrul aplicației și funcționează numai în memorie în această aplicație demonstrativă. Într-o aplicație din viața reală, probabil că ar fi susținută de Core Data. În cele din urmă, ChatEndpoint
se ocupă de toate apelurile HTTP.
Vedere
Vizualizările sunt destul de mari, deoarece trebuie să se ocupe de o mulțime de responsabilități, deoarece am separat cu grijă tot codul de vizualizare de UIViewController
. Am facut urmatoarele:
- A folosit modelul de
enum
a stării (foarte recomandabil) pentru a defini starea în care se află în prezent vizualizarea. - S-au adăugat funcțiile care sunt conectate la butoane și alte elemente de interfață care declanșează acțiuni (cum ar fi atingerea Înapoi în timp ce introduceți numele unui contact.)
- Configurați constrângerile și sunați înapoi delegatul de fiecare dată.
Odată ce aruncați un UITableView
în amestec, vizualizările sunt acum mult mai mari decât UIViewController
, ceea ce duce la peste 300 de linii de cod îngrijorătoare și la o mulțime de sarcini mixte în ChatView
.
Controlor
Deoarece toată logica de gestionare a modelului a fost mutată în ChatModel
. Tot codul de vizualizare – care ar putea fi pândit aici în proiecte mai puțin optime, separate – locuiește acum în vizualizare, așa că UIViewController
-urile sunt destul de subțiri. Controlerul de vizualizare este complet ignorant de cum arată datele modelului, cum sunt preluate sau cum ar trebui să fie afișate - doar coordonează. În proiectul exemplu, niciunul dintre UIViewController
nu trece peste 150 de linii de cod.
Cu toate acestea, ViewController face în continuare următoarele lucruri:
- Fiind un delegat pentru vizualizare și alți controlori de vizualizare
- Instanțarea și împingerea (sau deschiderea) controlerelor de vizualizare, dacă este necesar
- Trimiterea și primirea apelurilor către și de la
ChatModel
- Pornirea și oprirea WebSocket-ului în funcție de stadiul ciclului controlerului de vizualizare
- Luarea unor decizii logice, cum ar fi să nu trimită un mesaj dacă este gol
- Actualizarea vizualizării
Este încă mult, dar este în principal coordonare, procesare blocări de apel invers și redirecționare.
Beneficii
- Acest model este înțeles de toată lumea și promovat de Apple
- Funcționează cu toată documentația
- Nu sunt necesare cadre suplimentare
Dezavantaje
- Controlerele de vizualizare au o mulțime de sarcini; multe dintre ele, practic, transmit date înainte și înapoi între vizualizare și stratul de model
- Nu este foarte potrivit pentru a gestiona mai multe surse de evenimente
- Clasele tind să știe multe despre alte clase
Definirea problemei
Acest lucru funcționează foarte bine atâta timp cât aplicația urmărește acțiunile utilizatorului și răspunde la acestea, așa cum v-ați imagina că ar funcționa o aplicație precum Adobe Photoshop sau Microsoft Word. Utilizatorul efectuează o acțiune, UI se actualizează, repetă.
Dar aplicațiile moderne sunt conectate, adesea în mai multe moduri. De exemplu, interacționați printr-un API REST, primiți notificări push și, în unele cazuri, vă conectați și la un WebSocket.
Dintr-o dată, controlerul de vizualizare trebuie să se ocupe de mai multe surse de informații și, ori de câte ori este primit un mesaj extern fără ca utilizatorul să-l declanșeze, cum ar fi primirea unui mesaj prin WebSocket, sursele de informații trebuie să găsească drumul înapoi spre dreapta. vizualiza controlere. Acest lucru necesită mult cod doar pentru a lipi fiecare parte împreună pentru a îndeplini ceea ce este practic aceeași sarcină.
Surse de date externe
Să aruncăm o privire la ce se întâmplă când primim un mesaj 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 } }
Trebuie să cercetăm manual teancul de controlere de vizualizare pentru a ne da seama dacă există un controler de vizualizare care trebuie să se actualizeze după ce primim o notificare push. În acest caz, dorim să actualizăm și ecranele care implementează UpdatedChatDelegate
, care, în acest caz, este doar ChatsViewController
. De asemenea, facem acest lucru pentru a ști dacă ar trebui să suprimăm notificarea, deoarece ne uităm deja la Chat
-ul pentru care a fost destinat. În acest caz, în cele din urmă livrăm mesajul controlorului de vizualizare. Este destul de clar că PushNotificationController
trebuie să știe prea multe despre aplicație pentru a-și putea face treaba.
Dacă ChatWebSocket
ar livra mesaje și către alte părți ale aplicației, în loc să avem o relație unu-la-unu cu ChatViewController
, ne-am confrunta acolo cu aceeași problemă.
Este clar că trebuie să scriem un cod destul de invaziv de fiecare dată când adăugăm o altă sursă externă. Acest cod este, de asemenea, destul de fragil, deoarece se bazează în mare măsură pe structura aplicației și delegați care trec datele înapoi în ierarhie pentru a funcționa.
Delegații
Modelul MVC adaugă, de asemenea, un plus de complexitate amestecului odată ce adăugăm alte controlere de vizualizare. Acest lucru se datorează faptului că controlorii de vizualizare tind să cunoască unul despre celălalt prin delegați, inițializatori și, în cazul storyboard-urilor, să se pregătească pentru prepareForSegue
atunci când transmit date și referințe. Fiecare controler de vizualizare se ocupă de propriile conexiuni la modelul sau la controlerele mediatoare și amândoi trimit și primesc actualizări.
De asemenea, vizualizările comunică înapoi cu controlorii de vizualizare prin delegați. Deși acest lucru funcționează, înseamnă că trebuie să facem o mulțime de pași pe care trebuie să-i facem pentru a transmite datele și întotdeauna mă trezesc să refactorizez mult în jurul apelurilor înapoi și să verific dacă delegații sunt cu adevărat setași.
Este posibil să spargeți un controler de vizualizare schimbând codul într-un altul, cum ar fi datele învechite din ChatsListViewController
, deoarece ChatViewController
nu mai sună updated(chat: Chat)
. Mai ales în scenariile mai complexe, este o durere să păstrezi totul sincronizat.
Separarea între vedere și model
Prin eliminarea întregului cod legat de vizualizare de la controlerul de vizualizare în customView
-uri și mutarea întregului cod legat de model la controlere specializate, controlerul de vizualizare este destul de slab și separat. Cu toate acestea, mai rămâne o problemă: există un decalaj între ceea ce dorește să afișeze vizualizarea și datele care se află în model. Un exemplu bun este ChatListView
. Ceea ce vrem să afișăm este o listă de celule care ne spun cu cine vorbim, care a fost ultimul mesaj, data ultimului mesaj și câte mesaje necitite au rămas în Chat
:
Totuși, trecem pe lângă un model care nu știe ce vrem să vedem. În schimb, este doar un Chat
cu un contact, care conține mesaje:
class Chat { let contact: String var messages: [Message] init(with contact: String, messages: [Message] = []) { self.contact = contact self.messages = messages } }
Acum este posibil să adăugați rapid un cod suplimentar care ne va primi ultimul mesaj și numărul de mesaje, dar formatarea datelor în șiruri este o sarcină care aparține ferm stratului de vizualizare:
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 }
Deci, în sfârșit, formatăm data în ChatItemTableViewCell
când o afișăm:
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) }
Chiar și într-un exemplu destul de simplu, este destul de clar că există o tensiune între ceea ce are nevoie vederea și ceea ce oferă modelul.
MVVM bazat pe evenimente statice, alias o interpretare bazată pe evenimente statice asupra „modelului ViewModel”
MVVM static funcționează cu modele de vizualizare, dar în loc să creăm trafic bidirecțional prin ele - la fel cum obișnuiam să facem prin controlerul nostru de vizualizare cu MVC - creăm modele de vizualizare imuabile care actualizează interfața de utilizare de fiecare dată când interfața de utilizare trebuie să se schimbe ca răspuns la un eveniment .
Un eveniment poate fi declanșat de aproape orice parte a codului, atâta timp cât este capabil să furnizeze datele asociate cerute de enum
evenimentului. De exemplu, primirea evenimentului received(new: Message)
poate fi declanșată de o notificare push, WebSocket sau un apel obișnuit de rețea.
Să vedem într-o diagramă:
La prima vedere, pare a fi ceva mai complex decât exemplul clasic MVC, deoarece sunt mult mai multe clase implicate pentru a realiza exact același lucru. Dar la o inspecție mai atentă, niciuna dintre relații nu mai este bidirecțională.
Și mai important este că fiecare actualizare a interfeței de utilizare este declanșată de un eveniment, așa că există o singură rută prin aplicație pentru tot ceea ce se întâmplă. Este imediat clar la ce evenimente vă puteți aștepta. De asemenea, este clar unde ar trebui să adăugați unul nou, dacă este necesar, sau să adăugați un comportament nou atunci când răspundeți la evenimentele existente.
După refactorizare, am ajuns să am o mulțime de clase noi, așa cum am arătat mai sus. Puteți găsi implementarea mea a versiunii statice MVVM pe GitHub. Cu toate acestea, când compar modificările cu instrumentul cloc
, devine clar că de fapt nu există deloc atât de mult cod suplimentar:
Model | Fișiere | Gol | cometariu | Cod |
---|---|---|---|---|
MVC | 30 | 386 | 217 | 1807 |
MVVM | 51 | 442 | 359 | 1981 |
Există doar o creștere de 9 la sută a liniilor de cod. Mai important, dimensiunea medie a acestor fișiere a scăzut de la 60 de linii de cod la doar 39.
De asemenea, este esențial că cele mai mari scăderi pot fi găsite în fișierele care sunt de obicei cele mai mari din MVC: vizualizările și controlerele de vizualizare. Vizualizările sunt doar 74% din dimensiunile lor originale, iar controlerele de vizualizare sunt acum doar 53% din dimensiunea lor originală.
De asemenea, trebuie remarcat faptul că o mare parte din codul suplimentar este cod de bibliotecă care ajută la atașarea blocurilor la butoane și alte obiecte din arborele vizual, fără a necesita modelele MVC @IBAction
sau delegate clasice.
Să explorăm diferitele straturi ale acestui design unul câte unul.
Eveniment
Evenimentul este întotdeauna o enum
, de obicei cu valori asociate. Adesea se vor suprapune cu una dintre entitățile din modelul dvs., dar nu neapărat. În acest caz, aplicația este împărțită în două enum
de evenimente principale: ChatEvent
și MessageEvent
. ChatEvent
este pentru toate actualizările despre obiectele de chat în sine:
enum ChatEvent { case started case loaded(chats: [Chat]) case creating(chat: Chat) case created(chat: Chat) case createChatFailed(reason: String) }
Celălalt se ocupă de toate evenimentele legate de mesaje:
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) }
Este important să vă limitați enum
*Event
la o dimensiune rezonabilă. Dacă aveți nevoie de 10 sau mai multe cazuri, acesta este de obicei un semn că încercați să acoperiți mai mult de un subiect.
Notă: Conceptul de enum
este extrem de puternic în Swift. Tind să folosesc foarte mult enum
-urile cu valori asociate, deoarece pot elimina multă ambiguitate pe care altfel le-ați avea cu valori opționale.
Tutorial Swift MVVM: Router de evenimente
Routerul de evenimente este punctul de intrare pentru fiecare eveniment care are loc în aplicație. Orice clasă care poate furniza valoarea asociată poate crea un eveniment și îl poate trimite la ruterul de evenimente. Deci pot fi declanșate de orice fel de sursă, de exemplu:
- Utilizatorul trece la un anumit controler de vizualizare
- Utilizatorul care atinge un anumit buton
- Începe aplicația
- Evenimente externe precum:
- O solicitare de rețea care revine cu o eroare sau date noi
- Notificări
- Mesaje WebSocket
Routerul de evenimente ar trebui să știe cât mai puțin posibil despre sursa evenimentului și, de preferință, nimic. Niciunul dintre evenimentele din această aplicație exemplu nu are vreun indicator de unde provin, așa că este foarte ușor de amestecat în orice fel de sursă de mesaj. De exemplu, WebSocket declanșează același eveniment - received(message: Message, contact: String)
- ca o nouă notificare push.

Evenimentele sunt (ați ghicit deja) direcționate către clasele care trebuie să proceseze în continuare aceste evenimente. De obicei, singurele clase care sunt apelate sunt stratul model (dacă datele trebuie adăugate, modificate sau eliminate) și handlerul de evenimente. Voi discuta pe ambele un pic mai departe, dar principala caracteristică a ruterului de evenimente este oferirea unui singur punct de acces ușor la toate evenimentele și redirecționarea lucrărilor către alte clase. Iată ChatEventRouter
ca exemplu:
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) } } }
Se întâmplă destul de puțin aici: singurul lucru pe care îl facem este să actualizăm modelul și să redirecționăm evenimentul către ChatEventHandler
, astfel încât interfața de utilizare să fie actualizată.
Tutorial Swift MVVM: Model Controller
Aceasta este exact aceeași clasă pe care o folosim în MVC, deoarece funcționa destul de bine deja. Reprezintă starea aplicației și, de obicei, ar fi susținut de Core Data sau de o bibliotecă de stocare locală.
Straturile de model – dacă sunt implementate corect în MVC – foarte rar au nevoie de refactorizare pentru a se potrivi cu diferite modele. Cea mai mare schimbare este că schimbarea modelului are loc de la mai puține clase, ceea ce face puțin mai clar unde au loc schimbările.
Într-o variantă alternativă a acestui model, puteți observa modificările aduse modelului și vă asigurați că sunt tratate. În acest caz, am ales să las pur și simplu doar *EventRouter
și *Endpoint
să schimbe modelul, astfel încât există o responsabilitate clară a locului și când modelul este actualizat. În schimb, dacă am observa modificări, ar trebui să scriem cod suplimentar pentru a propaga evenimente care nu modifică modelul, cum ar fi erori, prin ChatEventHandler
, ceea ce ar face mai puțin evident modul în care evenimentele decurg prin aplicație.
Tutorial Swift MVVM: handler de evenimente
Managerul de evenimente este locul în care vizualizările sau controlorii de vizualizare se pot înregistra (și anula înregistrarea) ca ascultători pentru a primi modele de vizualizare actualizate, care sunt construite ori de câte ori ChatEventRouter
apelează o funcție pe ChatEventHandler
.
Puteți vedea că reflectă aproximativ toate stările de vizualizare pe care le-am folosit înainte în MVC. Dacă doriți alte tipuri de actualizări ale UI, cum ar fi sunetul sau declanșarea motorului Taptic, acestea se pot face și de aici.
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) } } }
Această clasă nu face altceva decât să se asigure că ascultătorul potrivit poate obține modelul de vizualizare potrivit ori de câte ori are loc un anumit eveniment. Ascultătorii noi pot obține un model de vizualizare imediat când sunt adăugați, dacă este necesar pentru a-și configura starea inițială. Asigurați-vă întotdeauna că adăugați o referință weak
la listă pentru a preveni ciclurile de retenție.
Tutorial Swift MVVM: Vedeți modelul
Iată una dintre cele mai mari diferențe între ceea ce fac multe modele MVVM față de ceea ce face varianta statică. În acest caz, modelul de vedere este imuabil în loc să se configureze ca un intermediar permanent în două sensuri între model și vedere. De ce am face asta? Să ne oprim pentru a explica un moment.
Unul dintre cele mai importante aspecte ale creării unei aplicații care funcționează bine în toate cazurile posibile este să vă asigurați că starea aplicației este corectă. Dacă interfața de utilizare nu se potrivește cu modelul sau are date învechite, tot ceea ce facem ar putea duce la salvarea datelor eronate sau la blocarea aplicației sau la un comportament neașteptat.
Unul dintre scopurile aplicării acestui model este că nu avem nicio stare în aplicație decât dacă este absolut necesar. Ce este starea, mai exact? Statul este practic fiecare loc în care stocăm o reprezentare a unui anumit tip de date. Un tip special de stare este starea în care se află în prezent interfața dvs. de utilizare, pe care, desigur, nu o putem preveni cu o aplicație bazată pe interfață. Celelalte tipuri de stat sunt toate legate de date. Dacă avem o copie a unei matrice de Chat
care face o copie de rezervă a UITableView
-ului nostru în ecranul Lista de chat, acesta este un exemplu de stare duplicat. Un model tradițional de vizualizare în două sensuri ar fi un alt exemplu de duplicare a Chat
-urilor utilizatorului nostru.
Prin trecerea unui model de vizualizare imuabil care este reîmprospătat la fiecare schimbare de model, eliminăm acest tip de stare duplicat, deoarece după ce se aplică interfeței de utilizare, nu mai este folosit. Apoi avem doar singurele două tipuri de stare pe care nu le putem evita — UI și model — și sunt perfect sincronizate între ele.
Deci modelul de vizualizare de aici este destul de diferit de unele aplicații MVVM. Acesta servește doar ca un depozit de date imuabil pentru toate steaguri, valori, blocuri și alte valori pe care vizualizarea le necesită pentru a reflecta starea modelului, dar nu poate fi actualizat în niciun fel de către View.
Prin urmare, poate fi o simplă struct
imuabilă. Pentru a menține această struct
cât mai simplă posibil, o vom instanția cu un generator de modele de vizualizare. Unul dintre lucrurile interesante despre un model de vizualizare este că primește semnalizatoare comportamentale precum shouldShowBusy
și shouldShowError
care înlocuiesc mecanismul de enum
a stării găsit anterior în vizualizare. Iată datele pentru ChatItemTableViewCell
pe care le-am analizat înainte:
struct ChatListItemViewModel { let contact: String let message: String let lastMessageDate: String let unreadMessageCount: Int let itemTapped: () -> Void }
Deoarece generatorul de modele de vizualizare are deja grijă de valorile și acțiunile exacte de care are nevoie vizualizarea, toate datele sunt preformatate. De asemenea, nou este un bloc care va fi declanșat odată ce un element este atins. Să vedem cum este realizat de către generatorul de modele de vizualizare.
Vizualizare Model Builder
Generatorul de modele de vizualizare poate construi exemple de modele de vizualizare, transformând intrările precum Chat
-uri sau Message
în modele de vizualizare care sunt perfect adaptate pentru o anumită vizualizare. Unul dintre cele mai importante lucruri care se întâmplă în generatorul de modele de vizualizare este determinarea a ceea ce se întâmplă de fapt în interiorul blocurilor din modelul de vizualizare. Blocurile atașate de generatorul de modele de vizualizare ar trebui să fie extrem de scurte, apelând funcțiile altor părți ale arhitecturii cât mai curând posibil. Astfel de blocuri nu ar trebui să aibă nicio logică de afaceri.
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) } }
Acum toată preformatarea are loc în același loc, iar comportamentul se decide și aici. Este o clasă destul de importantă în această ierarhie și poate fi interesant să vedem cum au fost implementați diferiții constructori din aplicația demo și se confruntă cu scenarii mai complicate.
Tutorial Swift MVVM: View Controller
Controlerul de vizualizare din această arhitectură face foarte puțin. Va instala și va dărâma tot ceea ce are legătură cu vederea sa. Este cel mai potrivit să faceți acest lucru, deoarece primește toate apelurile ciclului de viață care sunt necesare pentru a adăuga și elimina ascultători la momentul potrivit.
Uneori trebuie să actualizeze un element UI care nu este acoperit de vizualizarea rădăcină, cum ar fi titlul sau un buton din bara de navigare. De aceea, de obicei, încă înregistrez controlerul de vizualizare ca ascultător la ruterul de evenimente dacă am un model de vizualizare care acoperă întreaga vizualizare pentru controlerul de vizualizare dat; Transmit modelul de vizualizare către vizualizare după aceea. Dar este, de asemenea, bine să înregistrați orice UIView
ca ascultător direct dacă există o parte a ecranului care are o rată de actualizare diferită, de exemplu, un ticker de stoc în timp real în partea de sus a unei pagini despre o anumită companie.
Codul pentru ChatsViewController
este acum atât de scurt încât durează mai puțin de o pagină. Ceea ce a mai rămas este suprascrierea vizualizării de bază, adăugarea și eliminarea butonului de adăugare din bara de navigare, setarea titlului, adăugarea lui însuși ca ascultător și implementarea protocolului 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) } }
Nu a mai rămas nimic care să poată fi făcut în altă parte, deoarece ChatsViewController
este redus la minimum.
Tutorial Swift MVVM: Vizualizare
Vizualizarea în arhitectura imuabilă MVVM poate fi încă destul de grea, deoarece are încă o listă de sarcini, dar am reușit să o dezlipesc de următoarele responsabilități în comparație cu arhitectura MVC:
- Determinarea a ceea ce trebuie schimbat ca răspuns la o nouă stare
- Implementarea delegatilor si functiilor pentru actiuni
- Gestionați declanșatoarele de la vizualizare la vizualizare, cum ar fi gesturile și animațiile declanșate
- Transformarea datelor în așa fel încât să poată fi afișate (cum ar fi
Date
s înString
s)
Mai ales ultimul punct are un avantaj destul de mare. În MVC, atunci când controlerul de vizualizare sau vizualizare este responsabil pentru transformarea datelor pentru afișare, va face întotdeauna acest lucru pe firul principal, deoarece este foarte greu să se separe modificările adevărate ale interfeței de utilizare care trebuie să se întâmple în acest fir de lucruri care sunt nu este obligat să ruleze pe el. Și rularea unui cod fără modificare a UI pe firul principal poate duce la o aplicație mai puțin receptivă.
În schimb, cu acest model MVVM, totul, de la blocul care este declanșat de o atingere până în momentul în care modelul de vizualizare este construit și va fi transmis ascultătorului - putem rula toate acestea pe un fir separat și doar să pătrundem în firul principal din final pentru a face actualizări de UI. Dacă aplicația noastră petrece mai puțin timp pe firul principal, va funcționa mai ușor.
Odată ce modelul de vedere aplică noua stare vederii, este permis să se evapore în loc să zăbovească ca un alt strat de stare. Tot ceea ce ar putea declanșa un eveniment este atașat unui element din vizualizare și nu vom comunica înapoi modelului de vizualizare.
Un lucru este important de reținut: nu sunteți forțat să mapați un model de vedere printr-un controler de vizualizare la o vedere. După cum am menționat anterior, părți din vizualizare pot fi gestionate de alte modele de vizualizare, mai ales când ratele de actualizare variază. Luați în considerare o foaie de calcul Google editată de diferite persoane, păstrând în același timp un panou de chat deschis pentru colaboratori - nu este foarte util să reîmprospătați documentul ori de câte ori sosește un mesaj de chat.
Un exemplu binecunoscut este o implementare tip pentru a găsi în care caseta de căutare este actualizată cu rezultate mai precise pe măsură ce introducem mai mult text. Acesta este modul în care aș implementa completarea automată în clasa CreateAutocompleteView
: întregul ecran este deservit de CreateViewModel
, dar caseta de text ascultă în schimb AutocompleteContactViewModel
.
Un alt exemplu este folosirea unui validator de formulare, care poate fi fie construit ca o „buclă locală” (atașarea sau eliminarea stărilor de eroare la câmpuri și declararea unui formular ca fiind valid) sau realizat prin declanșarea unui eveniment.
Modelele statice cu vizualizare imuabilă oferă o mai bună separare
Folosind o implementare statică MVVM, am reușit să separăm în sfârșit toate straturile, deoarece modelul de vizualizare face acum punte între model și vedere. De asemenea, am facilitat gestionarea evenimentelor care nu au fost cauzate de acțiunile utilizatorului și am eliminat multe dependențe dintre diferitele părți ale aplicației noastre. Singurul lucru pe care îl face un controler de vizualizare este să se înregistreze (și să se anuleze) la gestionatorii de evenimente ca ascultător pentru evenimentele pe care dorește să le primească.
Beneficii:
- Implementările controlerului de vizualizare și vizualizare tind să fie mult mai ușoare
- Clasele sunt mai specializate și mai separate
- Evenimentele pot fi declanșate cu ușurință din orice loc
- Evenimentele urmează o cale previzibilă prin sistem
- Starea este actualizată doar dintr-un singur loc
- 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!