Aus der Architektur ist bekannt, dass wir unsere Gebäude formen, und danach formen die Gebäude uns. Wie alle Programmierer irgendwann lernen, gilt dies auch für die Erstellung von Software.
Es ist wichtig, unseren Code so zu gestalten, dass jedes Teil leicht erkennbar ist, einen bestimmten und offensichtlichen Zweck hat und auf logische Weise mit anderen Teilen zusammenpasst. Dies nennen wir Software-Architektur. Eine gute Architektur ist nicht das, was ein Produkt erfolgreich macht, aber sie macht ein Produkt wartbar und hilft, den Verstand der Leute zu bewahren, die es warten!
In diesem Artikel werden wir einen Ansatz für die Architektur von iOS-Anwendungen namens VIPER vorstellen. VIPER wurde für die Entwicklung vieler großer Projekte verwendet, aber in diesem Artikel werden wir VIPER anhand einer Aufgabenlisten-App vorstellen. Sie können das Beispielprojekt hier auf GitHub nachverfolgen:
Was ist VIPER?
Testen war nicht immer ein wichtiger Teil der Erstellung von iOS-Anwendungen. Als wir uns bei Mutual Mobile auf den Weg machten, unsere Testverfahren zu verbessern, stellten wir fest, dass das Schreiben von Tests für iOS-Apps schwierig war. Wir beschlossen, dass wir, wenn wir die Art und Weise, wie wir unsere Software testen, verbessern wollten, zunächst eine bessere Methode für die Architektur unserer Apps entwickeln müssten. Wir nennen diese Methode VIPER.
VIPER ist eine Anwendung von Clean Architecture auf iOS-Anwendungen. Das Wort VIPER ist ein Backronym für View, Interactor, Presenter, Entity und Routing. Clean Architecture unterteilt die logische Struktur einer App in verschiedene Verantwortungsebenen. Das macht es einfacher, Abhängigkeiten (z. B. Ihre Datenbank) zu isolieren und die Interaktionen an den Grenzen zwischen den Schichten zu testen:
Die meisten iOS-Apps sind nach MVC (Model-View-Controller) aufgebaut. Die Verwendung von MVC als Anwendungsarchitektur kann dazu führen, dass man denkt, jede Klasse sei entweder ein Model, ein View oder ein Controller. Da ein Großteil der Anwendungslogik nicht in ein Model oder eine View gehört, landet sie normalerweise im Controller. Dies führt zu einem Problem, das als massiver View-Controller bekannt ist, bei dem die View-Controller am Ende zu viel tun. Die Verschlankung dieser massiven View-Controller ist nicht die einzige Herausforderung, mit der iOS-Entwickler konfrontiert sind, die die Qualität ihres Codes verbessern wollen, aber es ist ein guter Ausgangspunkt.
Die verschiedenen Schichten von VIPER helfen bei der Bewältigung dieser Herausforderung, indem sie klare Stellen für Anwendungslogik und navigationsbezogenen Code vorsehen. Wenn Sie VIPER anwenden, werden Sie feststellen, dass die View-Controller in unserem Beispiel für die Aufgabenliste schlanke, gemeine View-Controlling-Maschinen sind. Sie werden auch feststellen, dass der Code in den View-Controllern und allen anderen Klassen leicht zu verstehen, leichter zu testen und folglich auch leichter zu warten ist.
Anwendungsdesign auf der Grundlage von Anwendungsfällen
Anwendungen werden oft als eine Reihe von Anwendungsfällen implementiert. Anwendungsfälle werden auch als Akzeptanzkriterien oder Verhaltensweisen bezeichnet und beschreiben, was eine Anwendung tun soll. Vielleicht muss eine Liste nach Datum, Typ oder Name sortierbar sein. Das ist ein Anwendungsfall. Ein Anwendungsfall ist die Schicht einer Anwendung, die für die Geschäftslogik verantwortlich ist. Anwendungsfälle sollten unabhängig von der Implementierung der Benutzeroberfläche sein. Außerdem sollten sie klein und klar definiert sein. Die Entscheidung, wie man eine komplexe Anwendung in kleinere Anwendungsfälle aufteilt, ist eine Herausforderung und erfordert Übung, aber es ist ein hilfreicher Weg, um den Umfang jedes Problems, das Sie lösen, und jeder Klasse, die Sie schreiben, zu begrenzen.
Die Erstellung einer Anwendung mit VIPER beinhaltet die Implementierung einer Reihe von Komponenten, um jeden Anwendungsfall zu erfüllen. Die Anwendungslogik ist ein wichtiger Teil der Implementierung eines Anwendungsfalls, aber sie ist nicht der einzige Teil. Der Anwendungsfall wirkt sich auch auf die Benutzeroberfläche aus. Darüber hinaus ist es wichtig zu berücksichtigen, wie der Anwendungsfall mit anderen Kernkomponenten einer Anwendung zusammenpasst, z. B. mit Netzwerken und Datenpersistenz. Komponenten wirken wie Plugins für die Anwendungsfälle, und VIPER ist eine Möglichkeit, die Rolle jeder dieser Komponenten zu beschreiben und wie sie miteinander interagieren können.
Einer der Anwendungsfälle oder Anforderungen für unsere Aufgabenlisten-App war es, die Aufgaben auf der Grundlage der Auswahl eines Benutzers auf unterschiedliche Weise zu gruppieren. Indem wir die Logik, die diese Daten organisiert, in einen Anwendungsfall aufteilen, können wir den Code der Benutzeroberfläche sauber halten und den Anwendungsfall leicht in Tests verpacken, um sicherzustellen, dass er weiterhin so funktioniert, wie wir es erwarten.
Hauptbestandteile von VIPER
Die Hauptbestandteile von VIPER sind:
- View: zeigt an, was der Presenter ihm sagt und leitet Benutzereingaben an den Presenter zurück.
- Interactor: enthält die Geschäftslogik, wie sie in einem Anwendungsfall angegeben ist.
- Presenter: enthält die Ansichtslogik zur Vorbereitung des Inhalts für die Anzeige (wie vom Interactor empfangen) und zur Reaktion auf Benutzereingaben (durch Anforderung neuer Daten vom Interactor).
- Entity: enthält grundlegende Modellobjekte, die vom Interactor verwendet werden.
- Routing: enthält die Navigationslogik zur Beschreibung, welche Bildschirme in welcher Reihenfolge angezeigt werden.
Diese Trennung entspricht auch dem Prinzip der einzigen Verantwortung. Der Interactor ist für den Business-Analysten verantwortlich, der Presenter repräsentiert den Interaktionsdesigner und die View ist für den visuellen Designer verantwortlich.
Nachfolgend ein Diagramm der verschiedenen Komponenten und wie sie miteinander verbunden sind:
Während die Komponenten von VIPER in einer Anwendung in beliebiger Reihenfolge implementiert werden können, haben wir uns entschieden, die Komponenten in der Reihenfolge vorzustellen, in der wir ihre Implementierung empfehlen. Sie werden feststellen, dass diese Reihenfolge in etwa mit dem Prozess der Erstellung einer gesamten Anwendung übereinstimmt, der damit beginnt, zu erörtern, was das Produkt tun muss, gefolgt von der Art und Weise, wie ein Benutzer damit interagieren wird.
Interactor
Ein Interactor stellt einen einzelnen Anwendungsfall in der Anwendung dar. Er enthält die Geschäftslogik zur Manipulation von Modellobjekten (Entities), um eine bestimmte Aufgabe auszuführen. Die Arbeit in einem Interactor sollte unabhängig von einer Benutzeroberfläche sein. Derselbe Interactor könnte in einer iOS-App oder einer OS X-App verwendet werden.
Da der Interactor ein PONSO (Plain Old NSObject
) ist, der in erster Linie Logik enthält, lässt er sich leicht mit TDD entwickeln.
Der primäre Anwendungsfall für die Beispiel-App ist es, dem Benutzer alle anstehenden Aufgaben anzuzeigen (d.h. alles, was bis Ende nächster Woche fällig ist). Die Geschäftslogik für diesen Anwendungsfall besteht darin, alle Aufgaben zu finden, die zwischen heute und Ende nächster Woche fällig sind, und ihnen ein relatives Fälligkeitsdatum zuzuweisen: heute, morgen, später in dieser Woche oder nächste Woche.
Nachfolgend finden Sie die entsprechende Methode aus VTDListInteractor
:
- (void)findUpcomingItems{ __weak typeof(self) welf = self; NSDate* today = ; NSDate* endOfNextWeek = dateForEndOfFollowingWeekWithDate:today]; ]; }];}
Entity
Entities sind die Modellobjekte, die von einem Interactor bearbeitet werden. Entitäten werden nur vom Interactor bearbeitet. Der Interactor übergibt Entitäten niemals an die Präsentationsschicht (d.h. den Presenter).
Entitäten sind in der Regel auch PONSOs. Wenn Sie Core Data verwenden, werden Sie wollen, dass Ihre verwalteten Objekte hinter der Datenschicht bleiben. Interactors sollten nicht mit NSManagedObjects
arbeiten.
Hier ist die Entität für unser To-Do-Element:
@interface VTDTodoItem : NSObject@property (nonatomic, strong) NSDate* dueDate;@property (nonatomic, copy) NSString* name;+ (instancetype)todoItemWithDueDate:(NSDate*)dueDate name:(NSString*)name;@end
Wundern Sie sich nicht, wenn Ihre Entitäten nur Datenstrukturen sind. Jegliche anwendungsabhängige Logik wird sich höchstwahrscheinlich in einem Interactor befinden.
Presenter
Der Presenter ist ein PONSO, das hauptsächlich aus Logik zur Steuerung der Benutzeroberfläche besteht. Er weiß, wann er die Benutzeroberfläche präsentieren muss. Er sammelt Eingaben von Benutzerinteraktionen, damit er die Benutzeroberfläche aktualisieren und Anfragen an einen Interaktor senden kann.
Wenn der Benutzer auf die Schaltfläche „+“ tippt, um einen neuen Aufgabeneintrag hinzuzufügen, wird addNewEntry
aufgerufen. Für diese Aktion bittet der Presenter den Wireframe, die Benutzeroberfläche für das Hinzufügen eines neuen Elements zu präsentieren:
- (void)addNewEntry{ ;}
Der Presenter empfängt auch Ergebnisse von einem Interactor und konvertiert die Ergebnisse in eine Form, die effizient in einer Ansicht angezeigt werden kann.
Nachfolgend ist die Methode, die kommende Elemente vom Interactor empfängt. Sie verarbeitet die Daten und bestimmt, was dem Benutzer angezeigt werden soll:
- (void)foundUpcomingItems:(NSArray*)upcomingItems{ if ( == 0) { ; } else { ; }}
Einheiten werden niemals vom Interactor an den Presenter übergeben. Stattdessen werden einfache Datenstrukturen, die kein Verhalten haben, vom Interactor an den Presenter weitergegeben. Dadurch wird verhindert, dass im Presenter „echte Arbeit“ geleistet wird. Der Presenter kann die Daten nur für die Anzeige in der Ansicht vorbereiten.
Ansicht
Die Ansicht ist passiv. Sie wartet darauf, dass der Presenter ihr Inhalte zur Anzeige gibt; sie fragt den Presenter nie nach Daten. Methoden, die für eine View definiert sind (z.B. LoginView für einen Anmeldebildschirm), sollten es dem Präsentator erlauben, auf einer höheren Abstraktionsebene zu kommunizieren, ausgedrückt durch den Inhalt und nicht durch die Art und Weise, wie dieser Inhalt angezeigt werden soll. Der Präsentator weiß nichts von der Existenz von UILabel
, UIButton
, etc. Der Präsentator weiß nur, welche Inhalte er verwaltet und wann sie angezeigt werden sollen. Es ist Sache der View, zu bestimmen, wie der Inhalt angezeigt wird.
Die View ist eine abstrakte Schnittstelle, die in Objective-C mit einem Protokoll definiert ist. Eine UIViewController
oder eine ihrer Unterklassen wird das View-Protokoll implementieren. Der Bildschirm „Hinzufügen“ aus unserem Beispiel hat beispielsweise die folgende Schnittstelle:
@protocol VTDAddViewInterface <NSObject>- (void)setEntryName:(NSString *)name;- (void)setEntryDueDate:(NSDate *)date;@end
Views und View-Controller verarbeiten auch Benutzerinteraktionen und Eingaben. Es ist leicht zu verstehen, warum View-Controller in der Regel so groß werden, denn sie sind der einfachste Ort, um diese Eingaben zu verarbeiten und eine Aktion auszuführen. Um unsere View-Controller schlank zu halten, müssen wir ihnen eine Möglichkeit geben, interessierte Parteien zu informieren, wenn ein Benutzer bestimmte Aktionen ausführt. Der View-Controller sollte keine Entscheidungen auf der Grundlage dieser Aktionen treffen, aber er sollte diese Ereignisse an etwas weitergeben, das dies kann.
In unserem Beispiel hat der View-Controller „Hinzufügen“ eine Ereignishandler-Eigenschaft, die der folgenden Schnittstelle entspricht:
@protocol VTDAddModuleInterface <NSObject>- (void)cancelAddAction;- (void)saveAddActionWithName:(NSString *)name dueDate:(NSDate *)dueDate@end
Wenn der Benutzer auf die Schaltfläche „Abbrechen“ tippt, teilt der View-Controller diesem Ereignishandler mit, dass der Benutzer angegeben hat, dass er die Aktion „Hinzufügen“ abbrechen soll. Auf diese Weise kann sich der Event-Handler darum kümmern, den View-Controller für das Hinzufügen abzubrechen und die Listenansicht zu aktualisieren.
Die Grenze zwischen der Ansicht und dem Präsentator ist ebenfalls ein guter Ort für ReactiveCocoa. In diesem Beispiel könnte der View-Controller auch Methoden bereitstellen, um Signale zurückzugeben, die Button-Aktionen darstellen. Dies würde es dem Presenter ermöglichen, einfach auf diese Signale zu reagieren, ohne die Trennung der Verantwortlichkeiten aufzuheben.
Routing
Routen von einem Bildschirm zum anderen werden in den Wireframes definiert, die von einem Interaktionsdesigner erstellt werden. In VIPER wird die Verantwortung für das Routing zwischen zwei Objekten geteilt: dem Presenter und dem Wireframe. Ein Wireframe-Objekt ist Eigentümer von UIWindow
, UINavigationController
, UIViewController
, etc. Es ist verantwortlich für die Erstellung eines View/ViewControllers und dessen Installation im Fenster.
Da der Presenter die Logik enthält, um auf Benutzereingaben zu reagieren, weiß der Presenter, wann er zu einem anderen Bildschirm navigieren muss und zu welchem Bildschirm er navigieren muss. Das Drahtgitter weiß hingegen, wie es zu navigieren ist. Der Presenter verwendet also das Drahtgitter, um die Navigation durchzuführen. Zusammen beschreiben sie einen Weg von einem Bildschirm zum nächsten.
Das Drahtgitter ist auch ein idealer Ort, um Animationen für Navigationsübergänge zu verwenden. Sehen Sie sich dieses Beispiel aus dem Drahtgitter an:
@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
Die Anwendung verwendet einen benutzerdefinierten View-Controller-Übergang, um den Add-View-Controller zu präsentieren. Da der Wireframe für die Durchführung des Übergangs verantwortlich ist, wird er zum Übergangsdelegaten für den Add-View-Controller und kann die entsprechenden Übergangsanimationen zurückgeben.
Anwendungskomponenten, die zu VIPER passen
Eine iOS-Anwendungsarchitektur muss die Tatsache berücksichtigen, dass UIKit und Cocoa Touch die wichtigsten Werkzeuge sind, auf denen Anwendungen aufgebaut werden. Die Architektur muss friedlich mit allen Komponenten der Anwendung koexistieren, aber sie muss auch Richtlinien dafür bereitstellen, wie einige Teile der Frameworks verwendet werden und wo sie sich befinden.
Das Arbeitspferd einer iOS-App ist UIViewController
. Es wäre leicht anzunehmen, dass ein Anwärter auf die Ablösung von MVC sich davor scheuen würde, viel Gebrauch von View-Controllern zu machen. Aber View-Controller sind für die Plattform von zentraler Bedeutung: Sie handhaben Ausrichtungsänderungen, reagieren auf Eingaben des Benutzers, lassen sich gut in Systemkomponenten wie Navigationscontroller integrieren und ermöglichen jetzt mit iOS 7 anpassbare Übergänge zwischen Bildschirmen. Sie sind äußerst nützlich.
Bei VIPER tut ein View-Controller genau das, wofür er gedacht ist: Er steuert die Ansicht. Unsere To-Do-Listen-App hat zwei View-Controller, einen für den Listen-Bildschirm und einen für den Hinzufügen-Bildschirm. Die Implementierung des View-Controllers für das Hinzufügen ist äußerst einfach, da er nur die Ansicht steuern muss:
@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 sind normalerweise viel überzeugender, wenn sie mit dem Netzwerk verbunden sind. Aber wo sollte diese Vernetzung stattfinden und wer sollte dafür verantwortlich sein, sie zu initiieren? Normalerweise ist es Aufgabe des Interactors, eine Netzwerkoperation zu initiieren, aber er wird den Netzwerkcode nicht direkt verarbeiten. Er wird eine Abhängigkeit, wie z.B. einen Netzwerkmanager oder einen API-Client fragen. Der Interactor muss möglicherweise Daten aus mehreren Quellen zusammenführen, um die für einen Anwendungsfall erforderlichen Informationen bereitzustellen. Anschließend ist es Aufgabe des Presenters, die vom Interactor zurückgegebenen Daten zu übernehmen und für die Präsentation zu formatieren.
Ein Datenspeicher ist für die Bereitstellung von Entitäten für einen Interactor verantwortlich. Wenn ein Interactor seine Geschäftslogik anwendet, muss er Entitäten aus dem Datenspeicher abrufen, die Entitäten bearbeiten und dann die aktualisierten Entitäten wieder in den Datenspeicher einfügen. Der Datenspeicher verwaltet die Persistenz der Entitäten. Entitäten kennen den Datenspeicher nicht, daher wissen Entitäten nicht, wie sie selbst persistieren können.
Der Interactor sollte auch nicht wissen, wie er die Entitäten persistieren kann. Manchmal möchte der Interactor einen Objekttyp verwenden, der Datenmanager genannt wird, um seine Interaktion mit dem Datenspeicher zu erleichtern. Der Datenmanager übernimmt mehr speicherspezifische Operationen, wie z.B. das Erstellen von Abrufanforderungen, das Erstellen von Abfragen, usw. Dadurch kann sich der Interactor mehr auf die Anwendungslogik konzentrieren und muss nichts darüber wissen, wie Entitäten gesammelt oder persistiert werden. Ein Beispiel dafür, wann es sinnvoll ist, einen Datenmanager zu verwenden, ist bei der Verwendung von Core Data, die im Folgenden beschrieben wird.
Hier ist die Schnittstelle für den Datenmanager der Beispielanwendung:
@interface VTDListDataManager : NSObject@property (nonatomic, strong) VTDCoreDataStore *dataStore;- (void)todoItemsBetweenStartDate:(NSDate *)startDate endDate:(NSDate *)endDate completionBlock:(void (^)(NSArray *todoItems))completionBlock;@end
Wenn man TDD verwendet, um einen Interactor zu entwickeln, ist es möglich, den Produktionsdatenspeicher durch einen Test-Double/Mock zu ersetzen. Dadurch, dass Sie nicht mit einem entfernten Server sprechen (bei einem Webdienst) oder die Festplatte berühren (bei einer Datenbank), können Ihre Tests schneller und wiederholbarer sein.
Ein Grund für die Beibehaltung des Datenspeichers als eigenständige Schicht mit klaren Grenzen ist, dass Sie so die Wahl einer bestimmten Persistenztechnologie hinauszögern können. Wenn Ihr Datenspeicher eine einzelne Klasse ist, können Sie Ihre Anwendung mit einer grundlegenden Persistenzstrategie beginnen und später auf SQLite oder Core Data aufrüsten, wenn dies sinnvoll ist, ohne etwas anderes an der Codebasis Ihrer Anwendung zu ändern.
Die Verwendung von Core Data in einem iOS-Projekt kann oft mehr Diskussionen auslösen als die Architektur selbst. Die Verwendung von Core Data mit VIPER kann jedoch die beste Core Data-Erfahrung sein, die Sie je gemacht haben. Core Data ist ein großartiges Werkzeug, um Daten zu persistieren und gleichzeitig einen schnellen Zugriff und einen geringen Speicherbedarf zu gewährleisten. Aber es hat die Angewohnheit, seine NSManagedObjectContext
Ranken überall in die Implementierungsdateien einer Anwendung zu schlängeln, besonders dort, wo sie nicht sein sollten. VIPER hält Core Data dort, wo es hingehört: in der Datenspeicherschicht.
Im Beispiel der Aufgabenliste sind die einzigen beiden Teile der Anwendung, die wissen, dass Core Data verwendet wird, der Datenspeicher selbst, der den Core Data-Stack einrichtet, und der Datenmanager. Der Datenmanager führt eine Abrufanforderung durch, konvertiert die vom Datenspeicher zurückgegebenen NSManagedObjects
in Standard-PONSO-Modellobjekte und übergibt diese zurück an die Geschäftslogikschicht. Auf diese Weise ist der Kern der Anwendung nie von Core Data abhängig, und als Bonus müssen Sie sich nie Sorgen machen, dass veraltete oder schlecht geführte NSManagedObjects
den Betrieb stören.
So sieht es innerhalb des Datenmanagers aus, wenn eine Anfrage für den Zugriff auf den Core Data-Speicher gestellt wird:
@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
Nahezu so umstritten wie Core Data sind UI Storyboards. Storyboards haben viele nützliche Funktionen, und es wäre ein Fehler, sie völlig zu ignorieren. Es ist jedoch schwierig, alle Ziele von VIPER zu erreichen und gleichzeitig alle Funktionen zu nutzen, die ein Storyboard zu bieten hat.
Der Kompromiss, den wir in der Regel eingehen, besteht darin, keine Segues zu verwenden. Es mag einige Fälle geben, in denen die Verwendung von Segues sinnvoll ist, aber die Gefahr bei Segues ist, dass sie es sehr schwierig machen, die Trennung zwischen Bildschirmen – sowie zwischen UI und Anwendungslogik – intakt zu halten. Als Faustregel gilt, dass wir versuchen, keine Segues zu verwenden, wenn die Implementierung der prepareForSegue-Methode notwendig erscheint.
Andererseits sind Storyboards eine großartige Möglichkeit, das Layout für Ihre Benutzeroberfläche zu implementieren, insbesondere bei Verwendung von Auto-Layout. Wir haben uns dafür entschieden, beide Bildschirme für das Beispiel der Aufgabenliste mit einem Storyboard zu implementieren und Code wie den folgenden zu verwenden, um unsere eigene Navigation durchzuführen:
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
Using VIPER to Build Modules
Wenn Sie mit VIPER arbeiten, werden Sie oft feststellen, dass ein Bildschirm oder eine Reihe von Bildschirmen dazu neigt, als ein Modul zusammenzufassen. Ein Modul kann auf verschiedene Weise beschrieben werden, aber in der Regel ist es am besten als eine Funktion zu verstehen. In einer Podcasting-App könnte ein Modul der Audio-Player oder der Abonnement-Browser sein. In unserer To-Do-Listen-App sind die Listen- und Hinzufügen-Bildschirme jeweils als separate Module aufgebaut.
Es gibt einige Vorteile, wenn Sie Ihre App als eine Reihe von Modulen entwerfen. Einer davon ist, dass Module sehr klare und gut definierte Schnittstellen haben können und unabhängig von anderen Modulen sind. Das macht es viel einfacher, Funktionen hinzuzufügen/zu entfernen oder die Art und Weise zu ändern, wie die Schnittstelle verschiedene Module dem Benutzer präsentiert.
Wir wollten die Trennung zwischen den Modulen im Beispiel der Aufgabenliste sehr deutlich machen, also haben wir zwei Protokolle für das Hinzufügungsmodul definiert. Das erste ist die Modulschnittstelle, die definiert, was das Modul tun kann. Das zweite ist der Moduldelegat, der beschreibt, was das Modul getan hat. Beispiel:
@protocol VTDAddModuleInterface <NSObject>- (void)cancelAddAction;- (void)saveAddActionWithName:(NSString *)name dueDate:(NSDate *)dueDate;@end@protocol VTDAddModuleDelegate <NSObject>- (void)addModuleDidCancelAddAction;- (void)addModuleDidSaveAddAction;@end
Da ein Modul präsentiert werden muss, um für den Benutzer von großem Wert zu sein, implementiert der Präsentator des Moduls normalerweise die Modulschnittstelle. Wenn ein anderes Modul dieses Modul präsentieren möchte, implementiert sein Präsentator das Modul-Delegate-Protokoll, so dass er weiß, was das Modul getan hat, während es präsentiert wurde.
Ein Modul kann eine gemeinsame Anwendungslogikschicht aus Entitäten, Interaktoren und Managern enthalten, die für mehrere Bildschirme verwendet werden kann. Dies hängt natürlich von der Interaktion zwischen diesen Bildschirmen ab und davon, wie ähnlich sie sind. Ein Modul kann genauso gut nur ein einziges Bild darstellen, wie das Beispiel der Aufgabenliste zeigt. In diesem Fall kann die Anwendungslogikschicht sehr spezifisch für das Verhalten des jeweiligen Moduls sein.
Module sind auch einfach eine gute Möglichkeit, Code zu organisieren. Wenn Sie den gesamten Code für ein Modul in einem eigenen Ordner und einer eigenen Gruppe in Xcode aufbewahren, ist er leicht zu finden, wenn Sie etwas ändern müssen. Es ist ein großartiges Gefühl, wenn man eine Klasse genau dort findet, wo man sie gesucht hat.
Ein weiterer Vorteil beim Erstellen von Modulen mit VIPER ist, dass sie sich leichter auf mehrere Formfaktoren erweitern lassen. Da die Anwendungslogik für alle Anwendungsfälle auf der Interactor-Schicht isoliert ist, können Sie sich auf die Erstellung der neuen Benutzeroberfläche für Tablets, Telefone oder Macs konzentrieren, während Sie Ihre Anwendungsschicht wiederverwenden.
Wenn man einen Schritt weiter geht, kann die Benutzeroberfläche für iPad-Apps einige der Views, View-Controller und Presenter der iPhone-App wiederverwenden. In diesem Fall würde ein iPad-Bildschirm durch „Super“-Presenter und Wireframes dargestellt werden, die den Bildschirm unter Verwendung vorhandener Presenter und Wireframes, die für das iPhone geschrieben wurden, zusammensetzen würden. Eine App für mehrere Plattformen zu entwickeln und zu pflegen, kann eine ziemliche Herausforderung sein, aber eine gute Architektur, die die Wiederverwendung in der Modell- und Anwendungsschicht fördert, macht dies viel einfacher.
Testen mit VIPER
Das Befolgen von VIPER fördert eine Trennung der Belange, die es einfacher macht, TDD zu übernehmen. Der Interactor enthält reine Logik, die unabhängig von der Benutzeroberfläche ist, was die Steuerung durch Tests erleichtert. Der Presenter enthält Logik zur Vorbereitung von Daten für die Anzeige und ist unabhängig von UIKit-Widgets. Die Entwicklung dieser Logik ist ebenfalls einfach mit Tests zu steuern.
Unsere bevorzugte Methode ist es, mit dem Interactor zu beginnen. Alles in der UI ist dazu da, die Bedürfnisse des Anwendungsfalls zu erfüllen. Wenn Sie TDD verwenden, um die API für den Interactor zu testen, erhalten Sie ein besseres Verständnis der Beziehung zwischen der Benutzeroberfläche und dem Anwendungsfall.
Als Beispiel betrachten wir den Interactor, der für die Liste der anstehenden Aufgaben zuständig ist. Die Richtlinie für die Suche nach anstehenden Aufgaben besteht darin, alle Aufgaben zu finden, die bis zum Ende der nächsten Woche fällig sind, und jede Aufgabe als heute, morgen, im Laufe dieser Woche oder nächste Woche fällig zu klassifizieren.
Der erste Test, den wir schreiben, soll sicherstellen, dass der Interactor alle Aufgaben findet, die bis Ende nächster Woche fällig sind:
- (void)testFindingUpcomingItemsRequestsAllToDoItemsFromTodayThroughEndOfNextWeek{ todoItemsBetweenStartDate:self.today endDate:self.endOfNextWeek completionBlock:OCMOCK_ANY]; ;}
Wenn wir wissen, dass der Interactor nach den richtigen Aufgaben fragt, schreiben wir mehrere Tests, um zu bestätigen, dass er die Aufgaben der richtigen relativen Datumsgruppe zuordnet (z.z.B. heute, morgen, etc.):
- (void)testFindingUpcomingItemsWithOneItemDueTodayReturnsOneUpcomingItemsForToday{ NSArray *todoItems = @]; ; NSArray *upcomingItems = @]; ; ;}
Nun, da wir wissen, wie die API für den Interactor aussieht, können wir den Presenter entwickeln. Wenn der Presenter anstehende Aufgaben vom Interactor empfängt, wollen wir testen, dass die Daten richtig formatiert und in der Benutzeroberfläche angezeigt werden:
- (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 = ; ];}
Wir wollen auch testen, dass die App die entsprechende Aktion startet, wenn der Benutzer eine neue Aufgabe hinzufügen möchte:
- (void)testAddNewToDoItemActionPresentsAddToDoUI{ presentAddInterface]; ;}
Wir können nun die Ansicht entwickeln. Wenn keine Aufgaben anstehen, wollen wir eine spezielle Meldung anzeigen:
- (void)testShowingNoContentMessageShowsNoContentView{ ; XCTAssertEqualObjects(self.view.view, self.view.noContentView, @"the no content view should be the view");}
Wenn Aufgaben anstehen, wollen wir sicherstellen, dass die Tabelle angezeigt wird:
- (void)testShowingUpcomingItemsShowsTableView{ ; XCTAssertEqualObjects(self.view.view, self.view.tableView, @"the table view should be the view");}
Das Entwickeln des Interactors zuerst ist eine natürliche Anpassung an TDD. Wenn Sie den Interactor zuerst entwickeln, gefolgt von dem Presenter, können Sie eine Reihe von Tests um diese Schichten herum aufbauen und die Grundlage für die Implementierung dieser Anwendungsfälle legen. Sie können schnell über diese Klassen iterieren, weil Sie nicht mit der Benutzeroberfläche interagieren müssen, um sie zu testen. Wenn Sie dann die Ansicht entwickeln, haben Sie eine funktionierende und getestete Logik- und Präsentationsschicht, die Sie mit ihr verbinden können. Wenn Sie mit der Entwicklung der Ansicht fertig sind, werden Sie vielleicht feststellen, dass beim ersten Start der Anwendung alles einfach funktioniert, weil alle Ihre bestandenen Tests Ihnen sagen, dass es funktionieren wird.
Abschluss
Wir hoffen, dass Ihnen diese Einführung in VIPER gefallen hat. Viele von Ihnen fragen sich jetzt vielleicht, wie es weitergehen soll. Wenn Sie Ihre nächste Anwendung mit VIPER entwickeln wollten, wo würden Sie anfangen?
Dieser Artikel und unsere Beispielimplementierung einer Anwendung mit VIPER sind so spezifisch und klar definiert, wie wir sie machen konnten. Unsere To-Do-Listen-App ist recht einfach, aber sie sollte auch genau erklären, wie man eine App mit VIPER erstellt. Wie genau Sie sich bei einem realen Projekt an dieses Beispiel halten, hängt von Ihren eigenen Herausforderungen und Zwängen ab. Unserer Erfahrung nach hat jedes unserer Projekte die Herangehensweise an die Verwendung von VIPER leicht variiert, aber alle haben sehr davon profitiert, es als Leitfaden für ihre Herangehensweise zu verwenden.
Es kann Fälle geben, in denen Sie aus verschiedenen Gründen von dem durch VIPER vorgegebenen Weg abweichen möchten. Vielleicht sind Sie auf ein Gewirr von „Hasen“-Objekten gestoßen, oder Ihre Anwendung würde von der Verwendung von Übergängen in Storyboards profitieren. Das ist in Ordnung. In diesen Fällen sollten Sie bei Ihrer Entscheidung den Geist dessen berücksichtigen, was VIPER darstellt. Im Kern ist VIPER eine Architektur, die auf dem Prinzip der einzigen Verantwortung basiert. Wenn Sie Probleme haben, denken Sie an dieses Prinzip, wenn Sie entscheiden, wie Sie weiter vorgehen wollen.
Sie fragen sich vielleicht auch, ob es möglich ist, VIPER in Ihrer bestehenden Anwendung zu verwenden. In diesem Fall sollten Sie eine neue Funktion mit VIPER entwickeln. Viele unserer bestehenden Projekte haben diesen Weg gewählt. Auf diese Weise können Sie ein Modul mit VIPER erstellen und auch bestehende Probleme erkennen, die die Einführung einer auf dem Prinzip der einzigen Verantwortung basierenden Architektur erschweren könnten.
Eines der großartigen Dinge bei der Entwicklung von Software ist, dass jede Anwendung anders ist und dass es auch verschiedene Möglichkeiten gibt, eine Anwendung zu entwickeln. Für uns bedeutet das, dass jede App eine neue Gelegenheit ist, zu lernen und neue Dinge auszuprobieren. Wenn Sie sich entschließen, VIPER auszuprobieren, werden Sie sicher auch ein paar neue Dinge lernen. Danke fürs Lesen.
Swift Addendum
Letzte Woche hat Apple auf der WWDC die Programmiersprache Swift als die Zukunft der Cocoa- und Cocoa Touch-Entwicklung vorgestellt. Es ist noch zu früh, um sich eine komplexe Meinung über die Swift-Sprache zu bilden, aber wir wissen, dass Sprachen einen großen Einfluss darauf haben, wie wir Software entwerfen und entwickeln. Wir haben beschlossen, unsere VIPER TODO-Beispielanwendung mit Swift neu zu schreiben, um zu erfahren, was dies für VIPER bedeutet. Bis jetzt gefällt uns, was wir sehen. Hier sind einige Funktionen von Swift, die unserer Meinung nach die Erfahrung bei der Erstellung von Anwendungen mit VIPER verbessern werden.
Strukturen
In VIPER verwenden wir kleine, leichtgewichtige Modellklassen, um Daten zwischen Schichten zu übergeben, z. B. vom Presenter zur View. Diese PONSOs sind in der Regel nur dazu gedacht, kleine Datenmengen zu transportieren, und sollen in der Regel nicht unterklassifiziert werden. Swift-Strukturen sind für diese Situationen perfekt geeignet. Hier ist ein Beispiel für eine Struktur, die im VIPER Swift-Beispiel verwendet wird. Beachten Sie, dass diese Struktur gleichwertig sein muss und wir daher den ==-Operator überladen haben, um zwei Instanzen ihres Typs zu vergleichen:
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}
Typsicherheit
Der vielleicht größte Unterschied zwischen Objective-C und Swift ist die Art und Weise, wie die beiden mit Typen umgehen. Objective-C ist dynamisch typisiert und Swift ist absichtlich sehr streng in der Art und Weise, wie es die Typüberprüfung zur Kompilierzeit implementiert. Bei einer Architektur wie VIPER, bei der eine Anwendung aus mehreren unterschiedlichen Schichten besteht, kann Typsicherheit ein großer Gewinn für die Effizienz der Programmierer und die Struktur der Architektur sein. Der Compiler hilft Ihnen sicherzustellen, dass Container und Objekte vom richtigen Typ sind, wenn sie zwischen den Schichten weitergegeben werden. Dies ist ein großartiger Ort für die Verwendung von Structs, wie oben gezeigt. Wenn eine Struktur an der Grenze zwischen zwei Schichten leben soll, dann können Sie garantieren, dass sie dank der Typsicherheit niemals zwischen diesen Schichten entkommen kann.
Weitere Lektüre
- VIPER TODO, Artikel Beispiel-App
- VIPER SWIFT, Artikel Beispiel-App gebaut mit Swift
- Zähler, eine weitere Beispiel-App
- Mutual Mobile Einführung in VIPER
- Clean Architecture
- Lighter View Controllers
- Testing View Controllers
- Bunnies