Praca z wzorcami statycznymi: samouczek Swift MVVM

Opublikowany: 2022-03-11

Dzisiaj zobaczymy, jak nowe możliwości techniczne i oczekiwania naszych użytkowników dotyczące aplikacji opartych na danych w czasie rzeczywistym stwarzają nowe wyzwania w sposobie strukturyzacji naszych programów, zwłaszcza aplikacji mobilnych. Chociaż ten artykuł dotyczy systemów iOS i Swift, wiele wzorców i wniosków można w równym stopniu zastosować do aplikacji na Androida i aplikacji internetowych.

W ciągu ostatnich kilku lat nastąpiła ważna ewolucja w działaniu nowoczesnych aplikacji mobilnych. Dzięki bardziej wszechobecnemu dostępowi do Internetu i technologiom, takim jak powiadomienia push i WebSockets, użytkownik zwykle nie jest już jedynym źródłem zdarzeń w czasie wykonywania — i niekoniecznie najważniejszym — w wielu dzisiejszych aplikacjach mobilnych.

Przyjrzyjmy się bliżej, jak dobrze każdy z dwóch wzorców projektowych Swift współpracuje z nowoczesną aplikacją do czatu: klasyczny wzorzec model-widok-kontroler (MVC) i uproszczony, niezmienny wzorzec model-widok-widok (MVVM, czasami stylizowany jako „wzorzec ViewModel ”). Dobrym przykładem są aplikacje czatu, ponieważ mają wiele źródeł danych i muszą aktualizować swoje interfejsy użytkownika na wiele różnych sposobów po otrzymaniu danych.

Nasza aplikacja do czatu

Aplikacja, której będziemy używać jako wytyczne w tym samouczku Swift MVVM, będzie miała większość podstawowych funkcji, które znamy z aplikacji do czatu, takich jak WhatsApp. Przejdźmy do funkcji, które zaimplementujemy i porównajmy MVVM z MVC. Aplikacja:

  • Załaduje poprzednio otrzymane czaty z dysku
  • Zsynchronizuje istniejące czaty przez żądanie GET z serwerem
  • Będzie otrzymywać powiadomienia push, gdy do użytkownika zostanie wysłana nowa wiadomość
  • Zostanie połączony z WebSocketem, gdy pojawimy się na ekranie czatu
  • Czy można POST nową wiadomość na czacie?
  • Pokaże powiadomienie w aplikacji, gdy zostanie odebrana nowa wiadomość z czatu, w którym aktualnie nie jesteśmy
  • Pokaże nową wiadomość natychmiast, gdy otrzymamy nową wiadomość dla bieżącego czatu
  • Wyśle przeczytaną wiadomość, gdy przeczytamy nieprzeczytaną wiadomość
  • Otrzyma wiadomość o przeczytaniu, gdy ktoś przeczyta naszą wiadomość
  • Aktualizuje odznakę licznika nieprzeczytanych wiadomości na ikonie aplikacji
  • Synchronizuje wszystkie otrzymane lub zmienione wiadomości z powrotem do danych podstawowych

W tej aplikacji demonstracyjnej nie będzie prawdziwej implementacji API, WebSocket ani Core Data, aby implementacja modelu była nieco prostsza. Zamiast tego dodałem chatbota, który zacznie Ci odpowiadać po rozpoczęciu rozmowy. Jednak wszystkie inne routingi i wywołania są implementowane tak, jak gdyby pamięć i połączenia były rzeczywiste, w tym małe asynchroniczne przerwy przed powrotem.

Zbudowano następujące trzy ekrany:

Ekrany Lista czatów, Utwórz czat i Wiadomości.

Klasyczny MVC

Przede wszystkim istnieje standardowy wzorzec MVC do budowania aplikacji na iOS. W ten sposób Apple strukturuje cały kod dokumentacji oraz sposób, w jaki interfejsy API i elementy interfejsu użytkownika mają działać. Tego uczy się większość ludzi, biorąc udział w kursie iOS.

