
Hallo allerseits! Mein Name ist Ilya, ich bin ein iOS-Entwickler bei Tinkoff.ru. In diesem Artikel möchte ich darüber sprechen, wie die Codeduplizierung in der Präsentationsschicht mithilfe von Protokollen reduziert werden kann.
Was ist das Problem?
Wenn das Projekt wächst, nimmt die Anzahl der Codeduplikationen zu. Dies wird nicht sofort offensichtlich und es wird schwierig, die Fehler der Vergangenheit zu korrigieren. Wir haben dieses Problem in unserem Projekt bemerkt und es mit einem Ansatz gelöst. Nennen wir es bedingt Merkmale.
Lebensbeispiel
Der Ansatz kann mit verschiedenen Architekturlösungen verwendet werden, ich werde ihn jedoch am Beispiel von VIPER betrachten.
Betrachten Sie die häufigste Methode im Router - die Methode, mit der der Bildschirm geschlossen wird:
func close() { self.transitionHandler.dismiss(animated: true, completion: nil) }
Es ist in vielen Routern vorhanden, und es ist besser, es nur einmal zu schreiben.
Vererbung würde uns dabei helfen, aber in Zukunft, wenn wir mehr und mehr Klassen mit unnötigen Methoden in unserer Anwendung haben oder nicht in der Lage sind, die benötigte Klasse zu erstellen, da sich die erforderlichen Methoden in verschiedenen Basisklassen befinden, werden große Klassen angezeigt Probleme.
Infolgedessen wird das Projekt mit überflüssigen Methoden zu vielen Basisklassen und Nachkommenklassen heranwachsen. Vererbung wird uns nicht helfen.
Was ist besser als Vererbung? Natürlich die Komposition.
Sie können eine separate Klasse für die Methode erstellen, mit der der Bildschirm geschlossen wird, und sie jedem Router hinzufügen, in dem sie benötigt wird:
struct CloseRouter { let transitionHandler: UIViewController func close() { self.transitionHandler.dismiss(animated: true, completion: nil) } }
Wir müssen diese Methode noch im Eingabeprotokoll des Routers deklarieren und im Router selbst implementieren:
protocol SomeRouterInput { func close() } class SomeRouter: SomeRouterInput { var transitionHandler: UIViewController! lazy var closeRouter = { CloseRouter(transitionHandler: self. transitionHandler) }() func close() { self.closeRouter.close() } }
Es stellte sich heraus, dass zu viel Code den Aufruf einfach an die Methode close weiterleitet.
Lazy Good Programmierer wird es nicht zu schätzen wissen.
Protokolllösung
Protokolle kommen zur Rettung. Dies ist ein ziemlich leistungsfähiges Tool, mit dem Sie Kompositionen implementieren können und das möglicherweise Implementierungsmethoden in Erweiterung enthält. So können wir ein Protokoll erstellen, das die Methode close enthält, und es in Erweiterung implementieren.
So wird es aussehen:
protocol CloseRouterTrait { var transitionHandler: UIViewController! { get } func close() } extension CloseRouterTrait { func close() { self.transitionHandler.dismiss(animated: true, completion: nil) } }
Die Frage ist, warum das Wort Merkmal im Namen des Protokolls erscheint. Es ist ganz einfach: Sie können festlegen, dass dieses Protokoll seine Methoden in Erweiterung implementiert und als Beimischung zu einem anderen Typ verwendet werden soll, um seine Funktionalität zu erweitern.
Nun wollen wir sehen, wie die Verwendung eines solchen Protokolls aussehen wird:
class SomeRouter: CloseRouterTrait { var transitionHandler: UIViewController! }
Ja, das ist alles. Sieht gut aus :). Wir haben die Komposition erhalten, indem wir das Protokoll zur Klasse des Routers hinzugefügt haben, keine einzige zusätzliche Zeile geschrieben haben und die Möglichkeit hatten, den Code wiederzuverwenden.
Was ist ungewöhnlich an diesem Ansatz?
Möglicherweise haben Sie diese Frage bereits gestellt. Die Verwendung von Protokollen als Merkmal ist weit verbreitet. Der Hauptunterschied besteht darin, diesen Ansatz als architektonische Lösung innerhalb der Präsentationsschicht zu verwenden. Wie bei jeder architektonischen Lösung sollte es eigene Regeln und Empfehlungen geben.
Hier ist meine Liste:
- Eigenschaften sollten den Status nicht speichern und ändern. Sie können nur Abhängigkeiten in Form von Diensten usw. aufweisen, bei denen es sich nur um Get-Only-Eigenschaften handelt.
- Merkmale sollten keine Methoden haben, die nicht in Erweiterung implementiert sind, da dies gegen ihr Konzept verstößt
- Die Namen der Methoden in Merkmal sollten explizit widerspiegeln, was sie tun, ohne an den Protokollnamen gebunden zu sein. Dies hilft, Namenskollisionen zu vermeiden und den Code klarer zu machen.
Vom VIPER zum MVP
Wenn Sie vollständig auf diesen Ansatz mit Protokollen umsteigen, sehen die Router- und Interaktorklassen ungefähr so aus:
class SomeRouter: CloseRouterTrait, OtherRouterTrait { var transitionHandler: UIViewController! } class SomeInteractor: SomeInteractorTrait { var someService: SomeServiceInput! }
Dies gilt nicht für alle Klassen. In den meisten Fällen verfügt das Projekt lediglich über leere Router und Interaktoren. In diesem Fall können Sie die VIPER-Modulstruktur stören und problemlos zu MVP wechseln, indem Sie dem Präsentator Verunreinigungsprotokolle hinzufügen.
Ungefähr so:
class SomePresenter: CloseRouterTrait, OtherRouterTrait, SomeInteractorTrait, OtherInteractorTrait { var transitionHandler: UIViewController! var someService: SomeSericeInput! }
Ja, die Fähigkeit, Router und Interaktor als Abhängigkeiten zu implementieren, geht verloren, aber in einigen Fällen ist dies der Fall.
Der einzige Nachteil ist TransitionHandler = UIViewController. Und gemäß den Regeln von VIPER Presenter sollte nichts über die Ansichtsebene und deren Implementierung mit welchen Technologien bekannt sein. Dies wird in diesem Fall einfach gelöst - die Übergangsmethoden vom UIViewController werden vom Protokoll, beispielsweise TransitionHandler, "geschlossen". Presenter interagiert also mit der Abstraktion.
Verhalten von Merkmalen ändern
Mal sehen, wie Sie das Verhalten in solchen Protokollen ändern können. Dies ist ein Analogon zum Ersetzen einiger Teile des Moduls, beispielsweise für Tests oder einen temporären Stub.
Nehmen Sie als Beispiel einen einfachen Interaktor mit einer Methode, die eine Netzwerkanforderung ausführt:
protocol SomeInteractorTrait { var someService: SomeServiceInput! { get } func performRequest(completion: @escaping (Response) -> Void) } extension SomeInteractorTrait { func performRequest(completion: @escaping (Response) -> Void) { someService.performRequest(completion) } }
Dies ist beispielsweise ein abstrakter Code. Angenommen, wir müssen keine Anfrage senden, sondern nur eine Art Stub zurückgeben. Hier gehen wir zum Trick - erstellen Sie ein leeres Protokoll namens Mock und gehen Sie wie folgt vor:
protocol Mock {} extension SomeInteractorTrait where Self: Mock { func performRequest(completion: @escaping (Response) -> Void) { completion(MockResponse()) } }
Hier wurde die Implementierung der performRequest-Methode für Typen geändert, die das Mock-Protokoll implementieren. Jetzt müssen Sie das Mock-Protokoll für die Klasse implementieren, die SomeInteractor implementiert:
class SomePresenter: SomeInteractorTrait, Mock { // Implementation }
Für die SomePresenter-Klasse wird die Implementierung der performRequest-Methode aufgerufen, die sich in der Erweiterung befindet, in der Self das Mock-Protokoll erfüllt. Es lohnt sich, das Mock-Protokoll zu entfernen, und die Implementierung der performRequest-Methode wird von der üblichen Erweiterung auf SomeInteractor übernommen.
Wenn Sie dies nur für Tests verwenden, ist es besser, den gesamten Code, der mit dem Ersetzen der Implementierung verbunden ist, im Testziel zu platzieren.
Zusammenfassend
Zusammenfassend ist festzuhalten, welche Vor- und Nachteile dieser Ansatz hat und in welchen Fällen es sich meiner Meinung nach lohnt, ihn anzuwenden.
Beginnen wir mit den Nachteilen:
- Wenn Sie Router und Interaktor entfernen, wie im Beispiel gezeigt, geht die Fähigkeit zum Implementieren dieser Abhängigkeiten verloren.
- Ein weiteres Minus ist die stark zunehmende Anzahl von Protokollen.
- Manchmal sieht der Code nicht so klar aus wie bei herkömmlichen Ansätzen.
Die positiven Aspekte dieses Ansatzes sind wie folgt:
- Am wichtigsten und offensichtlichsten ist, dass Doppelarbeit stark reduziert wird.
- Die statische Bindung wird auf Protokollmethoden angewendet. Dies bedeutet, dass die Bestimmung der Implementierung der Methode in der Kompilierungsphase erfolgt. Daher wird während der Ausführung des Programms keine zusätzliche Zeit für die Suche nach einer Implementierung aufgewendet (obwohl diese Zeit nicht besonders wichtig ist).
- Aufgrund der Tatsache, dass die Protokolle kleine „Bausteine“ sind, kann jede Zusammensetzung leicht daraus zusammengesetzt werden. Plus im Karma für Flexibilität im Gebrauch.
- Einfaches Refactoring, kein Kommentar hier.
- Sie können diesen Ansatz in jeder Phase des Projekts verwenden, da er nicht das gesamte Projekt betrifft.
Diese Entscheidung für gut zu halten oder nicht, ist eine private Angelegenheit für alle. Unsere Erfahrung mit diesem Ansatz war positiv und löste Probleme.
Das ist alles!