Android-Multimodul-Architektur. Von A bis Z.

Hallo allerseits!

Vor nicht allzu langer Zeit haben wir erkannt, dass eine mobile Anwendung nicht nur ein Thin Client ist, sondern eine wirklich große Anzahl sehr unterschiedlicher Logik, die optimiert werden muss. Aus diesem Grund haben wir uns von den Ideen der sauberen Architektur inspirieren lassen, fühlen, was DI ist, haben gelernt, wie man Dolch 2 verwendet, und jetzt können wir mit geschlossenen Augen jedes Merkmal in Ebenen aufteilen.

Aber die Welt steht nicht still und mit der Lösung alter Probleme kommen neue. Und der Name dieses neuen Problems ist Monomodularität. Sie erfahren dieses Problem normalerweise, wenn die Montagezeit in den Weltraum vergeht. Genau so viele Berichte über den Übergang zur Multimodularität ( eins , zwei ) beginnen.
Aber aus irgendeinem Grund vergisst jeder gleichzeitig irgendwie, dass die Monomodularität nicht nur die Montagezeit, sondern auch Ihre Architektur stark beeinflusst. Hier beantworten Sie die Fragen. Wie groß ist Ihre AppComponent? Sehen Sie im Code regelmäßig, dass Feature A aus irgendeinem Grund das Repository von Feature B abruft, obwohl es nicht so aussehen sollte, oder sollte es irgendwie mehr Top-Level sein? Haben Features einen Vertrag? Und wie organisieren Sie die Kommunikation zwischen Funktionen? Gibt es Regeln?
Sie haben das Gefühl, dass wir das Problem mit den Ebenen gelöst haben, das heißt, vertikal scheint alles in Ordnung zu sein, aber horizontal läuft etwas schief? Und nur die Pakete aufzuschlüsseln und die Überprüfung zu kontrollieren, löst das Problem nicht.

Und die Sicherheitsfrage für die Erfahreneren. Mussten Sie bei der Umstellung auf Multimodularität nicht die Hälfte der Anwendung schaufeln, den Code immer von einem Modul auf ein anderes ziehen und einen angemessenen Zeitraum mit einem nicht zusammengestellten Projekt verbringen?

In meinem Artikel möchte ich Ihnen erzählen, wie ich genau aus architektonischer Sicht zur Multimodularität gekommen bin. Welche Probleme haben mich gestört und wie ich versucht habe, sie schrittweise zu lösen. Und am Ende finden Sie einen Algorithmus für den Wechsel von Monomodularität zu Multimodularität ohne Tränen und Schmerzen.

Bei der Beantwortung der ersten Frage, wie groß die AppComponent ist, kann ich gestehen - groß, wirklich groß. Und es quälte mich ständig. Wie ist es passiert? Dies ist in erster Linie auf eine solche DI-Organisation zurückzuführen. Mit DI werden wir beginnen.

Wie ich es vorher getan habe


Ich denke, dass viele Menschen in ihren Köpfen so etwas wie dieses Diagramm der Abhängigkeiten der Komponenten und der entsprechenden Bereiche gebildet haben:


Was haben wir hier?


AppComponent , die absolut alle Abhängigkeiten mit Singleton- Bereichen absorbierte. Ich denke, fast jeder hat diese Komponente.

FeatureComponents . Jedes Feature hatte seinen eigenen Bereich und war eine Unterkomponente von AppComponent oder ein Senior-Feature.
Lassen Sie uns ein wenig auf die Funktionen eingehen. Was ist eine Funktion? Ich werde es in meinen eigenen Worten versuchen. Eine Funktion ist ein logisch vollständiges, maximal unabhängiges Programmmodul, das ein bestimmtes Benutzerproblem mit klar definierten externen Abhängigkeiten löst und in einem anderen Programm relativ einfach wieder verwendet werden kann. Features können groß und klein sein. Funktionen können andere Funktionen enthalten. Sie können auch andere Funktionen über klar definierte externe Abhängigkeiten verwenden oder ausführen. Wenn wir unsere Anwendung (Kaspersky Internet Security für Android) verwenden, können Funktionen als Anti-Virus, Anti-Theft usw. betrachtet werden.

ScreenComponents . Eine Komponente für einen bestimmten Bildschirm, die ebenfalls einen eigenen Bereich hat und gleichzeitig eine Unterkomponente der entsprechenden Feature-Komponente ist.

Nun eine Liste von "warum so"


Warum Unterkomponenten?
Bei Komponentenabhängigkeiten hat mir die Tatsache nicht gefallen, dass eine Komponente von mehreren Komponenten gleichzeitig abhängen kann, was meiner Meinung nach letztendlich zu einem Chaos der Komponenten und ihrer Abhängigkeiten führen kann. Wenn Sie eine strikte Eins-zu-Viele-Beziehung haben (eine Komponente und ihre Unterkomponenten), ist dies sicherer und offensichtlicher. Darüber hinaus stehen der Unterkomponente standardmäßig alle Abhängigkeiten des übergeordneten Elements zur Verfügung, was ebenfalls praktischer ist.

Warum gibt es für jede Funktion einen Bereich?
Weil ich dann von den Überlegungen ausgegangen bin, dass jedes Feature eine Art eigenen Lebenszyklus ist, der nicht mit dem anderer identisch ist, ist es logisch, einen eigenen Bereich zu erstellen. Es gibt noch einen Punkt für viele geizige Dinge, den ich unten erwähnen werde.