Często MVC jest obwiniany za doprowadzenie do rozdętych UIViewController składających się z kilku tysięcy wierszy kodu. Ale jeśli zastosuje się to dobrze, z dobrą separacją między poszczególnymi warstwami, możemy mieć całkiem smukłe ViewController , które działają tylko jako pośrednicy menedżerowie między View , Model i innymi Controller .

Oto schemat blokowy implementacji MVC aplikacji (pomijając CreateViewController dla jasności):

Schemat blokowy implementacji MVC, pomijając CreateViewController dla przejrzystości.

Przyjrzyjmy się szczegółowo warstwom.

Model

Warstwa modelowa jest zwykle najmniej problematyczną warstwą w MVC. W tym przypadku zdecydowałem się użyć ChatWebSocket , ChatModel i PushNotificationController do pośredniczenia między obiektami Chat i Message , zewnętrznymi źródłami danych i resztą aplikacji. ChatModel jest źródłem prawdy w aplikacji i działa tylko w pamięci w tej aplikacji demonstracyjnej. W rzeczywistej aplikacji prawdopodobnie byłby wspierany przez Core Data. Wreszcie ChatEndpoint obsługuje wszystkie wywołania HTTP.

Pogląd

Widoki są dość duże, ponieważ musi obsługiwać wiele obowiązków, ponieważ starannie oddzieliłem cały kod widoku od UIViewController s. Zrobiłem co następuje:

  • Użyto (bardzo zalecanego) wzorca enum stanu, aby zdefiniować, w jakim stanie aktualnie znajduje się widok.
  • Dodano funkcje, które są podłączane do przycisków i innych elementów interfejsu wyzwalających działania (takich jak stuknięcie Return podczas wpisywania nazwy kontaktu).
  • Skonfiguruj ograniczenia i za każdym razem oddzwoń do pełnomocnika.

Po wrzuceniu UITableView do miksu widoki są teraz znacznie większe niż UIViewController s, co prowadzi do niepokojących 300+ wierszy kodu i wielu mieszanych zadań w ChatView .

Kontroler

Ponieważ cała logika obsługi modelu została przeniesiona do ChatModel . Cały kod widoku — który może czaić się tutaj w mniej optymalnych, oddzielnych projektach — znajduje się teraz w widoku, więc UIViewController są dość wąskie. Kontroler widoku jest całkowicie nieświadomy tego, jak wyglądają dane modelu, jak są pobierane ani jak powinny być wyświetlane — po prostu koordynuje. W przykładowym projekcie żaden z UIViewController s nie przekracza 150 wierszy kodu.

Jednak ViewController nadal wykonuje następujące czynności:

  • Pełnienie funkcji delegata dla widoku i innych kontrolerów widoku
  • Tworzenie instancji i wypychanie (lub wyskakiwanie) kontrolerów widoku w razie potrzeby
  • Wysyłanie i odbieranie połączeń do iz ChatModel
  • Uruchamianie i zatrzymywanie WebSocket w zależności od etapu cyklu kontrolera widoku
  • Podejmowanie logicznych decyzji, takich jak niewysyłanie wiadomości, jeśli jest pusta
  • Aktualizacja widoku

To wciąż dużo, ale głównie zajmuje się koordynacją, przetwarzaniem bloków wywołań zwrotnych i przekazywaniem dalej.

Korzyści

  • Ten wzór jest rozumiany przez wszystkich i promowany przez Apple
  • Działa z całą dokumentacją
  • Nie są potrzebne żadne dodatkowe ramy

Wady

  • Kontrolery widoku mają wiele zadań; wiele z nich w zasadzie przekazuje dane tam i z powrotem między widokiem a warstwą modelu
  • Niezbyt przystosowany do obsługi wielu źródeł zdarzeń
  • Zajęcia zazwyczaj dużo wiedzą o innych zajęciach

Definicja problemu

