Dlaczego VIPER jest złym wyborem dla twojej następnej aplikacji

W ostatnim roku jest dużo szumu wokół VIPERa i wszyscy są nim zainspirowani. Większość z tych artykułów jest stronnicza i próbuje pokazać, jak fajnie jest. Nie jest. Ma co najmniej tyle samo problemów, jeśli nie więcej, co inne wzorce architektury. W tym artykule chcę wyjaśnić, dlaczego VIPER nie jest tak dobry jak się go reklamuje i nie nadaje się do większości aplikacji.

Niektóre artykuły o porównywaniu architektur zazwyczaj twierdzą, że VIPER jest zupełnie inną architekturą niż wszystkie architektury oparte na MVC. Twierdzenie to nie jest prawdziwe: Jest to zwykłe MVC, w którym dzielimy kontroler na dwie części: interactor i presenter. Widok pozostaje ten sam, ale model zostaje przemianowany na encję. Router zasługuje na kilka specjalnych słów: Tak, to prawda, inne architektury nie promują tej części w swoich skrótach, ale nadal istnieje, w sposób ukryty (tak, kiedy wywołujesz pushViewController piszesz prosty router) lub bardziej jawny (na przykład FlowCoordinators).

Teraz chcę porozmawiać o korzyściach, jakie oferuje VIPER. Będę używał tej książki jako kompletnej referencji na temat VIPERa. Zacznijmy od drugiego celu, czyli zgodności z SRP (Single Responsibility Principle). Jest to trochę szorstkie, ale co za dziwny umysł może nazwać to korzyścią? Płacą ci za rozwiązywanie zadań, a nie za dostosowanie się do jakiegoś buzzworda. Tak, nadal używasz TDD, BDD, testów jednostkowych, Realm lub SQLite, wstrzykiwania zależności i czegokolwiek, co możesz sobie przypomnieć, ale używasz tego do rozwiązywania problemów klienta, a nie tylko do używania tego.

Inny cel jest o wiele bardziej interesujący. Testowalność jest bardzo ważnym problemem. Zasługuje na osobny artykuł, ponieważ wiele osób o tym mówi, ale tylko kilka z nich naprawdę testuje swoje aplikacje, a jeszcze mniej robi to dobrze.

Jednym z głównych powodów jest brak dobrych przykładów. Istnieje wiele artykułów o tworzeniu testów jednostkowych z assert 2 + 2 == 4, ale nie ma przykładów z życia wziętych (btw, Artsy wykonuje świetną robotę poprzez open sourcing swoich aplikacji, powinieneś spojrzeć na ich projekty).

VIPER sugeruje, aby rozdzielić całą logikę na kilka małych klas z oddzielonymi obowiązkami. Może to ułatwić testowanie, ale nie zawsze. Owszem, napisanie unit-testu dla prostej klasy jest łatwe, ale większość z tych testów nie testuje niczego. Na przykład, spójrzmy na większość metod prezentera, są one tylko proxy pomiędzy widokiem a innymi komponentami. Możesz napisać testy dla tego proxy, zwiększy to pokrycie testowe twojego kodu, ale te testy są bezużyteczne. Masz również efekt uboczny: powinieneś aktualizować te bezużyteczne testy po każdej edycji w głównym kodzie.

Właściwe podejście do testowania powinno obejmować testowanie interaktora i prezentera w tym samym czasie, ponieważ te dwie części są głęboko zintegrowane. Ponadto, ponieważ rozdzielamy dwie klasy, potrzebujemy znacznie więcej testów w porównaniu do przypadku, gdy mamy tylko jedną klasę. To prosta kombinatoryka: klasa A ma 4 możliwe stany, klasa B ma 6 możliwych stanów, więc kombinacja A i B ma 20 stanów, i powinieneś przetestować to wszystko.

Właściwym podejściem do uproszczenia testowania jest wprowadzenie czystości do kodu, zamiast po prostu dzielenia skomplikowanego stanu na kilka klas.

Może to zabrzmieć dziwnie, ale testowanie widoków jest łatwiejsze niż testowanie jakiegoś kodu biznesowego. Widok eksponuje stan jako zestaw właściwości i dziedziczy wygląd wizualny z tych właściwości. Następnie możesz użyć FBSnapshotTestCase do dopasowania stanu do wyglądu. To wciąż nie obsługuje niektórych przypadków brzegowych, takich jak niestandardowe przejścia, ale jak często to implementujesz?

Overengineered design

VIPER jest tym, co dzieje się, gdy byli programiści Java w przedsiębiorstwach najeżdżają świat iOS. -n0damage, reddit comment