Da wir im Zusammenhang mit Clean über Dolch 2 sprechen, werde ich auch den Moment erwähnen, in dem Abhängigkeiten geliefert wurden. Präsentatoren, Interaktoren, Repositorys und andere Hilfsklassen von Abhängigkeiten wurden über den Konstruktor bereitgestellt. In Tests ersetzen wir dann Stubs oder Moki durch den Konstruktor und testen unsere Klasse leise.
Das Schließen des Abhängigkeitsgraphen erfolgt normalerweise in Aktivitäten, Fragmenten, manchmal Empfängern und Diensten im Allgemeinen an den Wurzelstellen, von denen aus der Android etwas starten kann. Die klassische Situation besteht darin, dass eine Aktivität für ein Feature erstellt wird, die Feature-Komponente startet und in der Aktivität lebt und im Feature selbst drei Bildschirme vorhanden sind, die in drei Fragmenten implementiert sind.

Alles scheint also logisch zu sein. Aber wie immer nimmt das Leben seine eigenen Anpassungen vor.

Lebensprobleme


Beispielaufgabe


Schauen wir uns ein einfaches Beispiel aus unserer Anwendung an. Wir haben eine Scannerfunktion und eine Diebstahlsicherung. Beide Funktionen haben einen geschätzten Kaufknopf. Darüber hinaus sendet „Kaufen“ nicht nur eine Anfrage, sondern auch eine Vielzahl unterschiedlicher Logik in Bezug auf den Kaufprozess. Dies ist eine reine Geschäftslogik mit einigen Dialogen für den sofortigen Kauf. Das heißt, es gibt eine ganz andere Funktion für sich - Kauf. Daher müssen wir in zwei Funktionen die dritte Funktion verwenden.
Aus Sicht der Benutzeroberfläche und der Navigation haben wir das folgende Bild. Der Hauptbildschirm startet mit zwei Tasten:


Durch Klicken auf diese Schaltflächen gelangen wir zur Funktion des Scanners oder der Diebstahlsicherung.
Betrachten Sie die Funktion des Scanners:


Durch Klicken auf "Antiviren-Scan starten" wird eine Art Scan-Arbeit erledigt. Wenn Sie auf "Kaufen" klicken, möchten wir nur kaufen, das heißt, wir ziehen die Funktion "Käufe" auf, aber auf "Hilfe" gelangen wir mit einer Hilfe zu einem einfachen Bildschirm.
Die Funktion von Anti-Theft sieht fast gleich aus.

Mögliche Lösungen


Wie implementieren wir dieses Beispiel in Bezug auf DI? Es gibt mehrere Möglichkeiten.

Erste Option


Wählen Sie eine Kauffunktion als unabhängige Komponente aus , die nur von der AppComponent abhängt .


Aber dann stehen wir vor dem Problem: Wie können Abhängigkeiten von zwei verschiedenen Graphen (Komponenten) gleichzeitig in eine Klasse eingefügt werden? Nur durch schmutzige Krücken, was natürlich so ist.

Zweite Option


Wir wählen die Kauffunktion in der Unterkomponente aus, die von der AppComponent abhängt. Und die Komponenten des Scanners und der Diebstahlsicherung können aus der Kaufkomponente zu Unterkomponenten gemacht werden.


Wie Sie verstehen, kann es in Anwendungen jedoch viele ähnliche Situationen geben. Dies bedeutet, dass die Tiefe der Abhängigkeiten der Komponenten sehr groß und komplex sein kann. Ein solches Diagramm ist verwirrender, als Ihre Anwendung kohärenter und verständlicher zu machen.

Dritte Option


Wir wählen die Kauffunktion nicht in einer separaten Komponente, sondern in einem separaten Dolchmodul . Zwei Wege sind weiter möglich.

Erster Weg
Fügen wir allen Abhängigkeiten die Singleton- Bereichsfunktionen hinzu und stellen eine Verbindung zur AppComponent her .


Die Option ist beliebt, führt jedoch zu einer Aufblähung der AppComponent . Infolgedessen nimmt die Größe zu, enthält alle Anwendungsklassen, und der springende Punkt bei der Verwendung von Dagger ist die bequemere Übermittlung von Abhängigkeiten an Klassen - über Felder oder den Konstruktor und nicht über Singletones. Im Prinzip ist dies DI, aber wir vermissen architektonische Punkte und es stellt sich heraus, dass jeder über jeden Bescheid weiß.
Wenn Sie zu Beginn des Pfads nicht wissen, wo Sie eine Klasse welchem ​​Feature zuordnen sollen, ist es im Allgemeinen einfacher, sie global zu gestalten. Dies ist häufig der Fall, wenn Sie mit Legacy arbeiten und versuchen, zumindest eine Art Architektur einzubringen. Außerdem kennen Sie den gesamten Code noch nicht gut. Und dort weiteten sich tatsächlich die Augen, und diese Handlungen sind gerechtfertigt. Der Fehler ist, dass niemand diese AppComponent in Angriff nehmen möchte, wenn sich mehr oder weniger alles abzeichnet.

Zweiter Weg
Dies ist eine Reduzierung aller Funktionen auf einen einzigen Bereich, z. B. PerFeature .