Działa to bardzo dobrze, o ile aplikacja podąża za działaniami użytkownika i reaguje na nie, tak jak można by sobie wyobrazić, że działałaby aplikacja taka jak Adobe Photoshop lub Microsoft Word. Użytkownik podejmuje akcję, aktualizuje interfejs, powtarza się.

Jednak nowoczesne aplikacje są połączone, często na więcej niż jeden sposób. Na przykład wchodzisz w interakcję za pośrednictwem interfejsu API REST, otrzymujesz powiadomienia push, a w niektórych przypadkach łączysz się również z WebSocket.

W związku z tym nagle kontroler widoku musi poradzić sobie z większą liczbą źródeł informacji, a za każdym razem, gdy odbierana jest wiadomość zewnętrzna bez wyzwalania jej przez użytkownika — tak jak w przypadku odbierania wiadomości przez WebSocket — źródła informacji muszą znaleźć drogę powrotną we właściwe miejsce Zobacz kontrolery. To wymaga dużo kodu, aby skleić każdą część razem, aby wykonać to samo zadanie.

Zewnętrzne źródła danych

Przyjrzyjmy się, co się dzieje, gdy otrzymujemy wiadomość 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 } }

Musimy ręcznie przekopać się przez stos kontrolerów widoku, aby dowiedzieć się, czy istnieje kontroler widoku, który musi się zaktualizować po otrzymaniu powiadomienia push. W tym przypadku chcemy również zaktualizować ekrany, które implementują UpdatedChatDelegate , który w tym przypadku jest tylko ChatsViewController . Robimy to również, aby wiedzieć, czy powinniśmy pominąć powiadomienie, ponieważ już patrzymy na Chat , do którego było przeznaczone. W takim przypadku w końcu dostarczamy wiadomość do kontrolera widoku. Jest całkiem jasne, że PushNotificationController musi wiedzieć zbyt dużo o aplikacji, aby móc wykonywać swoją pracę.

Gdyby ChatWebSocket dostarczał wiadomości również do innych części aplikacji, zamiast mieć relację jeden do jednego z ChatViewController , napotkalibyśmy tam ten sam problem.

Oczywiste jest, że za każdym razem, gdy dodajemy kolejne źródło zewnętrzne, musimy pisać dość inwazyjny kod. Ten kod jest również dość kruchy, ponieważ w dużym stopniu opiera się na strukturze aplikacji i deleguje przekazywanie danych z powrotem do hierarchii do pracy.

Delegaci

Wzorzec MVC dodaje również dodatkową złożoność do miksu, gdy dodamy inne kontrolery widoku. Dzieje się tak, ponieważ kontrolery widoku mają tendencję do poznawania się nawzajem za pośrednictwem delegatów, inicjatorów i — w przypadku prepareForSegue podczas przekazywania danych i odwołań. Każdy kontroler widoku obsługuje własne połączenia z modelem lub kontrolerami pośredniczącymi i zarówno wysyłają, jak i odbierają aktualizacje.

Ponadto widoki komunikują się z kontrolerami widoków za pośrednictwem delegatów. Chociaż to działa, oznacza to, że musimy wykonać wiele kroków, aby przekazać dane, a ja zawsze często refaktoryzuję się wokół wywołań zwrotnych i sprawdzam, czy delegaci są naprawdę ustawieni.

Możliwe jest zerwanie jednego kontrolera widoku, zmieniając kod w innym, na przykład przestarzałe dane w ChatsListViewController , ponieważ ChatViewController nie wywołuje już updated(chat: Chat) . Szczególnie w bardziej złożonych scenariuszach trudno jest zachować synchronizację.

Separacja między widokiem a modelem

