Welcher iOS-Entwickler kennt es nicht: Je grösser ein Projekt wird, desto umfangreicher werden oft die View Controller. Darunter leidet die Testbarkeit des Codes, und durch die vielen Abhängigkeiten kosten spätere Anpassungen unnötig viel Zeit. Einige Gedanken zur iOS-Architektur.

Das Problem

Zu Beginn eines iOS-Projekts sind die Controller meist noch rund 30 Zeilen kurz und übersichtlich. Mit jedem neuen Feature kommen jedoch weitere Aufgaben hinzu: hier der Core-Location-Ortungsdienst, dort ein Core-Data-Managed-Context für die Persistierung. Schnell wächst der View Controller auf über 1000 Zeilen an, hat zahlreiche Abhängigkeiten und lässt sich kaum noch isoliert testen.

Die Ursache

Ein Grund liegt in der Bequemlichkeit im Entwicklungsalltag: Es geht zunächst schneller, einen bestehenden Controller zu erweitern, als eine eigene Klasse zu schreiben und diese per Dependency Injection einzubinden. Ein grosser Teil des Problems geht aber auch auf Apples MVC-Implementierung zurück. Ein Grundprinzip sauberer objektorientierter Programmierung ist das Single-Responsibility-Prinzip: Jede Klasse hat genau eine klar abgegrenzte Aufgabe. Das verringert Abhängigkeiten und erleichtert das Testen einzelner Komponenten.

In vielen iOS-Projekten übernimmt der View Controller dagegen viel zu viele Aufgaben gleichzeitig:

  • Verwaltung und Gestaltung der Views (oft direkt in viewDidLoad)
  • Implementierung sämtlicher View-Delegates (z. B. Reaktionen auf Textfelder)
  • Aufbau der Daten-Persistierung (Erstellen des Managed Object Context von Core Data)
  • Ausführen von Core-Data-Abfragen
  • Steuerung von UITableView samt DataSource und Delegate
  • Netzwerkaufrufe über NSURLConnection und NSURLConnectionDelegate

Betrachtet man das klassische MVC-Pattern, hat der Controller eigentlich nur zwei Aufgaben:

  • Auf Änderungen im Model reagieren
  • Auf Eingaben in der View reagieren und diese an das Model weitergeben

iOS Architektur: Lebenszyklus eines Controllers

Der Controller verbindet also Model und View, ohne dass diese beiden Schichten direkt voneinander abhängen.

Lösung

Um die Controller schlank zu halten, hilft ein klarer Grundsatz: Fat Model, Thin Controllers. So viel Logik wie möglich wandert in den Model-Layer, wo sie sich sauber auf spezialisierte Klassen aufteilen lässt. Eine gute Faustregel dafür ist das Szenario «Eine App, zwei Zielgeräte (iPhone und iPad)»: Alles, was auf beiden Geräten identisch funktioniert und nichts mit der Oberfläche zu tun hat, gehört in den Model-Layer.

View-Code gehört nicht in den Controller

Ein häufiger Missbrauch ist Layout- und Darstellungs-Code direkt im View Controller:

-(void)viewDidLoad{
  self.submitButton.backgroundColor = [UIColor greenColor];
  self.messageLabel.font = [UIFont customFont];
}

Solcher Code gehört in eine eigene Klasse, die von UIView erbt. Der Controller hält dann lediglich die Referenz auf diese View:

-(void)viewDidLoad{
  [self.view loadView];
}

So muss der View Controller keine Details über Farben oder Schriften einzelner Buttons kennen, und die View lässt sich in anderen Controllern wiederverwenden.

UITableView und dessen Delegate / DataSource

Fast jede iOS-App nutzt UITableView. Auf dem Papier besteht eine Tabellenansicht aus drei Teilen: der UITableView selbst, der DataSource für die Bereitstellung der Daten und dem Delegate für Reaktionen auf Nutzeraktionen. In der Praxis hängen DataSource und Delegate bei Apples Protokollen jedoch so eng zusammen, dass eine Trennung der beiden meist mehr Aufwand als Nutzen bringt.

Sinnvoll ist es hingegen, DataSource und Delegate gemeinsam aus dem View Controller auszulagern – bei uns meist in eine eigene Klasse namens DataProvider. Diese Klasse liegt als Bindeglied zwischen View und Model: Sie nimmt die Daten entgegen und verarbeitet die Tabellen-Interaktionen.

Der Controller selbst instanziiert lediglich den DataProvider, übergibt ihm die geladenen Daten und verbindet ihn mit der UITableView. Weder kennt die UITableView die konkrete Implementierung des DataProvider, noch lädt der DataProvider seine Daten selbst. Zum Testen lassen sich ihm dadurch problemlos Testdaten übergeben:

-(void)viewDidLoad{
  [self initTableview:self.eventsList];
}

-(void)initTableview:(UITableView*)tableView{
  // hole zuerst Daten - woher sie kommen ist hier egal
  NSArray* events = [self.eventsMapper getAll];

  // erstelle einen Dataprovider und weise ihm die Daten zu
  id dataProvider = [[EventsDataProvider alloc] init];
  dataProvider.data = events;

  // verbinde Tableview mit DataProvider
  // der dataprovider ist sowohl dataSource als auch delegate
  // diese funktionalitäten lassen sich relativ schlecht trennen.
  tableView.dataSource = dataProvider;
  tableView.delegate = dataProvider;
}