Dann können wir das Dolchmodul des Einkaufs einfach und einfach mit den notwendigen Komponenten verbinden.
Es scheint bequem. Aber architektonisch stellt sich heraus, nicht isoliert. Die Funktionen des Scanners und der Diebstahlsicherung wissen absolut alles über die Kauffunktion, alle Innereien. Es kann versehentlich etwas beteiligt sein. Das heißt, die Kauffunktion verfügt nicht über eine eindeutige API, die Grenze zwischen den Funktionen ist verschwommen und es gibt keinen eindeutigen Vertrag. Das ist schlecht. Nun, im multimodularen Bereich wird das Gredloid später schwierig sein.

Architektonischer Schmerz


Ehrlich gesagt habe ich lange Zeit die dritte Option verwendet. Den ersten Weg . Dies war eine notwendige Maßnahme, als wir begannen, unser Erbe schrittweise auf normale Schienen zu übertragen. Aber wie ich bereits erwähnt habe, beginnen sich bei diesem Ansatz Ihre Funktionen ein wenig zu vermischen. Jeder kann über jeden Bescheid wissen, über die Details der Implementierung und dies für jeden. Und das Aufblähen von AppComponent zeigte deutlich, dass etwas getan werden muss.
Übrigens würde die dritte Option beim Entladen von AppComponent helfen . Der zweite Weg . Das Wissen über Implementierungen und Mischfunktionen wird jedoch nirgendwo hingehen. Natürlich wäre es sehr schwierig, Funktionen zwischen Anwendungen wiederzuverwenden.

Zwischenschlussfolgerungen


Also, was wollen wir am Ende? Welche Probleme wollen wir lösen? Gehen wir gleich zum Punkt, beginnend mit DI und weiter zur Architektur:

  • Ein praktischer DI-Mechanismus, mit dem Sie Funktionen in anderen Funktionen verwenden können (in unserem Beispiel möchten wir die Einkaufsfunktion in Scanner und Diebstahlschutz verwenden), ohne Krücken und Schmerzen.
  • Die dünnste AppComponent.
  • Features sollten keine Implementierungen anderer Features kennen.
  • Die Funktionen sollten standardmäßig für niemanden zugänglich sein. Ich möchte einen strengen Kontrollmechanismus.
  • Es ist möglich, die Funktion mit einer minimalen Anzahl von Gesten einer anderen Anwendung zuzuweisen.
  • Ein logischer Übergang zu Multimodularität und Best Practices für diesen Übergang.

Ich habe erst ganz am Ende speziell über Multimodularität gesprochen. Wir werden sie erreichen, wir werden uns nicht übertreffen.

"Auf neue Weise leben"


Jetzt werden wir versuchen, die obige Wunschliste schrittweise umzusetzen.
Lass uns gehen!

DI-Verbesserungen


Beginnen wir mit dem gleichen DI.

Ablehnung einer großen Anzahl von Bereichen


Wie ich oben schrieb, war mein Ansatz vor diesem: für jedes Feature seinen eigenen Umfang. Tatsächlich gibt es daraus keine besonderen Gewinne. Erhalten Sie nur eine große Anzahl von Bereichen und eine gewisse Menge an Kopfschmerzen.
Diese Kette ist völlig ausreichend: Singleton - PerFeature - PerScreen .

Aufgabe von Unterkomponenten zugunsten von Komponentenabhängigkeiten