Usuwając cały kod związany z widokiem z kontrolera widoku do customView s i przenosząc cały kod związany z modelem do wyspecjalizowanych kontrolerów, kontroler widoku jest dość oszczędny i oddzielony. Pozostaje jednak jeszcze jeden problem: istnieje luka między tym, co widok chce wyświetlić, a danymi znajdującymi się w modelu. Dobrym przykładem jest ChatListView . To, co chcemy wyświetlić, to lista komórek, które mówią nam, z kim rozmawiamy, jaka była ostatnia wiadomość, data ostatniej wiadomości i ile nieprzeczytanych wiadomości pozostało na Chat :

Licznik nieprzeczytanych wiadomości na ekranie Czatu.

Mijamy jednak model, który nie wie, co chcemy zobaczyć. Zamiast tego jest to po prostu Chat z kontaktem zawierający wiadomości:

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

Teraz można szybko dodać dodatkowy kod, który da nam ostatnią wiadomość i liczbę wiadomości, ale formatowanie dat do ciągów to zadanie, które zdecydowanie należy do warstwy widoku:

 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 }

W końcu formatujemy datę w ChatItemTableViewCell , kiedy ją wyświetlamy:

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

Nawet w dość prostym przykładzie widać wyraźnie, że istnieje napięcie między tym, czego potrzebuje widok, a tym, co zapewnia model.

MVVM oparty na zdarzeniach statycznych, znany również jako podejście do „wzorca ViewModel” oparte na zdarzeniach statycznych

Statyczny MVVM współpracuje z modelami widoku, ale zamiast tworzyć przez nie ruch dwukierunkowy — podobnie jak w przypadku naszego kontrolera widoku z MVC — tworzymy niezmienne modele widoku, które aktualizują interfejs użytkownika za każdym razem, gdy interfejs użytkownika musi się zmienić w odpowiedzi na zdarzenie .

Zdarzenie może zostać wyzwolone przez prawie każdą część kodu, o ile jest w stanie dostarczyć skojarzone dane wymagane przez enum zdarzenia . Na przykład odebranie zdarzenia received(new: Message) może być wyzwalane przez powiadomienie wypychane, interfejs WebSocket lub zwykłe połączenie sieciowe.

Zobaczmy to na schemacie:

Schemat blokowy implementacji MVVM.

Na pierwszy rzut oka wydaje się to być nieco bardziej złożone niż klasyczny przykład MVC, ponieważ zaangażowanych jest znacznie więcej klas, aby osiągnąć dokładnie to samo. Ale przy bliższym przyjrzeniu się żadna z relacji nie jest już dwukierunkowa.

Jeszcze ważniejsze jest to, że każda aktualizacja interfejsu użytkownika jest wyzwalana przez zdarzenie, więc istnieje tylko jedna trasa przez aplikację dla wszystkiego, co się dzieje. Od razu wiadomo, jakich wydarzeń możesz się spodziewać. Jest również jasne, gdzie należy dodać nowy, jeśli jest to wymagane, lub dodać nowe zachowanie podczas odpowiadania na istniejące zdarzenia.

Po refaktoryzacji uzyskałem wiele nowych klas, jak pokazałem powyżej. Możesz znaleźć moją implementację statycznej wersji MVVM na GitHub. Jednak kiedy porównuję zmiany z narzędziem cloc , staje się jasne, że w rzeczywistości nie ma w ogóle zbyt wiele dodatkowego kodu:

Wzór Akta Pusty Komentarz Kod
MVC 30 386 217 1807
MVVM 51 442 359 1981

Liczba wierszy kodu wzrosła tylko o 9 procent. Co ważniejsze, średni rozmiar tych plików spadł z 60 wierszy kodu do zaledwie 39.

Wykresy kołowe z wierszami kodu. Wyświetl kontrolery: MVC 287 vs MVVM 154 lub 47% mniej; Wyświetlenia: MVC 523 vs MVVM 392 lub 26% mniej.

Co najważniejsze, największe spadki można znaleźć w plikach, które zazwyczaj są największe w MVC: widokach i kontrolerach widoków. Widoki mają tylko 74 procent ich oryginalnych rozmiarów, a kontrolery widoków mają teraz tylko 53 procent ich oryginalnego rozmiaru.