Szczerze mówiąc, czy ktoś może na to spojrzeć i powiedzieć: „tak, te dodatkowe klasy i protokoły naprawdę poprawiły moją zdolność do zrozumienia, co dzieje się z moją aplikacją”.

Wyobraź sobie proste zadanie: mamy przycisk, który wywołuje aktualizację z serwera i widok z danymi z odpowiedzi serwera. Zgadnijcie ile klas/protokołów zostanie dotkniętych tą zmianą? Tak, co najmniej 3 klasy i 4 protokoły zostaną zmienione dla tej prostej funkcjonalności. Czy nikt nie pamięta jak Spring zaczynał od abstrakcji, a kończył na AbstractSingletonProxyFactoryBean? Ja zawsze chcę mieć w swoim kodzie „wygodną superklasę dla proxy factory bean, która tworzy tylko singletony”.

Redundantne komponenty

Jak już wcześniej wspomniałem, prezenter jest zazwyczaj bardzo głupią klasą, która po prostu proksuje wywołania z widoku do interaktora (coś w tym stylu). Owszem, czasem zawiera skomplikowaną logikę, ale w większości przypadków jest po prostu zbędnym komponentem.

„DI-friendly” ilość protokołów

Powszechne jest mylenie tego skrótu: VIPER wdraża zasady SOLID, gdzie DI oznacza „inwersję zależności”, a nie „wstrzykiwanie”. Wstrzykiwanie zależności jest specjalnym przypadkiem wzorca Inversion of Control, który jest powiązany, ale różni się od Dependency Inversion.

Dependency Inversion polega na oddzieleniu modułów z różnych poziomów poprzez wprowadzenie abstrakcji pomiędzy nimi. Na przykład, moduł UI nie powinien bezpośrednio zależeć od modułu sieciowego lub modułu persystencji. Inversion of Control to co innego, to sytuacja, w której moduł (zazwyczaj z biblioteki, której nie możemy zmienić) deleguje coś do innego modułu, co zazwyczaj jest dostarczane do pierwszego modułu jako zależność. Tak, kiedy implementujesz źródło danych dla swojego UITableView używasz zasady IoC. Używanie tych samych funkcji językowych do różnych celów wysokopoziomowych jest źródłem zamieszania tutaj.

Powróćmy do VIPER. Istnieje wiele protokołów (co najmniej 5) pomiędzy każdą klasą wewnątrz modułu. I wcale nie są one wymagane. Presenter i Interactor nie są modułami z różnych warstw. Stosowanie zasady IoC może i ma sens, ale zadaj sobie pytanie: jak często zdarza Ci się implementować przynajmniej dwa prezentery dla jednego widoku? Myślę, że większość z Was odpowiedziała, że zero. Więc po co jest wymagane tworzenie tej masy protokołów, których nigdy nie użyjemy?

Ponadto, z powodu tych protokołów nie możesz łatwo nawigować po swoim kodzie za pomocą IDE, ponieważ cmd+click przeniesie cię do protokołu, zamiast do prawdziwej implementacji.

Problemy z wydajnością

Jest to kluczowa rzecz i jest wielu ludzi, którzy po prostu nie dbają o to, lub po prostu nie doceniają wpływu złych decyzji architektonicznych.

Nie będę mówił o frameworku Typhoon (który jest bardzo popularny do wstrzykiwania zależności w świecie ObjC). Oczywiście, ma on pewien wpływ na wydajność, zwłaszcza gdy używasz auto-injection, ale VIPER nie wymaga, abyś go używał. Zamiast tego chcę porozmawiać o czasie działania i uruchamianiu aplikacji oraz o tym, jak VIPER spowalnia twoją aplikację dosłownie wszędzie.

Czas uruchamiania aplikacji. Jest to rzadko omawiane, ale jest to ważny temat, jeśli Twoja aplikacja uruchamia się bardzo wolno, użytkownicy będą unikać jej używania. Była sesja na ostatnim WWDC o optymalizacji czasu uruchamiania aplikacji w zeszłym roku. TL; DR: Czas uruchamiania aplikacji zależy bezpośrednio od ilości klas, które posiadasz. Jeśli masz 100 klas to jest w porządku, nikt tego nie zauważy. Jednak jeśli Twoja aplikacja ma tylko 100 klas, czy naprawdę potrzebujesz tej skomplikowanej architektury? Ale jeśli Twoja aplikacja jest ogromna, np. pracujesz nad aplikacją na Facebooka (18k klas) to wpływ na to jest ogromny, coś około 1 sekundy według wcześniej wspomnianej sesji. Tak, zimne uruchomienie twojej aplikacji zajmie 1 sekundę tylko po to, aby załadować wszystkie metadane klas i nic więcej, czytasz to dobrze.