Schon ein interessanter Punkt. Mit Unterkomponenten scheinen Sie eine strengere Hierarchie zu haben, aber gleichzeitig haben Sie die Hände vollständig gebunden und es gibt keine Möglichkeit, irgendwie zu manövrieren. Darüber hinaus kennt AppComponent alle Funktionen und Sie erhalten eine riesige generierte Klasse DaggerAppComponent .
Mit Komponentenabhängigkeiten erhalten Sie einen super coolen Vorteil. In Komponentenabhängigkeiten können Sie keine Komponenten angeben , sondern saubere Schnittstellen (dank Denis und Volodya). Dank dieser Funktion können Sie beliebige Schnittstellenimplementierungen ersetzen. Dagger frisst alles. Auch wenn die Komponente mit demselben Umfang diese Implementierung ist:
@Component( dependencies = FeatureDependencies.class, modules = FeatureModule.class ) @PerFeature public abstract class FeatureComponent { // ... } public interface FeatureDependencies { SomeDependency someDependency(); } @Component( modules = AnotherFeatureModule.class ) @PerFeature public abstract class AnotherFeatureComponent implements FeatureDependencies { // ... } 


Von DI-Verbesserungen zu architektonischen Verbesserungen


Wiederholen wir die Definition von Features. Eine Funktion ist ein logisch vollständiges, maximal unabhängiges Programmmodul, das ein bestimmtes Benutzerproblem mit klar definierten externen Abhängigkeiten löst und in einem anderen Programm relativ einfach wiederverwendet werden kann. Einer der Schlüsselausdrücke bei der Definition eines Features ist „mit klar definierten externen Abhängigkeiten“. Beschreiben wir daher alles, was wir von der Außenwelt für Funktionen erwarten, in einer speziellen Oberfläche.
Hier ist beispielsweise die externe Abhängigkeitsschnittstelle der Einkaufsfunktion:
 public interface PurchaseFeatureDependencies { HttpClientApi httpClient(); } 

Oder die Schnittstelle der externen Abhängigkeiten der Scannerfunktion:
 public interface ScannerFeatureDependencies { DbClientApi dbClient(); HttpClientApi httpClient(); SomeUtils someUtils(); //       PurchaseInteractor purchaseInteractor(); } 

Wie im Abschnitt über DI erwähnt, können Abhängigkeiten von jedem implementiert werden. Wie Sie möchten, handelt es sich hierbei um reine Schnittstellen, und unsere Funktionen sind von diesem zusätzlichen Wissen befreit.

Eine weitere wichtige Komponente eines „reinen“ Features ist das Vorhandensein einer klaren API, über die die Außenwelt auf das Feature zugreifen kann.
Hier sind die API-Funktionen von Shopping:
 public interface PurchaseFeatureApi { PurchaseInteractor purchaseInteractor(); } 

Das heißt, die Außenwelt kann einen PurchaseInteractor erhalten und versuchen, über diesen einen Kauf zu tätigen. Oben haben wir gesehen, dass der Scanner einen PurchaseInteractor benötigt, um den Kauf abzuschließen.

Und hier sind die API-Funktionen des Scanners:
 public interface ScannerFeatureApi { ScannerStarter scannerStarter(); } 

Und sofort bringe ich die Schnittstelle und Implementierung von ScannerStarter :
 public interface ScannerStarter { void start(Context context); } @PerFeature public class ScannerStarterImpl implements ScannerStarter { @Inject public ScannerStarterImpl() { } @Override public void start(Context context) { Class<?> cls = ScannerActivity.class; Intent intent = new Intent(context, cls); intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); context.startActivity(intent); } } 

Hier ist es interessanter. Tatsache ist, dass der Scanner und der Diebstahlschutz ziemlich geschlossene und isolierte Funktionen sind. In meinem Beispiel werden diese Funktionen in separaten Aktivitäten mit eigener Navigation usw. gestartet. Das heißt, wir können hier einfach die Aktivität starten. Aktivität stirbt - die Funktion stirbt. Sie können auf der Grundlage des Prinzips „Einzelaktivität“ arbeiten und dann über API-Funktionen beispielsweise einen FragmentManager und einen Rückruf übergeben, über den die Funktion meldet, dass sie abgeschlossen wurde. Es gibt viele Variationen.
Wir können auch sagen, dass wir das Recht haben, Funktionen wie Scanner und Diebstahlschutz als unabhängige Anwendungen zu betrachten. Im Gegensatz zu der Funktion des Kaufs, die eine Funktionsergänzung zu etwas darstellt, existiert sie irgendwie nicht besonders. Ja, es ist unabhängig, aber es ist eine logische Ergänzung zu anderen Funktionen.

Wie Sie sich vorstellen können, muss es einen Punkt geben, der die Funktionen, ihre Implementierung und die erforderlichen Funktionen der Abhängigkeit miteinander verbindet. Dieser Punkt ist die Dolchkomponente.
Ein Beispiel für eine Funktionskomponente des Scanners:
 @Component(modules = { ScannerFeatureModule.class, ScreenNavigationModule.class // ScannerFeatureDependencies - api    }, dependencies = ScannerFeatureDependencies.class) @PerFeature // ScannerFeatureApi - api   public abstract class ScannerFeatureComponent implements ScannerFeatureApi { private static volatile ScannerFeatureComponent sScannerFeatureComponent; //   public static ScannerFeatureApi initAndGet( ScannerFeatureDependencies scannerFeatureDependencies) { if (sScannerFeatureComponent == null) { synchronized (ScannerFeatureComponent.class) { if (sScannerFeatureComponent == null) { sScannerFeatureComponent = DaggerScannerFeatureComponent.builder() .scannerFeatureDependencies(scannerFeatureDependencies) .build(); } } } return sScannerFeatureComponent; } //           public static ScannerFeatureComponent get() { if (sScannerFeatureComponent == null) { throw new RuntimeException( "You must call 'initAndGet(ScannerFeatureDependenciesComponent scannerFeatureDependenciesComponent)' method" ); } return sScannerFeatureComponent; } //    (   ) public void resetComponent() { sScannerFeatureComponent = null; } public abstract void inject(ScannerActivity scannerActivity); //         Moxy public abstract ScannerScreenComponent scannerScreenComponent(); } 


Ich denke nichts Neues für dich.

Übergang zur Multimodularität


Sie und ich konnten also die Grenzen des Features durch die API seiner Abhängigkeiten und die externe API klar definieren. Wir haben auch herausgefunden, wie man alles in Dagger auf Touren bringt. Und jetzt kommen wir zum nächsten logischen und interessanten Schritt - der Aufteilung in Module.
Öffnen Sie sofort einen Testfall - es wird einfacher.
Schauen wir uns das Bild allgemein an:

Schauen Sie sich die Paketstruktur des Beispiels an:

Lassen Sie uns nun jeden Punkt sorgfältig besprechen.

Zunächst sehen wir vier große Blöcke: Application , API , Impl und Utils . In den APIs , Impl und Utils stellen Sie möglicherweise fest, dass alle Module entweder bei Core- oder Feature- beginnen . Lassen Sie uns zuerst über sie sprechen.

Trennung in Kern und Merkmal


Ich teile alle Module in zwei Kategorien ein: Core- und Feature- .
In Feature- , wie Sie vielleicht erraten haben, unsere Features. Im Kern gibt es solche Dinge wie Dienstprogramme, Arbeiten mit einem Netzwerk, Datenbanken usw. Aber es gibt dort keine Funktionsschnittstellen. Und der Kern ist kein Monolith. Ich bin dafür, das Kernmodul in logische Teile zu zerlegen und es nicht mit einigen anderen Funktionsschnittstellen zu laden.
Schreiben Sie im Namen des Moduls zuerst den Kern oder das Feature . Weiter im Modulnamen befindet sich ein logischer Name ( Scanner , Netzwerk usw.).

Nun zu vier großen Blöcken: Application, API, Impl und Utils


API
Jedes Feature oder Kernmodul ist in API und Impl unterteilt . Die API enthält eine externe API, über die Sie auf eine Funktion oder einen Kern zugreifen können. Nur das und nichts weiter:

Außerdem kennt das API-Modul niemanden, es ist ein absolut isoliertes Modul.

Utils
Die einzige Ausnahme von der obigen Regel kann als ein völlig zweckmäßiges Element angesehen werden, bei dem es keinen Sinn macht, in API und Implementierung einzubrechen.

Impl
Hier haben wir eine Unterteilung in Core-Impl und Feature-Impl .
Die Module in core-impl sind ebenfalls völlig unabhängig. Ihre einzige Abhängigkeit ist das API-Modul . Schauen Sie sich als Beispiel die build.gradle des core-db-impl-Moduls an :
 // bla-bla-bla dependencies { implementation project(':core-db-api') // bla-bla-bla } 

Nun zu Feature-Impl . Es gibt bereits den Löwenanteil der Anwendungslogik. Die Module der Feature-Impl-Gruppe können etwas über die Module der API- oder Utils- Gruppe wissen, aber sie wissen sicherlich nichts über die anderen Module der Impl- Gruppe.
Wie wir uns erinnern, werden alle externen Abhängigkeiten des Features in den externen Abhängigkeiten akkumuliert. Für eine Funktion von Scan sieht diese API beispielsweise wie folgt aus:
 public interface ScannerFeatureDependencies { // core-db-api DbClientApi dbClient(); // core-network-api HttpClientApi httpClient(); // core-utils SomeUtils someUtils(); // feature-purchase-api PurchaseInteractor purchaseInteractor(); } 

Dementsprechend sieht build.gradle Feature-Scanner-Impl folgendermaßen aus:
 // bla-bla-bla dependencies { implementation project(':core-utils') implementation project(':core-network-api') implementation project(':core-db-api') implementation project(':feature-purchase-api') implementation project(':feature-scanner-api') // bla-bla-bla } 

Sie fragen sich vielleicht, warum sich die API für externe Abhängigkeiten nicht im API-Modul befindet. Tatsache ist, dass dies ein Detail der Implementierung ist. Das heißt, es handelt sich um eine bestimmte Implementierung, die bestimmte Abhängigkeiten benötigt. Für den Abhängigkeits-API-Scanner gibt es hier:


Kleiner architektonischer Rückzugsort
Lassen Sie uns all das oben Genannte zusammenfassen und einige architektonische Punkte in Bezug auf Feature -...- Impl-Module und deren Abhängigkeiten von anderen Modulen verstehen.
Ich habe zwei der beliebtesten Abhängigkeitszuordnungsmuster für ein Modul kennengelernt:

  • Ein Modul kann über jeden Bescheid wissen. Es gibt keine Regeln. Es gibt nichts zu kommentieren.
  • Module kennen nur das Kernmodul . Und im Kernmodul sind alle Schnittstellen aller Funktionen konzentriert. Dieser Ansatz ist für mich nicht sehr ansprechend, da die Gefahr besteht, dass der Kern in eine andere Müllkippe verwandelt wird. Wenn wir unser Modul in eine andere Anwendung übertragen möchten, müssen wir diese Schnittstellen in eine andere Anwendung kopieren und im Kern platzieren . Das blöde Kopieren und Einfügen von Schnittstellen an und für sich ist nicht sehr attraktiv und kann in Zukunft wiederverwendet werden, wenn die Schnittstellen aktualisiert werden können.

In unserem Beispiel befürworte ich Kenntnisse über API und nur über API-Module (also Utils-Gruppen). Features wissen nichts über Implementierungen.

Es stellt sich jedoch heraus, dass Features andere Features kennen (natürlich über API) und ausführen können. Könnte es ein Chaos sein?
Faire Bemerkung. Es ist schwer, einige super klare Regeln auszuarbeiten. In allem sollte ein Maß sein. Wir haben dieses Problem bereits etwas weiter oben angesprochen und die Funktionen in unabhängige Funktionen (Scanner und Diebstahlschutz) unterteilt - völlig unabhängig und getrennt - und Funktionen „im Kontext“, dh sie werden immer als Teil von etwas gestartet (Kaufen) und implizieren normalerweise Geschäftslogik ohne ui. Aus diesem Grund sind sich Scanner und Diebstahlsicherung der Käufe bewusst.
Ein weiteres Beispiel. Stellen Sie sich vor, dass es in Anti-Theft so etwas wie das Löschen von Daten gibt, dh das Löschen absolut aller Daten vom Telefon. Es gibt viel Geschäftslogik, ui, es ist völlig isoliert. Daher ist es logisch, Löschdaten als separate Funktion zuzuweisen. Und dann die Gabel. Wenn Löschdaten immer nur von Anti-Theft gestartet werden und immer in Anti-Theft vorhanden sind, ist es logisch, dass Anti-Theft über Löschdaten Bescheid weiß und diese selbst ausführt. Und das akkumulierende Modul App würde dann nur noch über Diebstahlsicherung Bescheid wissen. Wenn Löschdaten jedoch an einer anderen Stelle beginnen können oder in Anti-Theft nicht immer vorhanden sind (dh in verschiedenen Anwendungen unterschiedlich sein können), ist es logisch, dass Anti-Theft diese Funktion nicht kennt und nur etwas Externes sagt (über Router, Durch einen Rückruf spielt es keine Rolle, dass der Benutzer die eine oder andere Taste gedrückt hat, und was darunter zu starten ist, ist bereits Sache des Verbrauchers der Anti-Theft-Funktion (bestimmte Anwendung, bestimmte App).

Es gibt auch eine interessante Frage zum Übertragen von Funktionen auf eine andere Anwendung. Wenn wir zum Beispiel den Scanner auf eine andere Anwendung übertragen möchten, müssen wir zusätzlich zu den Modulen : feature-scanner-api und : feature-scanner-impl und den Modulen, von denen der Scanner abhängt ( : core-utils ,: core-network-), auch übertragen. api ,: core-db-api ,: feature-purchase-api ).
Ja aber! Erstens sind alle Ihre API-Module völlig unabhängig und es gibt nur Schnittstellen und Datenmodelle. Keine Logik. Und diese Module sind logisch klar voneinander getrennt, und : core-utils ist normalerweise ein gemeinsames Modul für alle Anwendungen.
Zweitens können Sie API-Module in Form von aar sammeln und über den Maven an eine andere Anwendung senden, oder Sie können sie in Form eines Gig-Submoduls verbinden. Aber Sie werden eine Versionierung haben, es wird Kontrolle geben, es wird Integrität geben.
Somit sieht die Wiederverwendung des Moduls (genauer gesagt des Implementierungsmoduls) in einer anderen Anwendung viel einfacher, klarer und sicherer aus.

Anwendung


Es scheint, dass wir ein schlankes und verständliches Bild mit Funktionen, Modulen, ihren Abhängigkeiten haben und das ist alles. Jetzt kommen wir zu einem Höhepunkt - dies ist eine Kombination aus API und ihren Implementierungen, die alle erforderlichen Abhängigkeiten usw. ersetzt, jedoch aus Sicht der Gredloi-Module. Der Verbindungspunkt ist normalerweise die App selbst.
In unserem Beispiel ist dieser Punkt übrigens immer noch ein Feature-Scanner-Beispiel . Mit dem oben beschriebenen Ansatz können Sie jede Ihrer Funktionen als separate Anwendung ausführen, was die Erstellungszeit während der aktiven Entwicklung erheblich spart. Schönheit!

Betrachten wir zunächst, wie alles über die App am Beispiel des bereits geliebten Scanners abläuft.
Erinnern Sie sich schnell an die Funktion:
Sci externe Abhängigkeiten API ist:
 public interface ScannerFeatureDependencies { // core-db-api DbClientApi dbClient(); // core-network-api HttpClientApi httpClient(); // core-utils SomeUtils someUtils(); // feature-purchase-api PurchaseInteractor purchaseInteractor(); } 

Deshalb : Feature-Scanner-Impl hängt von folgenden Modulen ab:
 // bla-bla-bla dependencies { implementation project(':core-utils') implementation project(':core-network-api') implementation project(':core-db-api') implementation project(':feature-purchase-api') implementation project(':feature-scanner-api') // bla-bla-bla } 


Auf dieser Grundlage können wir eine Dolchkomponente erstellen, die die API externer Abhängigkeiten implementiert:
 @Component(dependencies = { CoreUtilsApi.class, CoreNetworkApi.class, CoreDbApi.class, PurchaseFeatureApi.class }) @PerFeature interface ScannerFeatureDependenciesComponent extends ScannerFeatureDependencies { } 

Ich habe diese Schnittstelle der Einfachheit halber in die ScannerFeatureComponent eingefügt:
 @Component(modules = { ScannerFeatureModule.class, ScreenNavigationModule.class }, dependencies = ScannerFeatureDependencies.class) @PerFeature public abstract class ScannerFeatureComponent implements ScannerFeatureApi { // bla-bla-bla @Component(dependencies = { CoreUtilsApi.class, CoreNetworkApi.class, CoreDbApi.class, PurchaseFeatureApi.class }) @PerFeature interface ScannerFeatureDependenciesComponent extends ScannerFeatureDependencies { } } 


Nun die App. App kennt alle Module, die sie benötigt ( Core-, Feature-, API, Impl ):
 // bla-bla-bla dependencies { implementation project(':core-utils') implementation project(':core-db-api') implementation project(':core-db-impl') implementation project(':core-network-api') implementation project(':core-network-impl') implementation project(':feature-scanner-api') implementation project(':feature-scanner-impl') implementation project(':feature-antitheft-api') implementation project(':feature-antitheft-impl') implementation project(':feature-purchase-api') implementation project(':feature-purchase-impl') // bla-bla-bla } 

Erstellen Sie als Nächstes eine Hilfsklasse. Zum Beispiel FeatureProxyInjector . Es wird helfen, alle Komponenten korrekt zu initialisieren, und über diese Klasse werden wir uns den Funktionen zuwenden. Mal sehen, wie die Scanner-Funktionskomponente initialisiert wird:
 public class FeatureProxyInjector { // another... public static ScannerFeatureApi getFeatureScanner() { return ScannerFeatureComponent.initAndGet( DaggerScannerFeatureComponent_ScannerFeatureDependenciesComponent.builder() .coreDbApi(CoreDbComponent.get()) .coreNetworkApi(CoreNetworkComponent.get()) .coreUtilsApi(CoreUtilsComponent.get()) .purchaseFeatureApi(featurePurchaseGet()) .build() ); } } 

Äußerlich geben wir die Feature-Oberfläche ( ScannerFeatureApi ) an und initialisieren nur das gesamte Implementierungsabhängigkeitsdiagramm (über die ScannerFeatureComponent.initAndGet (...) -Methode).
DaggerPurchaseComponent_PurchaseFeatureDependenciesComponent ist die Implementierung der von Dagger generierten PurchaseFeatureDependenciesComponent , über die wir oben gesprochen haben, wobei wir die Implementierung von API-Modulen im Builder ersetzen.
Das ist alles Magie. Siehe das Beispiel noch einmal.

Apropos Beispiel . Zum Beispiel müssen wir auch alle externen Abhängigkeiten erfüllen : feature-scanner-impl . Da dies jedoch ein Beispiel ist, können wir Dummy-Klassen ersetzen.
Wie es aussehen wird:
 //     ScannerFeatureDependencies public class ScannerFeatureDependenciesFake implements ScannerFeatureDependencies { @Override public DbClientApi dbClient() { return new DbClientFake(); } @Override public HttpClientApi httpClient() { return new HttpClientFake(); } @Override public SomeUtils someUtils() { return CoreUtilsComponent.get().someUtils(); } @Override public PurchaseInteractor purchaseInteractor() { return new PurchaseInteractorFake(); } } //  -  Application-   public class ScannerExampleApplication extends Application { @Override public void onCreate() { super.onCreate(); ScannerFeatureComponent.initAndGet( // ,     =) new ScannerFeatureDependenciesFake() ); } } 

Die Scannerfunktion selbst wird beispielsweise über das Manifest gestartet, um zusätzliche leere Aktivitäten nicht zu blockieren:
 <?xml version="1.0" encoding="utf-8"?> <manifest xmlns:android="http://schemas.android.com/apk/res/android" package="com.example.scanner_example"> <application android:name=".ScannerExampleApplication" android:allowBackup="true" android:icon="@mipmap/ic_launcher" android:label="@string/app_name" android:roundIcon="@mipmap/ic_launcher_round" android:supportsRtl="true" android:theme="@style/AppTheme"> <!--   --> <activity android:name="com.example.scanner.presentation.view.ScannerActivity"> <intent-filter> <action android:name="android.intent.action.MAIN" /> <category android:name="android.intent.category.LAUNCHER" /> </intent-filter> </activity> </application> </manifest> 


Algorithmus des Übergangs von Monomodularität zu Multimodularität


Das Leben ist eine harte Sache. Und die Realität ist, dass wir alle mit Legacy arbeiten. Wenn jetzt jemand ein brandneues Projekt sägt, bei dem Sie sofort alles segnen können, dann beneide ich Sie, Bruder. Aber das ist bei mir nicht der Fall und dieser Typ ist auch falsch =).

Wie übersetzen Sie Ihre Anwendung in mehrere Module? Ich habe hauptsächlich von zwei Optionen gehört.
Erster. Partitionierung der Anwendung hier und jetzt. Es stimmt, Ihr Projekt wird möglicherweise ein oder zwei Monate lang nicht zusammengestellt =).
Zweiter. Versuchen Sie, die Funktionen schrittweise herauszuziehen. Gleichzeitig erstrecken sich jedoch alle möglichen Abhängigkeiten dieser Merkmale. Und hier beginnt der Spaß. Der Abhängigkeitscode kann einen anderen Code abrufen, das Ganze wird in das gemeinsame Modul , in das Kernmodul und umgekehrt usw. migriert. Infolgedessen kann das Ziehen einer Funktion das Arbeiten mit einer anderen guten Hälfte der Anwendung bedeuten. Und wieder wird Ihr Projekt zu Beginn keinen angemessenen Zeitraum haben.

Ich befürworte die schrittweise Übertragung der Anwendung auf Multimodularität, da wir parallel noch neue Funktionen sehen müssen. Die Schlüsselidee ist, dass Sie diesen Code nicht sofort auch physisch in die Module ziehen sollten , wenn Ihr Modul einige der Abhängigkeiten benötigt . Schauen wir uns den Algorithmus zum Entfernen von Modulen am Beispiel des Scanners an:

  • Erstellen Sie API-Funktionen und fügen Sie sie in ein neues API-Modul ein. Das heißt, um ein Modul vollständig zu erstellen : Feature-Scanner-API mit allen Schnittstellen.
  • Erstellen : Feature-Scanner-Impl . Übertragen Sie den gesamten Code, der sich auf die Funktion bezieht, physisch auf dieses Modul. Alles, wovon Ihre Funktion abhängt, wird vom Studio sofort hervorgehoben.
  • Identifizieren Sie Abhängigkeiten von externen Features. Erstellen Sie entsprechende Schnittstellen. Diese Schnittstellen sind in logische API-Module unterteilt. Das heißt, in unserem Beispiel erstellen Sie die Module : Core-Utils: Core-Network-API: Core-DB-API: Feature-Purchase-API mit den entsprechenden Schnittstellen.
    Ich rate Ihnen, sofort in den Namen und die Bedeutung der Module zu investieren. Es ist klar, dass Schnittstellen und Module im Laufe der Zeit ein wenig gemischt, reduziert usw. werden können. Dies ist normal.
  • Erstellen Sie eine API für externe Abhängigkeiten ( ScannerFeatureDependencies ). Abhängig : Feature-Scanner-Impl registriert neu erstellte API-Module.
  • Da wir das gesamte Erbe in der App haben , tun wir Folgendes. In der App verbinden wir alle Module, die für das Feature erstellt wurden (Feature-API-Modul, Feature-Impl-Modul, Feature-API für externe Abhängigkeitsabhängigkeit).
    Super wichtiger Punkt . Als Nächstes erstellen wir in der App Implementierungen aller erforderlichen Schnittstellen für Feature-Abhängigkeiten (in unserem Beispiel Scanner). Diese Implementierungen sind wahrscheinlich nur Proxys von Ihren API-Abhängigkeiten zur aktuellen Implementierung dieser Abhängigkeiten im Projekt. Ersetzen Sie beim Initialisieren einer Feature-Komponente die Implementierungsdaten.
    Schwierig in Worten, wollen Sie ein Beispiel? Also ist er es schon! Ähnliches gibt es bereits im Feature-Scanner-Beispiel. Ich werde ihm noch einmal einen leicht angepassten Code geben:
     //     ScannerFeatureDependencies  app- public class ScannerFeatureDependenciesLegacy implements ScannerFeatureDependencies { @Override public DbClientApi dbClient() { return new DbClientLegacy(); } @Override public HttpClientApi httpClient() { // -  // ,      return NetworkFabric.createHttpClientLegacy(); } @Override public SomeUtils someUtils() { return new SomeUtils(); } @Override public PurchaseInteractor purchaseInteractor() { return new PurchaseInteractorLegacy(); } } //  -   ScannerFeatureComponent.initAndGet( new ScannerFeatureDependenciesLegacy() ); 

    Das heißt, die Hauptbotschaft hier ist dies. Lassen Sie den gesamten für die Funktion erforderlichen externen Code wie bisher in der App verfügbar . Und das Feature selbst funktioniert bereits auf normale Weise über API (dh API-Abhängigkeiten und API-Module). In Zukunft wird die Implementierung schrittweise auf Module umgestellt. Aber dann vermeiden wir ein endloses Spiel, indem wir den erforderlichen externen Code für das Feature vom Modul in das Modul ziehen. Wir können uns in klaren Iterationen bewegen!
  • Gewinn

Hier ist ein so einfacher, aber funktionierender Algorithmus, mit dem Sie Schritt für Schritt auf Ihr Ziel zugehen können.

Zusätzliche Tipps


Wie groß / klein sollten Features sein?
Es hängt alles vom Projekt usw. ab. Zu Beginn des Übergangs zur Multimodularität empfehle ich jedoch, sie in große Teile aufzuteilen. Außerdem wählen Sie bei Bedarf weitere Module aus diesen Modulen aus. Aber nicht mahlen. Tun Sie dies nicht: eine / mehrere Klassen = ein Modul.

Reinheit des App-Moduls
Wenn Sie zur Multimodul- App wechseln , haben wir eine Menge zu bieten, und von dort aus zucken Ihre hervorgehobenen Funktionen. Es ist möglich, dass Sie im Laufe der Arbeit Änderungen an diesem Erbe vornehmen müssen, um dort etwas fertig zu stellen, oder Sie haben nur eine Version, und Sie sind nicht in der Lage, die Module zu zerschneiden. In diesem Fall möchten Sie, dass die App und damit das gesamte Vermächtnis nur über die API über die hervorgehobenen Funktionen informiert wird, ohne über die Implementierungen Bescheid zu wissen. Tatsächlich kombiniert die App jedoch Api- und Impl-Module , und daher kennt die App jeden.
In diesem Fall können Sie ein spezielles Modul erstellen : Adapter, die der Verbindungspunkt von API und Impl sein wird, und die App wird dann nur über API Bescheid wissen. Ich denke, die Idee ist klar. Sie können ein Beispiel im Zweig clean_app sehen . Ich möchte hinzufügen, dass es bei Moxy oder besser MoxyReflector einige Probleme beim Aufteilen in Module gibt, weshalb ich ein weiteres zusätzliches Modul erstellen musste : stub-moxy-java . Eine leichte Prise Magie, wo ohne.
Die einzige Änderung. Dies funktioniert nur, wenn Ihre Funktion und die zugehörigen Abhängigkeiten bereits physisch auf andere Module übertragen wurden. Wenn Sie eine Funktion erstellt haben, die Abhängigkeiten jedoch weiterhin in der App vorhanden sind , wie im obigen Algorithmus, funktioniert dies nicht.

Nachwort


Der Artikel fiel ziemlich groß aus. Aber ich hoffe, dass es Ihnen im Kampf gegen die Monomodularität wirklich hilft, zu verstehen, wie es sein sollte und wie Sie sich mit DI anfreunden können.
Wenn Sie daran interessiert sind, in ein Problem mit der Build-Geschwindigkeit einzutauchen und alles zu messen, empfehle ich die Berichte von Denis Neklyudov und Zhenya Suworow (Mobius 2018 Piter, Videos sind noch nicht öffentlich verfügbar).
Über Gradle. Der Unterschied zwischen API und Implementierung in Gradle wurde von Vova Tagakov perfekt gezeigt . Wenn Sie die Multi-Modul-Boilerplate reduzieren möchten, können Sie hier mit diesem Artikel beginnen .
Ich freue mich über Kommentare, Korrekturen und Likes! Alles sauberer Code!

Source: https://habr.com/ru/post/de422555/


All Articles