Należy również zauważyć, że wiele dodatkowego kodu to kod biblioteczny, który pomaga dołączać bloki do przycisków i innych obiektów w drzewie wizualnym, bez konieczności stosowania klasycznych @IBAction lub delegatów MVC.

Przyjrzyjmy się kolejno różnym warstwom tego projektu.

Wydarzenie

Zdarzeniem jest zawsze enum , zwykle z powiązanymi wartościami. Często będą się pokrywać z jednym z elementów modelu, ale niekoniecznie. W tym przypadku aplikacja jest podzielona na dwa główne enum zdarzeń: ChatEvent i MessageEvent . ChatEvent jest przeznaczony dla wszystkich aktualizacji samych obiektów czatu:

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

Drugi zajmuje się wszystkimi zdarzeniami związanymi z wiadomościami:

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

Ważne jest, aby ograniczyć enum *Event do rozsądnego rozmiaru. Jeśli potrzebujesz 10 lub więcej spraw, zwykle jest to znak, że próbujesz objąć więcej niż jeden temat.

Uwaga: koncepcja enum jest niezwykle potężna w Swift. Często używam enum z powiązanymi wartościami, ponieważ mogą one wyeliminować wiele niejednoznaczności, które w przeciwnym razie mielibyście z wartościami opcjonalnymi.

Samouczek dotyczący Swift MVVM: router zdarzeń

Router zdarzeń jest punktem wejścia dla każdego zdarzenia, które ma miejsce w aplikacji. Każda klasa, która może dostarczyć skojarzoną wartość, może utworzyć zdarzenie i wysłać je do routera zdarzeń. Mogą być więc wyzwalane z dowolnego źródła, np.:

  • Użytkownik przechodzący do określonego kontrolera widoku
  • Użytkownik dotyka określonego przycisku
  • Uruchamianie aplikacji
  • Wydarzenia zewnętrzne, takie jak:
    • Żądanie sieciowe powracające z błędem lub nowymi danymi
    • Powiadomienia push
    • Wiadomości WebSocket

Router zdarzeń powinien wiedzieć jak najmniej o źródle zdarzenia, a najlepiej w ogóle nic. Żadne ze zdarzeń w tej przykładowej aplikacji nie ma żadnego wskaźnika, skąd pochodzą, więc bardzo łatwo jest mieszać dowolne źródło wiadomości. Na przykład WebSocket wyzwala to samo zdarzenie — received(message: Message, contact: String) — jako nowe powiadomienie push.

Zdarzenia są (już zgadłeś) kierowane do klas, które muszą je dalej przetworzyć. Zwykle jedyne klasy, które są wywoływane, to warstwa modelu (jeśli dane muszą zostać dodane, zmienione lub usunięte) i procedura obsługi zdarzeń. Omówię je nieco dalej, ale główną cechą routera zdarzeń jest zapewnienie jednego łatwego punktu dostępu do wszystkich zdarzeń i przekazywanie pracy innym klasom. Oto przykład ChatEventRouter :

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

Tutaj niewiele się dzieje: jedyne, co robimy, to aktualizowanie modelu i przekazywanie zdarzenia do ChatEventHandler , aby interfejs użytkownika został zaktualizowany.

Samouczek Swift MVVM: Kontroler modelu

Jest to dokładnie ta sama klasa, której używamy w MVC, ponieważ działała już całkiem dobrze. Reprezentuje stan aplikacji i zwykle jest wspierany przez dane podstawowe lub lokalną bibliotekę magazynu.

Warstwy modelu — jeśli są poprawnie zaimplementowane w MVC — bardzo rzadko wymagają refaktoryzacji w celu dopasowania do różnych wzorców. Największą zmianą jest to, że zmiana modelu odbywa się z mniejszej liczby klas, dzięki czemu jest nieco bardziej jasne, gdzie zachodzą zmiany.