Runtime dispatch. Jest to bardziej skomplikowane, znacznie trudniejsze do profilowania i w większości dotyczy tylko kompilatora Swift (ponieważ ObjC ma bogate możliwości runtime, a kompilator nie może bezpiecznie wykonywać tych optymalizacji). Porozmawiajmy o tym, co dzieje się pod maską, gdy wywołujesz jakąś metodę (używam „wywołania” zamiast „wysyłania wiadomości”, ponieważ drugi termin nie zawsze jest poprawny dla Swift). Istnieją 3 rodzaje wysyłki w Swift (od szybszej do wolniejszej): statyczna, wysyłka tablicy i wysyłanie wiadomości. Ostatni jest jedynym typem, który jest używany w ObjC i jest używany w Swift, gdy wymagany jest interop z kodem ObjC, lub gdy deklarujemy metodę jako dynamic. Oczywiście, ta część runtime jest ogromnie zoptymalizowana i napisana w asemblerze dla wszystkich platform. Ale co jeśli możemy uniknąć tego narzutu, ponieważ kompilator posiada wiedzę o tym, co będzie wywoływane w czasie kompilacji? To jest dokładnie to, co kompilator Swift robi z dyspozycją statyczną i tablicową. Statyczna wysyłka jest szybka, ale kompilator nie może jej użyć bez 100% pewności co do typów w wyrażeniu. Ale kiedy typ naszej zmiennej jest protokołem, kompilator jest zmuszony do korzystania z wysyłki za pośrednictwem tablic świadków protokołu. Nie jest to dosłownie powolne, ale 1ms tu, 1ms tam, a teraz całkowity czas wykonania jest o ponad sekundę wyższy niż moglibyśmy osiągnąć używając czystego kodu Swift. Ten akapit jest powiązany z poprzednim o protokołach, ale myślę, że lepiej jest oddzielić obawy dotyczące tylko lekkomyślnej ilości nieużywanych protokołów od prawdziwego bałaganu z kompilatorem z tym związanym.

Słaba separacja abstrakcji

Powinien być jeden – i najlepiej tylko jeden – oczywisty sposób na zrobienie tego.

Jednym z najpopularniejszych pytań w społeczności VIPER jest „gdzie powinienem umieścić X?”. Tak więc, z jednej strony istnieje wiele zasad jak należy postępować, ale z drugiej strony wiele rzeczy opiera się na opiniach. Mogą to być skomplikowane przypadki, na przykład obsługa CoreData za pomocą NSFetchedResultsController, lub UIWebView. Ale nawet zwykłe przypadki, jak użycie UIAlertController, jest tematem do dyskusji. Przyjrzyjmy się, tutaj mamy router zajmujący się alertami, ale tam mamy widok prezentujący alert. Można próbować to zbić argumentem, że zwykły alert jest przypadkiem specjalnym dla zwykłego alertu bez żadnych akcji poza zamknięciem.

Przypadki specjalne nie są na tyle specjalne, żeby łamać zasady.

Tak, ale dlaczego mamy tutaj fabrykę do tworzenia tego typu alertów? Czyli mamy bałagan nawet przy tak prostym przypadku z UIAlertController. Chcesz tego?

Generowanie kodu

Liczą się czytelność.

Jak to może być problem z architekturą? To jest po prostu generowanie garści klas szablonów zamiast pisania ich samemu. Jaki jest w tym problem? Problem w tym, że przez większość czasu czytasz kod (chyba, że pracujesz w firmie outsourcingowej), a nie go piszesz. Więc przez większość czasu czytasz kod z szablonu, który jest pomieszany z prawdziwym kodem. Czy to jest dobre? Nie sądzę.

Podsumowanie

Nie mam na celu zniechęcania do używania VIPERa w ogóle. Nadal mogą istnieć aplikacje, które mogą czerpać z niego korzyści. Jednak zanim zaczniesz rozwijać swoją aplikację, powinieneś zadać sobie kilka pytań:

  1. Czy ta aplikacja będzie miała długi czas życia?
  2. Czy specyfikacja jest wystarczająco stabilna? W przeciwnym razie możesz skończyć z niekończącymi się, ogromnymi refaktoryzacjami nawet dla małych zmian.
  3. Czy naprawdę testujesz swoje aplikacje? Bądź ze sobą szczery.

Tylko jeśli odpowiesz „tak” na wszystkie pytania VIPER może być dobrym wyborem dla twojej aplikacji.

Wreszcie ostatni: powinieneś używać własnego mózgu do podejmowania decyzji, nie tylko ślepo ufaj jakiemuś facetowi z Medium lub konferencji, w której uczestniczyłeś, który mówi: „Użyj X, X jest fajny”. Ci faceci też mogą się mylić.

.