Miért rossz választás a VIPER a következő alkalmazásodhoz

A VIPER-rel kapcsolatban az elmúlt évben nagy volt a felhajtás, és mindenkit nagyon megihletett. A cikkek többsége elfogultan nyilatkozik róla, és próbálják bemutatni, hogy mennyire menő. Pedig nem az. Legalább ugyanannyi problémája van, ha nem még több, mint más architektúra mintáknak. Ebben a cikkben azt szeretném elmagyarázni, hogy a VIPER miért nem olyan jó, mint ahogyan reklámozzák, és miért nem alkalmas a legtöbb alkalmazáshoz.

Az architektúrák összehasonlításáról szóló egyes cikkek általában azt állítják, hogy a VIPER teljesen más, mint bármelyik MVC-alapú architektúra. Ez az állítás nem igaz: Ez csak egy normál MVC, ahol a kontrollert két részre osztjuk: interaktorra és prezenterre. A nézet ugyanaz marad, de a modellt átnevezzük entitássá. A router megérdemel néhány külön szót: Igen, ez igaz, más architektúrák nem reklámozzák ezt a részt a rövidítésükben, de ettől még létezik, implicit (igen, amikor a pushViewController-t hívod, akkor egy egyszerű routert írsz) vagy explicitebb módon (például FlowCoordinators).

Most a VIPER által kínált előnyökről szeretnék beszélni. Ezt a könyvet teljes referenciaként fogom használni a VIPER-ről. Kezdjük a második céllal, az SRP-nek (Single Responsibility Principle – Egyetlen felelősség elve) való megfeleléssel. Ez egy kicsit durva, de milyen furcsa elme nevezheti ezt előnynek? Önt a feladatok megoldásáért fizetik, nem pedig azért, hogy megfeleljen valamilyen divatszónak. Igen, még mindig használsz TDD-t, BDD-t, unit-teszteket, Realmot vagy SQLite-ot, függőségi injektálást és bármit, ami eszedbe jut, de az ügyfél problémájának megoldására használod, nem csak azért, hogy használd.

A másik cél sokkal érdekesebb. A tesztelhetőség nagyon fontos szempont. Megérdemelne egy önálló cikket, mivel sokan beszélnek róla, de csak kevesen tesztelik igazán az alkalmazásaikat, és még kevesebben csinálják jól.

Az egyik fő oka ennek a jó példák hiánya. Sok cikk szól a assert 2 + 2 == 4 egységteszt készítéséről, de nincsenek valós példák (btw, az Artsy kiváló munkát végez azzal, hogy nyílt forráskódúvá teszi az alkalmazásait, érdemes megnézni a projektjeiket).

VIPER azt javasolja, hogy minden logikát válasszunk szét egy csomó kis osztályba, elkülönített felelősséggel. Ez megkönnyítheti a tesztelést, de nem mindig. Igen, unit-tesztet írni egy egyszerű osztályra könnyű, de a legtöbb ilyen teszt nem tesztel semmit. Nézzük például a prezenter legtöbb metódusát, ezek csak egy proxy a nézet és más komponensek között. Írhatunk teszteket erre a proxyra, ez növeli a kódunk tesztlefedettségét, de ezek a tesztek haszontalanok. Van egy mellékhatása is: ezeket a haszontalan teszteket a fő kód minden egyes szerkesztése után frissítenie kell.

A tesztelés megfelelő megközelítésének egyszerre kell tartalmaznia az interaktor és a prezenter tesztelését, mivel ez a két rész mélyen integrálódik. Ráadásul mivel két osztály között különválunk, sokkal több tesztre van szükségünk ahhoz az esethez képest, amikor csak egy osztályunk van. Ez egy egyszerű szombinatorika: az A osztálynak 4 lehetséges állapota van, a B osztálynak 6 lehetséges állapota, tehát az A és B kombinációja 20 állapotot tartalmaz, és mindet tesztelnünk kell.

A tesztelés egyszerűsítésének helyes megközelítése a kód tisztaságának bevezetése ahelyett, hogy a bonyolult állapotokat egy csomó osztályra osztanánk.

Lehet, hogy furcsán hangzik, de a nézetek tesztelése egyszerűbb, mint néhány üzleti kód tesztelése. Egy nézet az állapotot tulajdonságok halmazaként tárja fel, és a vizuális megjelenést ezekből a tulajdonságokból örökli. Ezután használhatja az FBSnapshotTestCase-t az állapot és a megjelenés összevetésére. Ez még mindig nem kezel néhány szélsőséges esetet, például az egyéni átmeneteket, de milyen gyakran valósítja meg?

Overengineered design

VIPER az történik, amikor a korábbi vállalati Java programozók betörnek az iOS világába. -n0damage, reddit komment