W alternatywnym podejściu do tego wzorca możesz obserwować zmiany w modelu i upewnić się, że zostaną one obsłużone. W tym przypadku zdecydowałem się po prostu pozwolić, aby tylko *EventRouter i *Endpoint zmieniały model, więc istnieje wyraźna odpowiedzialność za to, gdzie i kiedy model zostanie zaktualizowany. W przeciwieństwie do tego, gdybyśmy obserwowali zmiany, musielibyśmy napisać dodatkowy kod, aby propagować zdarzenia niezmieniające model, takie jak błędy, za pośrednictwem ChatEventHandler , co uczyniłoby mniej oczywistym przepływ zdarzeń przez aplikację.

Samouczek Swift MVVM: Obsługa zdarzeń

Program obsługi zdarzeń to miejsce, w którym widoki lub kontrolery widoków mogą rejestrować się (i wyrejestrowywać) się jako odbiorniki, aby otrzymywać zaktualizowane modele widoków, które są budowane za każdym razem, gdy ChatEventRouter wywołuje funkcję w ChatEventHandler .

Widać, że z grubsza odzwierciedla wszystkie stany widoku, których używaliśmy wcześniej w MVC. Jeśli chcesz inne rodzaje aktualizacji interfejsu użytkownika — takie jak dźwięk lub uruchamianie silnika Taptic — można je również wykonać tutaj.

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

Ta klasa nie robi nic poza upewnieniem się, że właściwy słuchacz może uzyskać właściwy model widoku za każdym razem, gdy wydarzy się określone zdarzenie. Nowi słuchacze mogą uzyskać model widoku natychmiast po dodaniu, jeśli jest to konieczne do skonfigurowania ich stanu początkowego. Zawsze upewnij się, że dodajesz weak odniesienie do listy, aby zapobiec cyklom przechowywania.

Samouczek Swift MVVM: Zobacz model

Oto jedna z największych różnic między tym, co robi wiele wzorców MVVM, a tym, co robi wariant statyczny. W takim przypadku model widoku jest niezmienny, zamiast ustawiać się jako trwały, dwukierunkowy element pośredni między modelem a widokiem. Dlaczego mielibyśmy to zrobić? Zatrzymajmy się na chwilę, aby to wyjaśnić.

Jednym z najważniejszych aspektów tworzenia aplikacji, która działa dobrze we wszystkich możliwych przypadkach, jest upewnienie się, że stan aplikacji jest poprawny. Jeśli interfejs użytkownika nie jest zgodny z modelem lub zawiera nieaktualne dane, wszystko, co robimy, może prowadzić do zapisania błędnych danych lub awarii lub nieoczekiwanego działania aplikacji.

Jednym z celów zastosowania tego wzorca jest to, że nie mamy stanu w aplikacji, chyba że jest to absolutnie konieczne. Czym dokładnie jest państwo? Stan to w zasadzie każde miejsce, w którym przechowujemy reprezentację określonego typu danych. Jednym ze specjalnych typów stanu jest stan, w którym aktualnie znajduje się interfejs użytkownika, któremu oczywiście nie możemy zapobiec za pomocą aplikacji opartej na interfejsie użytkownika. Pozostałe typy stanów są powiązane z danymi. Jeśli mamy kopię tablicy Chat , która tworzy kopię zapasową naszego UITableView na ekranie Listy czatów, jest to przykład stanu zduplikowanego. Tradycyjny dwukierunkowy model widoku byłby kolejnym przykładem duplikatu Chat naszego użytkownika.

Przekazując niezmienny model widoku, który jest odświeżany przy każdej zmianie modelu, eliminujemy ten rodzaj duplikatu stanu, ponieważ po zastosowaniu się do interfejsu użytkownika nie jest już używany. Wtedy mamy tylko dwa rodzaje stanów, których nie możemy uniknąć — interfejs użytkownika i model — i są one ze sobą doskonale zsynchronizowane.

