Det er velkendt inden for arkitektur, at vi former vores bygninger, og bagefter former vores bygninger os. Som alle programmører efterhånden lærer, gælder dette lige så godt for opbygning af software.
Det er vigtigt at designe vores kode, så hver enkelt del er let identificerbar, har et specifikt og indlysende formål og passer sammen med andre dele på en logisk måde. Det er det, vi kalder softwarearkitektur. God arkitektur er ikke det, der gør et produkt vellykket, men den gør et produkt vedligeholdbart og er med til at bevare fornuften hos de personer, der vedligeholder det!
I denne artikel introducerer vi en tilgang til iOS-applikationsarkitektur kaldet VIPER. VIPER er blevet brugt til at bygge mange store projekter, men i forbindelse med denne artikel vil vi vise dig VIPER ved at bygge en to-do-liste-app. Du kan følge med i eksempelprojektet her på GitHub:
Hvad er VIPER?
Testning har ikke altid været en vigtig del af opbygningen af iOS-apps. Da vi gik i gang med en søgen efter at forbedre vores testpraksis hos Mutual Mobile, fandt vi ud af, at det var svært at skrive tests til iOS-apps. Vi besluttede, at hvis vi skulle forbedre den måde, vi tester vores software på, skulle vi først finde på en bedre måde at bygge vores apps på. Vi kalder denne metode VIPER.
VIPER er en anvendelse af Clean Architecture på iOS-apps. Ordet VIPER er et backronym for View, Interactor, Presenter, Entity og Routing. Clean Architecture opdeler en app’s logiske struktur i forskellige ansvarslag. Det gør det nemmere at isolere afhængigheder (f.eks. din database) og at teste interaktionerne på grænserne mellem lagene:
De fleste iOS-apps er arkitektonisk opbygget ved hjælp af MVC (model-view-controller). Hvis man bruger MVC som en applikationsarkitektur, kan det føre til, at man tror, at hver klasse enten er en model, en visning eller en controller. Da en stor del af applikationslogikken ikke hører hjemme i en model eller visning, ender den normalt i controlleren. Dette fører til et problem, der er kendt som en Massive View Controller, hvor view-controllerne ender med at gøre for meget. At slanke disse massive viewcontrollere er ikke den eneste udfordring, som iOS-udviklere, der ønsker at forbedre kvaliteten af deres kode, står over for, men det er et godt sted at starte.
VIPER’s forskellige lag hjælper med at håndtere denne udfordring ved at give klare placeringer for applikationslogik og navigationsrelateret kode. Når VIPER er anvendt, vil du bemærke, at visningscontrollerne i vores eksempel med to-do-listen er slanke, effektive, visningskontrollerende maskiner. Du vil også opdage, at koden i view-controllerne og alle de andre klasser er let at forstå, lettere at teste og som følge heraf også lettere at vedligeholde.
Applikationsdesign baseret på Use Cases
Apps implementeres ofte som et sæt Use Cases. Use cases er også kendt som acceptkriterier eller adfærd, og de beskriver, hvad det er meningen, at en app skal gøre. Måske skal en liste kunne sorteres efter dato, type eller navn. Det er en use case. En use case er det lag i en applikation, der er ansvarlig for forretningslogikken. Use cases bør være uafhængige af implementeringen af brugergrænsefladen af dem. De bør også være små og veldefinerede. Det er en udfordring og kræver øvelse at beslutte, hvordan en kompleks app skal opdeles i mindre use cases, men det er en nyttig måde at begrænse omfanget af hvert problem, du løser, og hver klasse, du skriver.
At opbygge en app med VIPER indebærer implementering af et sæt komponenter til at opfylde hver use case. Applikationslogik er en vigtig del af implementeringen af en use case, men det er ikke den eneste del. Anvendelsestilfælde påvirker også brugergrænsefladen. Derudover er det vigtigt at overveje, hvordan use case passer sammen med andre centrale komponenter i en applikation, f.eks. netværk og datapersistens. Komponenterne fungerer som plugins til use cases, og VIPER er en måde at beskrive, hvilken rolle hver af disse komponenter spiller, og hvordan de kan interagere med hinanden.
En af use cases eller krav til vores to-do-liste-app var at gruppere to-dos på forskellige måder baseret på en brugers valg. Ved at adskille logikken, der organiserer disse data, i en use case kan vi holde brugergrænsefladekoden ren og nemt pakke use casen ind i tests for at sikre, at den fortsat fungerer, som vi forventer, at den skal.
Hoveddele i VIPER
Hoveddelene i VIPER er:
- Visning: Viser det, som den får besked på af Presenter, og videresender brugerinput tilbage til Presenter.
- Interaktor: Indeholder forretningslogikken som specificeret af en use case.
- Presenter: indeholder visningslogik til at forberede indhold til visning (som modtaget fra interaktøren) og til at reagere på brugerinput (ved at anmode om nye data fra interaktøren).
- Entity: indeholder grundlæggende modelobjekter, der anvendes af interaktøren.
- Routing: indeholder navigationslogik til at beskrive, hvilke skærme der vises i hvilken rækkefølge.
Denne adskillelse er også i overensstemmelse med princippet om enkelt ansvar. Interactor er ansvarlig over for forretningsanalytikeren, Presenter repræsenterer interaktionsdesigneren, og View er ansvarlig over for den visuelle designer.
Nedenfor er et diagram over de forskellige komponenter, og hvordan de er forbundet:
Selv om komponenterne i VIPER kan implementeres i en applikation i en hvilken som helst rækkefølge, har vi valgt at introducere komponenterne i den rækkefølge, som vi anbefaler at implementere dem. Du vil bemærke, at denne rækkefølge er nogenlunde i overensstemmelse med processen for opbygning af en hel applikation, som starter med at diskutere, hvad produktet skal gøre, efterfulgt af, hvordan en bruger vil interagere med det.
Interaktør
En interaktør repræsenterer et enkelt anvendelsestilfælde i applikationen. Den indeholder forretningslogikken til at manipulere modelobjekter (Entities) for at udføre en bestemt opgave. Det arbejde, der udføres i en Interactor, skal være uafhængigt af enhver brugergrænseflade. Den samme Interactor kunne bruges i en iOS-app eller en OS X-app.
Da Interactor er en PONSO (Plain Old NSObject
), der primært indeholder logik, er den nem at udvikle ved hjælp af TDD.
Den primære use case for eksempelappen er at vise brugeren eventuelle kommende to-do ting (dvs. alt, der skal være klar inden udgangen af næste uge). Forretningslogikken for denne use case er at finde alle to-do-emner, der forfalder mellem i dag og slutningen af næste uge, og tildele en relativ forfaldsdato: i dag, i morgen, senere i denne uge eller i næste uge.
Nedenfor er den tilsvarende metode fra VTDListInteractor
:
- (void)findUpcomingItems{ __weak typeof(self) welf = self; NSDate* today = ; NSDate* endOfNextWeek = dateForEndOfFollowingWeekWithDate:today]; ]; }];}
Entity
Entities er modelobjekter, der manipuleres af en Interactor. Entiteter manipuleres kun af interaktøren. Interaktøren videregiver aldrig entiteter til præsentationslaget (dvs. Presenter).
Entiteter har også en tendens til at være PONSO’er. Hvis du bruger Core Data, vil du ønske, at dine administrerede objekter forbliver bag dit datalag. Interactors bør ikke arbejde med NSManagedObjects
.
Her er Entity’en for vores to-do item:
@interface VTDTodoItem : NSObject@property (nonatomic, strong) NSDate* dueDate;@property (nonatomic, copy) NSString* name;+ (instancetype)todoItemWithDueDate:(NSDate*)dueDate name:(NSString*)name;@end
Du skal ikke blive overrasket, hvis dine entiteter blot er datastrukturer. Enhver applikationsafhængig logik vil højst sandsynligt være i en Interactor.
Presenter
Presenter er en PONSO, der hovedsageligt består af logik til at styre brugergrænsefladen. Den ved, hvornår brugergrænsefladen skal præsenteres. Den indsamler input fra brugerinteraktioner, så den kan opdatere brugergrænsefladen og sende anmodninger til en interaktør.
Når brugeren trykker på +-knappen for at tilføje et nyt to-do punkt, bliver addNewEntry
kaldt. I forbindelse med denne handling beder Presentereren wireframen om at præsentere brugergrænsefladen for tilføjelse af et nyt element:
- (void)addNewEntry{ ;}
Presentereren modtager også resultater fra en Interactor og konverterer resultaterne til en formular, der er effektiv til visning i en visning.
Nedenfor er den metode, der modtager kommende elementer fra Interactor. Den behandler dataene og bestemmer, hvad der skal vises for brugeren:
- (void)foundUpcomingItems:(NSArray*)upcomingItems{ if ( == 0) { ; } else { ; }}
Enhederne overføres aldrig fra Interactor til Presenter. I stedet overføres simple datastrukturer, der ikke har nogen adfærd, fra Interactor til Presenter. Dette forhindrer, at der udføres noget “rigtigt arbejde” i Presenter’en. Presentereren kan kun forberede dataene til visning i visningen.
View
View’en er passiv. Den venter på, at Presenter’en giver den indhold, den skal vise; den beder aldrig Presenter’en om data. Metoder, der er defineret for en View (f.eks. LoginView for en login-skærm), bør gøre det muligt for en Presenter at kommunikere på et højere abstraktionsniveau, udtrykt i form af dens indhold og ikke i form af, hvordan dette indhold skal vises. Presenteren har ikke kendskab til eksistensen af UILabel
, UIButton
osv. Presentereren kender kun til det indhold, som den vedligeholder, og hvornår det skal vises. Det er op til View at bestemme, hvordan indholdet skal vises.
View er en abstrakt grænseflade, der er defineret i Objective-C med en protokol. En UIViewController
eller en af dens underklasser vil implementere View-protokollen. F.eks. har skærmen “tilføj” fra vores eksempel følgende grænseflade:
@protocol VTDAddViewInterface <NSObject>- (void)setEntryName:(NSString *)name;- (void)setEntryDueDate:(NSDate *)date;@end
Views og viewcontrollere håndterer også brugerinteraktion og input. Det er let at forstå, hvorfor viewcontrollere normalt bliver så store, da de er det nemmeste sted at håndtere dette input for at udføre en eller anden handling. For at holde vores viewcontrollere slanke er vi nødt til at give dem en måde at informere interesserede parter på, når en bruger foretager visse handlinger. Visionscontrolleren bør ikke træffe beslutninger baseret på disse handlinger, men den bør videregive disse hændelser til noget, der kan.
I vores eksempel har Add View Controller en egenskab til håndtering af hændelser, der overholder følgende grænseflade:
@protocol VTDAddModuleInterface <NSObject>- (void)cancelAddAction;- (void)saveAddActionWithName:(NSString *)name dueDate:(NSDate *)dueDate@end
Når brugeren trykker på annulleringsknappen, fortæller visionscontrolleren denne hændelseshåndtering, at brugeren har angivet, at den skal annullere tilføjelseshandlingen. På den måde kan hændelseshåndteringsenheden tage sig af at afvise tilføjelsesvisningscontrolleren og fortælle listevisningen, at den skal opdateres.
Grænsen mellem visningen og præsentationsenheden er også et godt sted for ReactiveCocoa. I dette eksempel kunne visningscontrolleren også levere metoder til at returnere signaler, der repræsenterer knaphandlinger. Dette ville gøre det muligt for Presenter’en at reagere nemt på disse signaler uden at bryde adskillelsen af ansvarsområder.
Routing
Routes fra et skærmbillede til et andet defineres i de wireframes, der er oprettet af en interaktionsdesigner. I VIPER er ansvaret for Routing delt mellem to objekter: Presenter og wireframe. Et wireframe-objekt ejer UIWindow
, UINavigationController
, UIViewController
osv. Det er ansvarligt for at oprette en View/ViewController og installere den i vinduet.
Da Presenter’en indeholder logikken til at reagere på brugerinput, er det Presenter’en, der ved, hvornår der skal navigeres til et andet skærmbillede, og hvilket skærmbillede der skal navigeres til. I mellemtiden ved trådrammen, hvordan den skal navigere. Så Presenter vil bruge wireframen til at udføre navigationen. Sammen beskriver de en rute fra et skærmbillede til det næste.
Trådrammen er også et oplagt sted at håndtere navigationsovergangsanimationer. Tag et kig på dette eksempel fra wireframen tilføjelse:
@implementation VTDAddWireframe- (void)presentAddInterfaceFromViewController:(UIViewController *)viewController { VTDAddViewController *addViewController = ; addViewController.eventHandler = self.addPresenter; addViewController.modalPresentationStyle = UIModalPresentationCustom; addViewController.transitioningDelegate = self; ; self.presentedViewController = viewController;}#pragma mark - UIViewControllerTransitioningDelegate Methods- (id<UIViewControllerAnimatedTransitioning>)animationControllerForDismissedController:(UIViewController *)dismissed { return init];}- (id<UIViewControllerAnimatedTransitioning>)animationControllerForPresentedController:(UIViewController *)presented presentingController:(UIViewController *)presenting sourceController:(UIViewController *)source { return init];}@end
Appen bruger en brugerdefineret visningscontrollerovergang til at præsentere visningscontrolleren tilføjelse. Da wireframen er ansvarlig for at udføre overgangen, bliver den overgangsdelegeret for add view controller og kan returnere de relevante overgangsanimationer.
Applikationskomponenter, der passer ind i VIPER
En iOS-applikationsarkitektur skal tage hensyn til, at UIKit og Cocoa Touch er de vigtigste værktøjer, som apps bygges oven på. Arkitekturen skal eksistere fredeligt sammen med alle komponenterne i applikationen, men den skal også give retningslinjer for, hvordan nogle dele af rammerne bruges, og hvor de bor.
Arbejdshesten i en iOS-app er UIViewController
. Det ville være let at antage, at en udfordrer, der skal erstatte MVC, ville vige tilbage for at gøre kraftig brug af viewcontrollere. Men viewcontrollere er centrale for platformen: de håndterer orienteringsændringer, reagerer på input fra brugeren, integrerer godt med systemkomponenter som navigationscontrollere og giver nu med iOS 7 mulighed for tilpassede overgange mellem skærme. De er ekstremt nyttige.
Med VIPER gør en view controller præcis det, som det var meningen, at den skulle gøre: den styrer viewet. Vores to-do-liste-app har to viewcontrollere, en til listens skærm og en til tilføjelsesskærmen. Implementeringen af visningscontrolleren for tilføjelse er ekstremt grundlæggende, fordi det eneste, den skal gøre, er at styre visningen:
@implementation VTDAddViewController- (void)viewDidAppear:(BOOL)animated { ; UITapGestureRecognizer *gestureRecognizer = initWithTarget:self action:@selector(dismiss)]; ; self.transitioningBackgroundView.userInteractionEnabled = YES;}- (void)dismiss { ;}- (void)setEntryName:(NSString *)name { self.nameTextField.text = name;}- (void)setEntryDueDate:(NSDate *)date { ;}- (IBAction)save:(id)sender { ;}- (IBAction)cancel:(id)sender { ;}#pragma mark - UITextFieldDelegate Methods- (BOOL)textFieldShouldReturn:(UITextField *)textField { ; return YES;}@end
Apps er normalt meget mere overbevisende, når de er forbundet til netværket. Men hvor skal denne netværksforbindelse finde sted, og hvad skal være ansvarlig for at igangsætte den? Det er typisk op til Interactor at igangsætte en netværksoperation, men den vil ikke håndtere netværkskoden direkte. Den vil spørge en afhængighed, som f.eks. en netværksmanager eller API-klient. Interaktøren kan være nødt til at samle data fra flere kilder for at tilvejebringe de oplysninger, der er nødvendige for at opfylde en brugssag. Derefter er det op til Presenter at tage de data, der returneres af Interactor, og formatere dem til præsentation.
Et datalager er ansvarlig for at levere enheder til en Interactor. Når en interaktør anvender sin forretningslogik, skal den hente enheder fra datalageret, manipulere enhederne og derefter lægge de opdaterede enheder tilbage i datalageret. Datalageret administrerer persistensen af enhederne. Enhederne kender ikke til datalageret, så enhederne ved ikke, hvordan de skal persistere sig selv.
Interaktøren bør heller ikke vide, hvordan den skal persistere entiteterne. Nogle gange ønsker interaktøren måske at bruge en type objekt kaldet en datamanager for at lette interaktionen med datalageret. Datamanageren håndterer flere af de butiksspecifikke typer operationer, som f.eks. oprettelse af hentningsanmodninger, opbygning af forespørgsler osv. Dette gør det muligt for interaktøren at fokusere mere på applikationslogikken og ikke behøver at vide noget om, hvordan enheder indsamles eller lagres. Et eksempel på, hvornår det giver mening at bruge en datamanager, er, når du bruger Core Data, som beskrives nedenfor.
Her er grænsefladen for eksempelappens datamanager:
@interface VTDListDataManager : NSObject@property (nonatomic, strong) VTDCoreDataStore *dataStore;- (void)todoItemsBetweenStartDate:(NSDate *)startDate endDate:(NSDate *)endDate completionBlock:(void (^)(NSArray *todoItems))completionBlock;@end
Når man bruger TDD til at udvikle en Interactor, er det muligt at udskifte produktionsdatalageret med et testdobbelt/mock. Ved ikke at tale med en fjernserver (for en webtjeneste) eller røre ved disken (for en database) kan dine tests være hurtigere og mere gentagelige.
En af grundene til at holde datalageret som et særskilt lag med klare grænser er, at det giver dig mulighed for at udskyde valget af en bestemt persistensteknologi. Hvis dit datalager er en enkelt klasse, kan du starte din app med en grundlæggende persistensstrategi og derefter opgradere til SQLite eller Core Data senere, hvis og når det giver mening, alt sammen uden at ændre noget andet i applikationens kodebase.
Anvendelse af Core Data i et iOS-projekt kan ofte udløse mere debat end selve arkitekturen. Men brugen af Core Data med VIPER kan være den bedste Core Data-oplevelse, du nogensinde har haft. Core Data er et fantastisk værktøj til at persistere data, samtidig med at der opretholdes hurtig adgang og et lavt hukommelsesaftryk. Men det har en vane med at sno sine NSManagedObjectContext
-snore rundt i alle en apps implementeringsfiler, især hvor de ikke bør være. VIPER holder Core Data der, hvor det skal være: i datalagringslaget.
I eksemplet med to-do-listen er de eneste to dele af appen, der ved, at Core Data bliver brugt, selve datalageret, som opretter Core Data-stakken, og datahåndteringen. Datamanageren udfører en hentningsanmodning, konverterer de NSManagedObjects
, der returneres af datalageret, til standard PONSO-modelobjekter og sender dem tilbage til forretningslogiklaget. På den måde er programmets kerne aldrig afhængig af Core Data, og som en bonus behøver du aldrig at bekymre dig om forældede eller dårligt trådet NSManagedObjects
, der ødelægger arbejdet.
Sådan ser det ud inde i datamanageren, når der bliver foretaget en anmodning om adgang til Core Data-lageret:
@implementation VTDListDataManager- (void)todoItemsBetweenStartDate:(NSDate *)startDate endDate:(NSDate*)endDate completionBlock:(void (^)(NSArray *todoItems))completionBlock{ NSCalendar *calendar = ; NSPredicate *predicate = , ]; NSArray *sortDescriptors = @; __weak typeof(self) welf = self; ); } }];}- (NSArray*)todoItemsFromDataStoreEntries:(NSArray *)entries{ return ; }];}@end
Næsten lige så kontroversielt som Core Data er UI Storyboards. Storyboards har mange nyttige funktioner, og det ville være en fejl at ignorere dem helt og holdent at ignorere dem. Det er imidlertid vanskeligt at opnå alle målene med VIPER og samtidig anvende alle de funktioner, som et storyboard har at tilbyde.
Det kompromis, vi har tendens til at indgå, er at vælge ikke at bruge segues. Der kan være nogle tilfælde, hvor det giver mening at bruge segue, men faren ved segues er, at de gør det meget svært at holde adskillelsen mellem skærme – såvel som mellem UI og applikationslogik – intakt. Som tommelfingerregel forsøger vi ikke at bruge segues, hvis det synes nødvendigt at implementere prepareForSegue-metoden.
Som tommelfingerregel er storyboards ellers en god måde at implementere layoutet for din brugergrænseflade på, især når du bruger Auto Layout. Vi valgte at implementere begge skærme i eksemplet med to-do-listen ved hjælp af et storyboard og bruge kode som denne til at udføre vores egen navigation:
static NSString *ListViewControllerIdentifier = @"VTDListViewController";@implementation VTDListWireframe- (void)presentListInterfaceFromWindow:(UIWindow *)window { VTDListViewController *listViewController = ; listViewController.eventHandler = self.listPresenter; self.listPresenter.userInterface = listViewController; self.listViewController = listViewController; ;}- (VTDListViewController *)listViewControllerFromStoryboard { UIStoryboard *storyboard = ; VTDListViewController *viewController = ; return viewController;}- (UIStoryboard *)mainStoryboard { UIStoryboard *storyboard = ]; return storyboard;}@end
Brug af VIPER til at opbygge moduler
Ofte vil du, når du arbejder med VIPER, opdage, at en skærm eller et sæt skærme har tendens til at blive samlet som et modul. Et modul kan beskrives på flere forskellige måder, men normalt er det bedst at tænke på det som en funktion. I en podcasting-app kan et modul være lydafspilleren eller abonnementsbrowseren. I vores to-do-liste-app er listen og tilføj-skærmene hver især bygget som separate moduler.
Der er et par fordele ved at designe din app som et sæt moduler. Den ene er, at moduler kan have meget klare og veldefinerede grænseflader samt være uafhængige af andre moduler. Det gør det meget nemmere at tilføje/fjern funktioner eller at ændre den måde, hvorpå din grænseflade præsenterer forskellige moduler for brugeren.
Vi ønskede at gøre adskillelsen mellem modulerne meget tydelig i eksemplet med to-do-listen, så vi definerede to protokoller for tilføjelsesmodulet. Den første er modulgrænsefladen, som definerer, hvad modulet kan gøre. Den anden er moduldelegatet, som beskriver, hvad modulet gjorde. Eksempel:
@protocol VTDAddModuleInterface <NSObject>- (void)cancelAddAction;- (void)saveAddActionWithName:(NSString *)name dueDate:(NSDate *)dueDate;@end@protocol VTDAddModuleDelegate <NSObject>- (void)addModuleDidCancelAddAction;- (void)addModuleDidSaveAddAction;@end
Da et modul skal præsenteres for at være af stor værdi for brugeren, implementerer modulets Presenter normalt modulets grænseflade. Når et andet modul ønsker at præsentere dette modul, vil dets Presenter implementere moduldelegatprotokollen, så den ved, hvad modulet gjorde, mens det blev præsenteret.
Et modul kan omfatte et fælles applikationslogiklag af entiteter, interaktorer og managers, som kan bruges til flere skærme. Dette afhænger naturligvis af interaktionen mellem disse skærme og af, hvor ens de er. Et modul kan lige så godt kun repræsentere en enkelt skærm, som det fremgår af eksemplet med to-do-listen. I dette tilfælde kan applikationslogiklaget være meget specifikt for det pågældende moduls adfærd.
Moduler er også bare en god simpel måde at organisere kode på. Ved at holde al koden til et modul gemt væk i sin egen mappe og gruppe i Xcode er det nemt at finde den, når du skal ændre noget. Det er en dejlig følelse, når man finder en klasse præcis der, hvor man forventede at lede efter den.
En anden fordel ved at bygge moduler med VIPER er, at de bliver lettere at udvide til flere formfaktorer. Når applikationslogikken for alle dine use cases er isoleret i Interactor-laget, kan du fokusere på at bygge den nye brugergrænseflade til tablet, telefon eller Mac, mens du genbruger dit applikationslag.
Tager vi dette et skridt videre, kan brugergrænsefladen til iPad-apps måske genbruge nogle af visningerne, viewcontrollerne og presenters fra iPhone-appen. I dette tilfælde vil en iPad-skærm blive repræsenteret af “super” presenters og wireframes, som vil sammensætte skærmen ved hjælp af eksisterende presenters og wireframes, der blev skrevet til iPhone. Det kan være en stor udfordring at opbygge og vedligeholde en app på tværs af flere platforme, men en god arkitektur, der fremmer genbrug på tværs af model- og applikationslaget, er med til at gøre dette meget lettere.
Testning med VIPER
Følge VIPER fremmer en adskillelse af bekymringer, der gør det lettere at indføre TDD. Interactoren indeholder ren logik, der er uafhængig af enhver brugergrænseflade, hvilket gør det nemt at køre med tests. Presenter’en indeholder logik til at forberede data til visning og er uafhængig af eventuelle UIKit-widgets. Udvikling af denne logik er også let at køre med tests.
Vores foretrukne metode er at starte med Interactor. Alt i UI’et er der for at opfylde behovene i brugssagen. Ved at bruge TDD til at teste API’et for Interactor vil du få en bedre forståelse af forholdet mellem UI og use case.
Som eksempel vil vi se på den Interactor, der er ansvarlig for listen over kommende to-do punkter. Politikken for at finde kommende opgaver er at finde alle opgaver, der skal forfalde inden udgangen af næste uge, og klassificere hver enkelt opgave som værende forfalden i dag, i morgen, senere i denne uge eller i næste uge.
Den første test, vi skriver, er at sikre, at Interactor finder alle to-do-elementer, der forfalder inden udgangen af næste uge:
- (void)testFindingUpcomingItemsRequestsAllToDoItemsFromTodayThroughEndOfNextWeek{ todoItemsBetweenStartDate:self.today endDate:self.endOfNextWeek completionBlock:OCMOCK_ANY]; ;}
Når vi ved, at Interactor spørger efter de relevante to-do-elementer, skriver vi flere tests for at bekræfte, at den tildeler to-do-elementerne til den korrekte relative datagruppe (f.eks.f.eks. i dag, i morgen osv.):
- (void)testFindingUpcomingItemsWithOneItemDueTodayReturnsOneUpcomingItemsForToday{ NSArray *todoItems = @]; ; NSArray *upcomingItems = @]; ; ;}
Nu, hvor vi ved, hvordan API’et for Interactor ser ud, kan vi udvikle Presenter’en. Når Presenter modtager kommende to-do-emner fra Interactor, vil vi gerne teste, at vi formaterer dataene korrekt og viser dem i UI:
- (void)testFoundZeroUpcomingItemsDisplaysNoContentMessage{ showNoContentMessage]; ];}- (void)testFoundUpcomingItemForTodayDisplaysUpcomingDataWithNoDay{ VTDUpcomingDisplayData *displayData = ; showUpcomingDisplayData:displayData]; NSCalendar *calendar = ; NSDate *dueDate = ; VTDUpcomingItem *haircut = ; ];}- (void)testFoundUpcomingItemForTomorrowDisplaysUpcomingDataWithDay{ VTDUpcomingDisplayData *displayData = ; showUpcomingDisplayData:displayData]; NSCalendar *calendar = ; NSDate *dueDate = ; VTDUpcomingItem *groceries = ; ];}
Vi vil også gerne teste, at appen starter den relevante handling, når brugeren ønsker at tilføje et nyt to-do-emne:
- (void)testAddNewToDoItemActionPresentsAddToDoUI{ presentAddInterface]; ;}
Vi kan nu udvikle View’en. Når der ikke er nogen kommende to-do-emner, vil vi vise en særlig meddelelse:
- (void)testShowingNoContentMessageShowsNoContentView{ ; XCTAssertEqualObjects(self.view.view, self.view.noContentView, @"the no content view should be the view");}
Når der er kommende to-do-emner, der skal vises, vil vi sikre os, at tabellen vises:
- (void)testShowingUpcomingItemsShowsTableView{ ; XCTAssertEqualObjects(self.view.view, self.view.tableView, @"the table view should be the view");}
Det passer naturligt med TDD at bygge interaktøren først. Hvis du udvikler Interactor først, efterfulgt af Presenter, får du mulighed for at opbygge en suite af tests omkring disse lag først og lægge fundamentet for implementering af disse use cases. Du kan iterere hurtigt på disse klasser, fordi du ikke behøver at interagere med brugergrænsefladen for at teste dem. Når du så går i gang med at udvikle visningen, har du et fungerende og testet logik- og præsentationslag, som du kan forbinde til den. Når du er færdig med at udvikle View’en, vil du måske opdage, at første gang du kører app’en, virker alting bare, fordi alle dine beståetests fortæller dig, at det vil virke.
Konklusion
Vi håber, at du har nydt denne introduktion til VIPER. Mange af jer undrer jer måske nu over, hvor I skal gå hen næste gang. Hvis du ville arkitektonisere din næste app ved hjælp af VIPER, hvor ville du så starte?
Denne artikel og vores eksempelimplementering af en app ved hjælp af VIPER er så specifikke og veldefinerede, som vi kunne gøre dem. Vores to-do-liste-app er ret ligetil, men den skulle også forklare præcist, hvordan man bygger en app ved hjælp af VIPER. I et projekt i den virkelige verden vil det afhænge af dine egne udfordringer og begrænsninger, hvor tæt du følger dette eksempel. Det er vores erfaring, at hvert af vores projekter har varieret en smule i forhold til tilgangen til brugen af VIPER, men de har alle haft stor gavn af at bruge det som rettesnor for deres fremgangsmåde.
Der kan være tilfælde, hvor du af forskellige årsager ønsker at afvige fra den vej, der er udstukket af VIPER. Måske er du stødt på et virvar af “kaninobjekter”, eller din app ville have gavn af at bruge segues i Storyboards. Det er helt i orden. I disse tilfælde skal du overveje ånden i det, VIPER repræsenterer, når du træffer din beslutning. VIPER er i sin kerne en arkitektur baseret på Single Responsibility Principle. Hvis du har problemer, skal du tænke på dette princip, når du beslutter, hvordan du skal gå videre.
Du spekulerer måske også på, om det er muligt at bruge VIPER i din eksisterende app. I dette scenarie skal du overveje at bygge en ny funktion med VIPER. Mange af vores eksisterende projekter har taget denne vej. Det giver dig mulighed for at bygge et modul ved hjælp af VIPER og hjælper dig også med at få øje på eventuelle eksisterende problemer, der kan gøre det sværere at indføre en arkitektur baseret på Single Responsibility Principle.
En af de gode ting ved at udvikle software er, at hver app er forskellig, og der er også forskellige måder at arkitektonisere enhver app på. For os betyder det, at hver app er en ny mulighed for at lære og prøve nye ting. Hvis du beslutter dig for at prøve VIPER, tror vi, at du også vil lære et par nye ting. Tak for læsningen.
Swift Addendum
I sidste uge på WWDC introducerede Apple programmeringssproget Swift som fremtiden for Cocoa- og Cocoa Touch-udvikling. Det er for tidligt at have dannet sig komplekse meninger om Swift-sproget, men vi ved, at sprog har stor indflydelse på den måde, vi designer og udvikler software på. Vi besluttede at omskrive vores VIPER TODO-eksempelapp ved hjælp af Swift for at lære, hvad dette betyder for VIPER. Indtil videre kan vi lide det, vi ser. Her er nogle få funktioner i Swift, som vi mener vil forbedre oplevelsen af at bygge apps med VIPER.
Structs
I VIPER bruger vi små, lette modelklasser til at overføre data mellem lagene, f.eks. fra Presenter til View. Disse PONSO’er er normalt beregnet til blot at overføre små datamængder og er normalt ikke beregnet til at blive underklasset. Swift structs passer perfekt til disse situationer. Her er et eksempel på en struct, der anvendes i VIPER Swift-eksemplet. Bemærk, at denne struct skal kunne sidestilles, og derfor har vi overbelastet ==-operatoren for at sammenligne to instanser af dens type:
struct UpcomingDisplayItem : Equatable, Printable { let title : String = "" let dueDate : String = "" var description : String { get { return "\(title) -- \(dueDate)" }} init(title: String, dueDate: String) { self.title = title self.dueDate = dueDate }}func == (leftSide: UpcomingDisplayItem, rightSide: UpcomingDisplayItem) -> Bool { var hasEqualSections = false hasEqualSections = rightSide.title == leftSide.title if hasEqualSections == false { return false } hasEqualSections = rightSide.dueDate == rightSide.dueDate return hasEqualSections}
Type Safety
Måske er den største forskel mellem Objective-C og Swift, hvordan de to behandler typer. Objective-C er dynamisk typet, og Swift er meget bevidst streng med hensyn til, hvordan den implementerer typekontrol på kompileringstidspunktet. For en arkitektur som VIPER, hvor en app er sammensat af flere forskellige lag, kan typesikkerhed være en stor gevinst for programmørernes effektivitet og for den arkitektoniske struktur. Compileren hjælper dig med at sikre, at containere og objekter er af den korrekte type, når de sendes mellem laggrænserne. Dette er et godt sted at bruge structs som vist ovenfor. Hvis det er meningen, at en struct skal bo på grænsen mellem to lag, kan du garantere, at den aldrig vil kunne flygte fra mellem disse lag takket være type-sikkerheden.
Videre læsning
- VIPER TODO, artikel eksempelapp
- VIPER SWIFT, artikel eksempelapp bygget med Swift
- Counter, endnu en eksempelapp
- Mutual Mobile Introduktion til VIPER
- Clean Architecture
- Lighter View Controllers
- Testing View Controllers
- Bunnies