Őszintén szólva, tud valaki erre ránézni és azt mondani: “igen, ezek az extra osztályok és protokollok tényleg javították a képességemet, hogy megértsem, mi történik az alkalmazásommal.”

Képzeljünk el egy egyszerű feladatot: van egy gombunk, ami egy frissítést indít el egy szerverről, és egy nézet a szerver válaszából származó adatokkal. Találd ki, hogy mennyi osztályt/protokollt érint ez a változás? Igen, legalább 3 osztály és 4 protokoll fog megváltozni ehhez az egyszerű funkcióhoz. Senki sem emlékszik arra, hogy a Spring hogyan kezdődött néhány absztrakcióval, és hogyan végződött AbstractSingletonProxyFactoryBean? Én mindig szeretnék egy “kényelmes proxy factory bean szuperosztályt a proxy factory beanekhez, amelyek csak singletonokat hoznak létre” a kódomban.

Redundáns komponensek

Mint már említettem korábban, a prezenter általában egy nagyon buta osztály, amely csak proxy hívásokat intéz a view-ból az interaktorhoz (valami ilyesmi). Igen, néha tartalmaz bonyolult logikát, de a legtöbb esetben csak egy felesleges komponens.

“DI-barát” mennyiségű protokoll

Ezzel a rövidítéssel kapcsolatban gyakori a félreértés: A VIPER a SOLID elveket valósítja meg, ahol a DI “függőségi inverziót” jelent, nem pedig “injektálást”. A függőségi injektálás az Inversion of Control minta speciális esete, amely rokon, de különbözik a függőségi inverziótól.

A függőségi inverzió a különböző szintű modulok elkülönítéséről szól, absztrakciók bevezetésével közöttük. Például a felhasználói felület modul nem függhet közvetlenül a hálózati vagy a perszisztencia modultól. A vezérlés inverziója más, ez az, amikor egy modul (általában egy könyvtárból, amit nem tudunk megváltoztatni) delegál valamit egy másik modulnak, amit jellemzően függőségként biztosítunk az első modulnak. Igen, amikor adatforrást implementálsz a UITableView számára, akkor IoC elvet használsz. Ugyanazon nyelvi funkciók különböző magas szintű célokra való használata itt zavar forrása.

Vissza a VIPER-hez. Sok protokoll van (legalább 5) az egyes osztályok között egy modulon belül. És ezek egyáltalán nem szükségesek. Presenter és Interactor nem különböző rétegek moduljai. Az IoC elv alkalmazásának lehet értelme, de tegyél fel magadnak egy kérdést: milyen gyakran implementálsz legalább két prezentert egy nézethez? Azt hiszem, a legtöbben nulla választ adtatok. Akkor miért kell létrehozni ezt a rengeteg protokollt, amit soha nem fogunk használni?

Ezek miatt a protokollok miatt nem tudsz könnyen navigálni a kódodban egy IDE-vel, mert a cmd+klikk egy protokollhoz navigál, ahelyett, hogy egy valódi implementációhoz.

Teljesítményproblémák

Ez egy döntő fontosságú dolog, és sokan vannak, akik egyszerűen nem törődnek vele, vagy csak alábecsülik a rossz architektúrális döntések hatását.

A Typhoon keretrendszerről (ami nagyon népszerű a függőségi injektáláshoz az ObjC világában) nem fogok beszélni. Természetesen van némi teljesítményhatása, főleg ha auto-injectiont használsz, de a VIPER nem követeli meg a használatát. Ehelyett a futási időről és az alkalmazás indításáról szeretnék beszélni, és arról, hogy a VIPER szó szerint mindenhol lelassítja az alkalmazásodat.

Az alkalmazás indítási ideje. Ritkán tárgyalják, pedig fontos téma, ha az alkalmazásod nagyon lassan indul, a felhasználók el fogják kerülni a használatát. A tavalyi WWDC-n volt egy ülés az alkalmazásindítási idő optimalizálásáról tavaly. TL; DR: Az alkalmazás indítási ideje közvetlenül függ attól, hogy hány osztályod van. Ha 100 osztályod van, az rendben van, ezt senki sem fogja észrevenni. Azonban ha az alkalmazásodnak csak 100 osztálya van, akkor tényleg szükséged van erre a bonyolult architektúrára? De ha az alkalmazásod hatalmas, például Facebook alkalmazáson dolgozol (18k osztály) ez a hatás óriási, valami 1 másodperc körüli a korábban említett munkamenet szerint. Igen, az alkalmazásod hideg indítása 1 másodpercet vesz igénybe csak az összes osztály metaadatának betöltéséhez és semmi máshoz, jól olvastad.