Tak więc model widoku tutaj różni się od niektórych aplikacji MVVM. Służy tylko jako niezmienny magazyn danych dla wszystkich flag, wartości, bloków i innych wartości, których widok wymaga, aby odzwierciedlić stan modelu, ale nie może być w żaden sposób aktualizowany przez Widok.

Dlatego może to być prosta, niezmienna struct . Aby zachować tę struct tak prostą, jak to tylko możliwe, utworzymy jej instancję za pomocą konstruktora modeli widoku. Jedną z interesujących rzeczy w modelu widoku jest to, że otrzymuje on flagi behawioralne, takie jak shouldShowBusy i shouldShowError , które zastępują mechanizm enum stanu wcześniej znaleziony w widoku. Oto dane dla ChatItemTableViewCell , które analizowaliśmy wcześniej:

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

Ponieważ konstruktor modelu widoku już dba o dokładne wartości i działania, których potrzebuje widok, wszystkie dane są wstępnie sformatowane. Nowością jest również blok, który zostanie uruchomiony po dotknięciu elementu. Zobaczmy, jak to się robi przez konstruktora modeli widoku.

Zobacz konstruktora modeli

Konstruktor modeli widoków może budować instancje modeli widoków, przekształcając dane wejściowe, takie jak Chat lub Message , w modele widoków, które są idealnie dopasowane do określonego widoku. Jedną z najważniejszych rzeczy, która dzieje się w konstruktorze modelu widoku, jest określenie, co faktycznie dzieje się wewnątrz bloków w modelu widoku. Bloki dołączane przez konstruktora modeli widoków powinny być niezwykle krótkie, tak szybko, jak to możliwe, wywołując funkcje innych części architektury. Takie bloki nie powinny mieć żadnej logiki biznesowej.

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

Teraz całe wstępne formatowanie odbywa się w tym samym miejscu i tutaj również ustalane jest zachowanie. Jest to dość ważna klasa w tej hierarchii i może być interesujące zobaczyć, jak zaimplementowano różne konstruktory w aplikacji demonstracyjnej i radzić sobie z bardziej skomplikowanymi scenariuszami.

Samouczek Swift MVVM: Wyświetl kontroler

Kontroler widoku w tej architekturze robi bardzo niewiele. Ustawi i zburzy wszystko, co jest związane z jego widokiem. Najlepiej jest to zrobić, ponieważ otrzymuje wszystkie wywołania zwrotne cyklu życia, które są wymagane do dodawania i usuwania detektorów we właściwym czasie.

Czasami trzeba zaktualizować element interfejsu użytkownika, który nie jest objęty widokiem głównym, taki jak tytuł lub przycisk na pasku nawigacyjnym. Dlatego zwykle nadal rejestruję kontroler widoku jako odbiornik routera zdarzeń, jeśli mam model widoku, który obejmuje cały widok dla danego kontrolera widoku; Następnie przesyłam model widoku do widoku. Ale dobrze jest również zarejestrować dowolny UIView jako odbiornik bezpośrednio, jeśli część ekranu ma inną częstotliwość aktualizacji, np. pasek giełdowy na górze strony o określonej firmie.

Kod dla ChatsViewController jest teraz tak krótki, że zajmuje mniej niż stronę. Pozostało tylko zastąpić widok bazowy, dodać i usunąć przycisk dodawania z paska nawigacyjnego, ustawić tytuł, dodać siebie jako słuchacza i zaimplementować protokół 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) } }

Nie pozostało już nic, co można zrobić gdzie indziej, ponieważ ChatsViewController jest okrojony do absolutnego minimum.

Samouczek Swift MVVM: Widok

Widok w niezmiennej architekturze MVVM może nadal być dość ciężki, ponieważ wciąż ma listę zadań, ale udało mi się zdjąć z niej następujące obowiązki w porównaniu z architekturą MVC:

  • Ustalenie, co musi się zmienić w odpowiedzi na nowy stan
  • Wdrażanie delegatów i funkcji do działań
  • Obsługuj wyzwalacze od widoku do widoku, takie jak gesty i wyzwalane animacje
  • Przekształcanie danych w taki sposób, aby można je było pokazać (np. Date s na String s)

Szczególnie ostatni punkt ma dość dużą przewagę. W MVC, gdy widok lub kontroler widoku jest odpowiedzialny za przekształcanie danych do wyświetlenia, zawsze będzie to robił w głównym wątku, ponieważ bardzo trudno jest oddzielić prawdziwe zmiany w interfejsie użytkownika, które są wymagane w tym wątku, od rzeczy, które są nie jest wymagane, aby na nim biegać. A posiadanie kodu bez zmiany interfejsu użytkownika działającego w głównym wątku może prowadzić do mniej responsywnej aplikacji.

Zamiast tego, dzięki temu wzorcowi MVVM, wszystko od bloku, który jest wyzwalany przez dotknięcie, aż do momentu zbudowania modelu widoku i przekazania go do słuchacza — możemy uruchomić to wszystko w osobnym wątku i zanurzyć się tylko w głównym wątku w koniec za robienie aktualizacji interfejsu użytkownika. Jeśli nasza aplikacja spędza mniej czasu na głównym wątku, będzie działać płynniej.

Gdy model widoku zastosuje nowy stan do widoku, może on wyparować zamiast pozostawać jako kolejna warstwa stanu. Wszystko, co może wywołać zdarzenie, jest dołączone do elementu w widoku i nie będziemy komunikować się z modelem widoku.

Jedna rzecz jest ważna do zapamiętania: nie musisz mapować modelu widoku za pomocą kontrolera widoku do widoku. Jak wspomniano wcześniej, częściami widoku mogą zarządzać inne modele widoków, zwłaszcza gdy częstotliwość aktualizacji jest różna. Weź pod uwagę, że arkusz Google jest edytowany przez różne osoby, a okienko czatu jest otwarte dla współpracowników — odświeżanie dokumentu po otrzymaniu wiadomości na czacie nie jest zbyt przydatne.

Dobrze znanym przykładem jest implementacja typu „typ-to-find”, w której pole wyszukiwania jest aktualizowane o dokładniejsze wyniki, gdy wprowadzamy więcej tekstu. W ten sposób zaimplementowałbym autouzupełnianie w klasie CreateAutocompleteView : Cały ekran jest obsługiwany przez CreateViewModel ale zamiast tego pole tekstowe nasłuchuje AutocompleteContactViewModel .

Innym przykładem jest użycie walidatora formularzy, który można zbudować jako „pętlę lokalną” (dołączanie lub usuwanie stanów błędów do pól i deklarowanie poprawności formularza) lub poprzez wyzwolenie zdarzenia.

Niezmienne statyczne modele widoku zapewniają lepszą separację

Korzystając ze statycznej implementacji MVVM, udało nam się w końcu całkowicie oddzielić wszystkie warstwy, ponieważ model widoku łączy się teraz między modelem a widokiem. Ułatwiliśmy również zarządzanie zdarzeniami, które nie były spowodowane działaniami użytkownika i usunęliśmy wiele zależności między różnymi częściami naszej aplikacji. Jedyną rzeczą, jaką robi kontroler widoku, jest zarejestrowanie (i wyrejestrowanie) się w programach obsługi zdarzeń jako odbiornik zdarzeń, które chce otrzymywać.

Korzyści:

  • Implementacje kontrolerów przeglądania i przeglądania są zwykle znacznie lżejsze
  • Zajęcia są bardziej wyspecjalizowane i rozdzielone
  • Zdarzenia można łatwo wyzwolić z dowolnego miejsca
  • Wydarzenia podążają przewidywalną ścieżką przez system
  • Stan jest aktualizowany tylko z jednego miejsca
  • 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