Futtatási idő dispatch. Ez bonyolultabb, sokkal nehezebb profilozni és leginkább csak Swift fordítóra alkalmazható (mert az ObjC gazdag futásidejű képességekkel rendelkezik és egy fordító nem tudja biztonságosan elvégezni ezeket az optimalizálásokat). Beszéljünk arról, hogy mi történik a gépház alatt, amikor meghívunk valamilyen metódust (azért használom a “hívás” kifejezést a “küldés” helyett, mert a második kifejezés nem mindig helyes a Swift esetében). A Swiftben 3 féle diszpozíció létezik (a gyorsabbtól a lassabbig): statikus, táblázatos diszpozíció és üzenetküldés. Az utolsó az egyetlen olyan típus, amely az ObjC-ben használatos, és a Swiftben akkor használjuk, amikor interopra van szükség az ObjC kóddal, vagy amikor egy metódust dynamic-ként deklarálunk. Természetesen a futásidőnek ez a része óriási mértékben optimalizált és assemblerben íródott minden platformra. De mi lenne, ha elkerülhetnénk ezt az overheadet, mert a fordító már fordítási időben tud arról, hogy mit fogunk meghívni? Pontosan ezt teszi a Swift fordító a statikus és a táblázatos diszpécserrel. A statikus diszpatch gyors, de a fordító nem tudja használni anélkül, hogy 100%-ig biztos lenne a kifejezésben lévő típusokban. De ha a változónk típusa egy protokoll, a fordító kénytelen a protokoll tanú táblákon keresztül történő diszpatchet használni. Ez nem szó szerint lassú, de 1ms itt, 1ms ott, és most a teljes végrehajtási idő több mint egy másodperccel magasabb, mint amit tiszta Swift kóddal elérhetnénk. Ez a bekezdés kapcsolódik az előzőhöz a protokollokról, de szerintem jobb, ha szétválasztjuk az aggodalmakat a csak meggondolatlanul sok nem használt protokoll miatt és a fordítóval való valódi összevisszaságot ezzel.

Gyenge absztrakciók szétválasztása

Egy – és lehetőleg csak egy – nyilvánvaló módnak kellene lennie.

A VIPER közösségben az egyik legnépszerűbb kérdés a “hova tegyem X-et?”. Tehát az egyik oldalról sok szabály van arra, hogy hogyan csináljuk helyesen a dolgokat, de a másik oldalról sok minden véleményen alapul. Lehetnek bonyolult esetek, például a CoreData kezelése NSFetchedResultsController, vagy UIWebView. De még a hétköznapi esetek is, mint például a UIAlertController használata, vita tárgyát képezik. Nézzük csak meg, itt van egy router, ami a riasztással foglalkozik, de ott van egy nézet, ami egy riasztást mutat be. Megpróbálhatod ezt azzal az érvvel megdönteni, hogy az egyszerű riasztás egy speciális eset az egyszerű riasztás számára, amely a bezáráson kívül semmilyen műveletet nem tartalmaz.

A speciális esetek nem elég speciálisak ahhoz, hogy megszegjük a szabályokat.

Igen, de miért van itt egy gyár az ilyen típusú riasztások létrehozására? Szóval, még ilyen egyszerű UIAlertController esettel is zűrzavar van. Akarod ezt?

Kódgenerálás

Az olvashatóság számít.

Hogyan lehet ez probléma egy architektúrával? Csak generál egy csomó sablon osztályt ahelyett, hogy magam írnám meg. Mi a probléma ezzel? Az a baj, hogy legtöbbször kódot olvasol (hacsak nem egy kidobós outsourcing cégnél dolgozol), nem pedig írsz. Tehát az időd nagy részében egy tényleges kóddal összekevert boilerplate kódot olvasol. Ez jó? Szerintem nem.

Következtetés

Nem azt a célt követem, hogy egyáltalán lebeszéljelek a VIPER használatáról. Még mindig lehet néhány olyan alkalmazás, amely mindenből hasznot húzhat. Mielőtt azonban elkezdené az alkalmazás fejlesztését, fel kell tennie magának néhány kérdést:

  1. Ez az alkalmazás hosszú élettartamú lesz?
  2. Elég stabilak a specifikációk? Máskülönben végtelenül nagy refaktorálásokat végezhetsz még apró változtatások esetén is.
  3. Tényleg teszteled az alkalmazásaidat? Légy őszinte magadhoz.

Kizárólag akkor lehet jó választás a VIPER az alkalmazásodhoz, ha minden kérdésre igennel válaszolsz.

Végül az utolsó: használd a saját agyadat a döntéseidhez, ne bízz vakon egy srácban a Mediumról vagy a konferenciáról, amin részt vettél, aki azt mondja: “Használj X-et, X menő.” Ezek a srácok is tévedhetnek.