Vom Kopieren und Einfügen zu Komponenten: Wiederverwendung von Code in verschiedenen Anwendungen



Badoo entwickelt mehrere Anwendungen, von denen jede ein separates Produkt mit eigenen Eigenschaften, Management-, Produkt- und Entwicklungsteams ist. Aber wir arbeiten alle im selben Büro zusammen und lösen ähnliche Probleme.

Die Entwicklung jedes Projekts erfolgte auf seine eigene Weise. Die Codebasis wurde nicht nur von unterschiedlichen Zeitrahmen und Produktlösungen beeinflusst, sondern auch von der Vision der Entwickler. Am Ende haben wir festgestellt, dass die Projekte dieselbe Funktionalität haben, die sich in der Implementierung grundlegend unterscheidet.

Dann haben wir uns für eine Struktur entschieden, die uns die Möglichkeit gibt, Funktionen zwischen Anwendungen wiederzuverwenden. Anstatt Funktionen in einzelnen Projekten zu entwickeln, erstellen wir jetzt gemeinsame Komponenten, die in alle Produkte integriert werden. Wenn Sie daran interessiert sind, wie wir dazu gekommen sind, begrüßen Sie bitte bei cat.

Aber zuerst wollen wir uns mit den Problemen befassen, deren Lösung zur Schaffung gemeinsamer Komponenten führte. Es gab mehrere von ihnen:

  • Kopieren und Einfügen zwischen Anwendungen;
  • Prozesse, bei denen Stöcke in die Räder eingeführt werden;
  • unterschiedliche Architektur von Projekten.



Dieser Artikel ist eine Textversion meines Berichts mit AppsConf 2019 , die hier eingesehen werden kann.

Problem: Kopieren Einfügen


Vor einiger Zeit, als die Bäume unschärfer waren, das Gras grüner und ich ein Jahr jünger, hatten wir oft die folgende Situation.

Es gibt einen Entwickler, nennen wir ihn Lesha. Er macht ein cooles Modul für seine Aufgabe, erzählt seinen Kollegen davon und legt es im Repository für seine Anwendung ab, wo er es verwendet.

Das Problem ist, dass sich alle unsere Anwendungen in verschiedenen Repositorys befinden.



Entwickler Andrey arbeitet derzeit nur an einer anderen Anwendung in einem anderen Repository. Er möchte dieses Modul für seine Aufgabe verwenden, die der von Lesha verdächtig ähnlich ist. Es gibt jedoch ein Problem: Der Prozess der Wiederverwendung von Code ist vollständig debuggt.

In dieser Situation schreibt Andrei entweder seine Entscheidung (was in 80% der Fälle der Fall ist) oder kopiert die Lösung von Lyosha und ändert alles darin, damit es zu seiner Anwendung, Aufgabe oder Stimmung passt.



Danach kann Lesha sein Modul aktualisieren, indem er Änderungen an seinem Code für seine Aufgabe hinzufügt. Er kennt keine andere Version und aktualisiert nur sein Repository.

Diese Situation bringt mehrere Probleme mit sich.

Erstens haben wir mehrere Anwendungen mit jeweils eigener Entwicklungsgeschichte. Bei der Arbeit an jeder Anwendung erstellte das Produktteam häufig Lösungen, die sich nur schwer in eine einzelne Struktur integrieren lassen.

Zweitens sind separate Teams an Projekten beteiligt, die schlecht miteinander kommunizieren und sich daher selten über Aktualisierungen / Wiederverwendung des einen oder anderen Moduls informieren.

Drittens ist die Anwendungsarchitektur sehr unterschiedlich: von MVP zu MVI, von Gottaktivität zu Einzelaktivität.

Nun, das „Highlight des Programms“: Anwendungen befinden sich in verschiedenen Repositories mit jeweils eigenen Prozessen.

Zu Beginn des Kampfes gegen diese Probleme haben wir uns das ultimative Ziel gesetzt: unsere Best Practices (sowohl Logik als auch Benutzeroberfläche) zwischen allen Anwendungen wiederzuverwenden.

Entscheidungen: Wir etablieren Prozesse


Von den oben genannten Problemen hängen zwei mit den Prozessen zusammen:

  1. Zwei Repositories, die Projekte mit einer undurchdringlichen Wand gemeinsam nutzen.
  2. Separate Teams ohne etablierte Kommunikation und unterschiedliche Anforderungen der Produktanwendungsteams.

Beginnen wir mit dem ersten: Wir haben es mit zwei Repositorys mit derselben Modulversion zu tun. Theoretisch könnten wir git-subtree oder ähnliche Lösungen verwenden und gemeinsame Projektmodule in separate Repositories stellen.



Das Problem tritt während der Änderung auf. Im Gegensatz zu Open-Source-Projekten, die über eine stabile API verfügen und über externe Quellen verteilt werden, treten häufig Änderungen an internen Komponenten auf, die alles beschädigen. Bei Verwendung eines Teilbaums wird jede solche Migration zu einem Schmerz.

Meine Kollegen vom iOS-Team haben ähnliche Erfahrungen gemacht, und es stellte sich als nicht sehr erfolgreich heraus, wie Anton Schukin letztes Jahr auf der Mobius-Konferenz sagte.

Nachdem wir ihre Erfahrungen studiert und verstanden hatten, wechselten wir zu einem einzigen Repository. Alle Android-Anwendungen befinden sich jetzt an einem Ort, was uns bestimmte Vorteile bietet:

  • Sie können den Code mithilfe von Gradle-Modulen sicher wiederverwenden.
  • Wir haben es geschafft, die Toolchain auf CI mithilfe einer einzigen Infrastruktur für Builds und Tests zu verbinden.
  • Diese Änderungen haben die physische und einige mentale Barriere zwischen den Teams beseitigt, da wir nun frei sind, die Entwicklungen und Lösungen des anderen zu nutzen.

Natürlich hat diese Lösung auch Nachteile. Wir haben ein riesiges Projekt, das manchmal nicht IDE und Gradle unterliegt. Das Problem könnte teilweise durch die Lade- / Entlademodule in Android Studio gelöst werden. Es ist jedoch schwierig, sie zu verwenden, wenn Sie gleichzeitig an allen Anwendungen arbeiten und häufig wechseln müssen.

Das zweite Problem - die Interaktion zwischen Teams - bestand aus mehreren Teilen:

  • getrennte Teams ohne etablierte Kommunikation;
  • undeutliche Verteilung der Verantwortung für gemeinsame Module;
  • unterschiedliche Anforderungen der Produktteams.

Um dieses Problem zu lösen, haben wir Teams gebildet, die bestimmte Funktionen in jeder Anwendung implementieren: zum Beispiel Chat oder Registrierung. Neben der Entwicklung sind sie auch für die Integration dieser Komponenten in die Anwendung verantwortlich.

Produktteams haben bereits vorhandene Komponenten in der Hand, um sie zu verbessern und an die Anforderungen eines bestimmten Projekts anzupassen.

Somit ist die Erstellung einer wiederverwendbaren Komponente nun Teil des Prozesses für das gesamte Unternehmen, von der Phase der Idee bis zum Beginn der Produktion.

Lösungen: Rationalisierung der Architektur


Unser nächster Schritt zur Wiederverwendung war die Rationalisierung der Architektur. Warum haben wir das gemacht?

Unsere Codebasis trägt das historische Erbe einer mehrjährigen Entwicklung. Mit der Zeit und den Menschen änderten sich auch die Ansätze. Wir befanden uns also in einer Situation mit einem ganzen Zoo von Architekturen, die zu folgenden Problemen führte:

  1. Die Integration gängiger Module war fast langsamer als das Schreiben neuer. Zusätzlich zu den Merkmalen der Funktion war es notwendig, die Struktur sowohl der Komponente als auch der Anwendung zu ertragen.
  2. Entwickler, die sehr oft zwischen Anwendungen wechseln mussten, verbrachten viel Zeit damit, neue Ansätze zu beherrschen.
  3. Oft wurden Wrapper von einem Ansatz zum anderen geschrieben, was der Hälfte des Codes in der Modulintegration entsprach.

Am Ende haben wir uns für den MVI-Ansatz entschieden, den wir in unserer MVICore-Bibliothek ( GitHub ) strukturiert haben. Wir waren besonders an einer seiner Funktionen interessiert - Atomzustandsaktualisierungen, die immer die Gültigkeit garantieren. Wir gingen etwas weiter und kombinierten die Zustände der logischen und der Präsentationsebene, um die Fragmentierung zu verringern. Auf diese Weise gelangen wir zu einer Struktur, in der die einzige Entität für die Logik verantwortlich ist und in der Ansicht nur das aus dem Status erstellte Modell angezeigt wird.



Die Trennung von Verantwortlichkeiten erfolgt durch die Transformation von Modellen zwischen Ebenen. Dank dessen erhalten wir einen Bonus in Form von Wiederverwendbarkeit. Wir verbinden die Elemente von außen, das heißt, jeder von ihnen ahnt nicht, dass der andere existiert - sie verschenken einfach einige Modelle und reagieren auf das, was zu ihnen kommt. Auf diese Weise können Sie Komponenten herausziehen und an anderer Stelle verwenden, indem Sie Adapter für ihre Modelle schreiben.

Schauen wir uns ein Beispiel eines einfachen Bildschirms an, wie er in der Realität aussieht.



Wir verwenden die grundlegenden RxJava-Schnittstellen, um die Typen anzugeben, mit denen das Element arbeitet. Die Eingabe wird durch die Schnittstelle Consumer <T>, Ausgabe - ObservableSource <T> bezeichnet.

// input = Consumer<ViewModel> // output = ObservableSource<Event> class View( val events: PublishRelay<Event> ): ObservableSource<Event> by events, Consumer<ViewModel> { val button: Button val textView: TextView init { button.setOnClickListener { events.accept(Event.ButtonClick) } } override fun accept(model: ViewModel) { textView.text = model.text } } 

Über diese Schnittstellen können wir View als Consumer <ViewModel> und ObservableSource <Event> ausdrücken. Beachten Sie, dass das ViewModel nur den Status des Bildschirms enthält und wenig mit MVVM zu tun hat. Nachdem wir das Modell erhalten haben, können wir die Daten daraus anzeigen. Wenn wir auf die Schaltfläche klicken, senden wir das Ereignis, das nach draußen übertragen wird.

 // input = Consumer<Wish> // output = ObservableSource<State> class Feature: ReducerFeature<Wish, State>( initialState = State(counter = 0), reducer = ReducerImpl() ) { class ReducerImpl: Reducer<Wish, State> { override fun invoke(state: State, wish: Wish) = when (wish) { is Increment -> state.copy(counter = state.counter + 1) } } } 

Feature implementiert ObservableSource und Consumer bereits für uns; Wir müssen dort den Anfangszustand (Zähler gleich 0) übertragen und angeben, wie dieser Zustand geändert werden soll.

Nach der Übertragung von Wish wird Reducer aufgerufen, wodurch basierend auf dem letzten Status ein neuer erstellt wird. Zusätzlich zum Reduzierer kann die Logik durch andere Komponenten beschrieben werden. Hier können Sie mehr darüber erfahren.

Nachdem wir die beiden Elemente erstellt haben, müssen wir sie verbinden.


 val eventToWish: (Event) -> Wish = { when (it) { is ButtonClick -> Increment } } val stateToModel: (State) -> ViewModel = { ViewModel(text = state.counter.toString()) } Binder().apply { bind(view to feature using eventToWish) bind(feature to view using stateToModel) } 

Zunächst geben wir an, wie wir ein Element eines Typs in ein anderes umwandeln. ButtonClick wird also zu Inkrement und das Zählerfeld von State wird in Text eingefügt.

Jetzt können wir jede der Ketten mit der gewünschten Transformation erstellen. Dafür verwenden wir Binder. Sie können damit Beziehungen zwischen ObservableSource und Consumer erstellen und den Lebenszyklus beobachten. Und das alles mit einer schönen Syntax. Diese Art der Verbindung führt uns zu einem flexiblen System, mit dem wir Elemente einzeln herausziehen und verwenden können.

MVICore-Elemente funktionieren sehr gut mit unserem „Zoo“ von Architekturen, nachdem Wrapper von ObservableSource und Consumer geschrieben wurden. Beispielsweise können wir Use-Case-Methoden aus Clean Architecture in Wish / State einschließen und in der Kette anstelle von Feature verwenden.



Komponente


Schließlich gehen wir zu den Komponenten über. Wie sind sie?

Betrachten Sie den Bildschirm in der Anwendung und teilen Sie ihn in logische Teile.



Es kann unterschieden werden:

  • Symbolleiste mit Logo und Schaltflächen oben;
  • eine Karte mit Profil und Logo;
  • Instagram-Bereich.

Jeder dieser Teile ist genau die Komponente, die in einem völlig anderen Kontext wiederverwendet werden kann. So kann der Instagram-Bereich Teil der Profilbearbeitung in einer anderen Anwendung werden.



Im allgemeinen Fall besteht eine Komponente aus mehreren Ansichten, Logikelementen und verschachtelten Komponenten, die durch gemeinsame Funktionen verbunden sind. Und sofort stellt sich die Frage: Wie können sie zu einer unterstützten Struktur zusammengesetzt werden?

Das erste Problem, auf das wir gestoßen sind, ist, dass MVICore beim Erstellen und Binden von Elementen hilft, jedoch keine gemeinsame Struktur bietet. Bei der Wiederverwendung von Elementen aus einem gemeinsamen Modul ist nicht klar, wo diese Teile zusammengesetzt werden sollen: innerhalb des gemeinsamen Teils oder auf der Anwendungsseite?

Im allgemeinen Fall wollen wir der Anwendung definitiv keine Streustücke geben. Im Idealfall streben wir eine Struktur an, die es uns ermöglicht, Abhängigkeiten zu erhalten und die Komponente als Ganzes mit dem gewünschten Lebenszyklus zusammenzusetzen.

Zunächst haben wir die Komponenten in Bildschirme unterteilt. Die Verbindung der Elemente erfolgte neben der Erstellung von DI-Containern für Aktivität oder Fragment. Diese Container kennen bereits alle Abhängigkeiten, haben Zugriff auf die Ansicht und den Lebenszyklus.

 object SomeScopedComponent : ScopedComponent<SomeComponent>() { override fun create(): SomeComponent { return DaggerSomeComponent.builder() .build() } override fun SomeComponent.subscribe(): Array<Disposable> = arrayOf( Binder().apply { bind(feature().news to otherFeature()) bind(feature() to view()) } ) } 

Probleme begannen an zwei Stellen gleichzeitig:

  1. DI begann mit Logik zu arbeiten, was zur Beschreibung der gesamten Komponente in einer Klasse führte.
  2. Da der Container an eine Aktivität oder ein Fragment angehängt ist und mindestens den gesamten Bildschirm beschreibt, enthält ein solcher Bildschirm / Container viele Elemente, die sich in einer großen Menge an Code niederschlagen, um alle Abhängigkeiten dieses Bildschirms zu verbinden.

Um die Probleme der Reihe nach zu lösen, haben wir zunächst die Logik in eine separate Komponente eingefügt. So können wir alle Funktionen in dieser Komponente sammeln und über Eingabe und Ausgabe mit View kommunizieren. Aus Sicht der Benutzeroberfläche sieht es wie ein reguläres MVICore-Element aus, wird jedoch gleichzeitig aus mehreren anderen Elementen erstellt.



Nachdem wir dieses Problem gelöst hatten, teilten wir die Verantwortung für die Verbindung der Elemente. Trotzdem haben wir die Komponenten auf den Bildschirmen geteilt, was für uns eindeutig nicht zur Hand war, was zu einer großen Anzahl von Abhängigkeiten an einem Ort führte.

 @Scope internal class ComponentImpl @Inject constructor( private val params: ScreenParams, news: NewsRelay, @OnDisposeAction onDisposeAction: () -> Unit, globalFeature: GlobalFeature, conversationControlFeature: ConversationControlFeature, messageSyncFeature: MessageSyncFeature, conversationInfoFeature: ConversationInfoFeature, conversationPromoFeature: ConversationPromoFeature, messagesFeature: MessagesFeature, messageActionFeature: MessageActionFeature, initialScreenFeature: InitialScreenFeature, initialScreenExplanationFeature: InitialScreenExplanationFeature?, errorFeature: ErrorFeature, conversationInputFeature: ConversationInputFeature, sendRegularFeature: SendRegularFeature, sendContactForCreditsFeature: SendContactForCreditsFeature, screenEventTrackingFeature: ScreenEventTrackingFeature, messageReadFeature: MessageReadFeature?, messageTimeFeature: MessageTimeFeature?, photoGalleryFeature: PhotoGalleryFeature?, onlineStatusFeature: OnlineStatusFeature?, favouritesFeature: FavouritesFeature?, isTypingFeature: IsTypingFeature?, giftStoreFeature: GiftStoreFeature?, messageSelectionFeature: MessageSelectionFeature?, reportingFeature: ReportingFeature?, takePhotoFeature: TakePhotoFeature?, giphyFeature: GiphyFeature, goodOpenersFeature: GoodOpenersFeature?, matchExpirationFeature: MatchExpirationFeature, private val pushIntegration: PushIntegration ) : AbstractMviComponent<UiEvent, States>( 

Die richtige Lösung in dieser Situation besteht darin, die Komponente zu beschädigen. Wie wir oben gesehen haben, besteht jeder Bildschirm aus vielen logischen Elementen, die wir in unabhängige Teile unterteilen können.

Nach einigem Nachdenken kamen wir zu einer Baumstruktur und bauten sie naiv aus vorhandenen Komponenten auf und erhielten dieses Schema:



Natürlich ist es fast unmöglich, die Synchronisation zweier Bäume (aus der Ansicht und aus der Logik) aufrechtzuerhalten. Wenn die Komponente jedoch für die Anzeige ihrer Ansicht verantwortlich ist, können wir dieses Schema vereinfachen. Nachdem wir die bereits erstellten Lösungen untersucht hatten, überlegten wir unseren Ansatz und stützten uns auf die RIBs von Uber.



Die Ideen hinter diesem Ansatz sind den Grundlagen von MVICore sehr ähnlich. RIB ist eine Art „Black Box“, deren Kommunikation über eine streng definierte Schnittstelle von Abhängigkeiten (nämlich Eingabe und Ausgabe) erfolgt. Trotz der offensichtlichen Komplexität der Unterstützung einer solchen Schnittstelle in einem schnell iterativen Produkt erhalten wir großartige Möglichkeiten zur Wiederverwendung von Code.

Im Vergleich zu früheren Iterationen erhalten wir also:

  • gekapselte Logik innerhalb einer Komponente;
  • Unterstützung für das Verschachteln, wodurch Bildschirme in Teile unterteilt werden können;
  • Interaktion mit anderen Komponenten über eine strikte Schnittstelle von Eingabe / Ausgabe mit Unterstützung für MVICore;
  • Kompilierzeitsichere Verbindung von Komponentenabhängigkeiten (basierend auf Dagger als DI).

Das ist natürlich alles andere als gut. Das Repository auf GitHub enthält eine detailliertere und aktuellere Beschreibung.

Und hier haben wir eine perfekte Welt. Es enthält Komponenten, aus denen wir einen vollständig wiederverwendbaren Baum erstellen können.

Aber wir leben in einer unvollkommenen Welt.

Willkommen in der Realität!


In einer unvollkommenen Welt gibt es eine Reihe von Dingen, die wir ertragen müssen. Wir sind besorgt über Folgendes:

  • Unterschiedliche Funktionen: Trotz aller Vereinheitlichung haben wir es immer noch mit einzelnen Produkten mit unterschiedlichen Anforderungen zu tun.
  • Support: Wie ohne neue Funktionalität bei A / B-Tests?
  • Vermächtnis (alles, was vor unserer neuen Architektur geschrieben wurde).

Die Komplexität von Lösungen nimmt exponentiell zu, da jede Anwendung gemeinsamen Komponenten etwas Eigenes hinzufügt.

Betrachten Sie den Registrierungsprozess als Beispiel für eine allgemeine Komponente, die in Anwendungen integriert wird. Im Allgemeinen ist die Registrierung eine Kette von Bildschirmen mit Aktionen, die sich auf den gesamten Ablauf auswirken. Jede Anwendung verfügt über unterschiedliche Bildschirme und eine eigene Benutzeroberfläche. Das ultimative Ziel ist es, eine flexible wiederverwendbare Komponente herzustellen, die uns auch hilft, die Probleme aus der obigen Liste zu lösen.



Verschiedene Anforderungen


Jede Anwendung hat ihre eigenen Registrierungsvarianten, sowohl von der Logikseite als auch von der UI-Seite. Daher beginnen wir, die Funktionalität in der Komponente mit einem Minimum zu verallgemeinern: indem wir Daten herunterladen und den gesamten Fluss weiterleiten.



Ein solcher Container überträgt Daten vom Server an die Anwendung, die mit Logik in einen fertigen Bildschirm konvertiert werden. Die einzige Anforderung besteht darin, dass an einen solchen Container übergebene Bildschirme Abhängigkeiten erfüllen müssen, um mit der Logik des gesamten Flusses zu interagieren.

Nachdem wir diesen Trick mit einigen Anwendungen durchgeführt hatten, stellten wir fest, dass die Logik der Bildschirme fast dieselbe ist. In einer idealen Welt würden wir eine gemeinsame Logik erstellen, indem wir die Ansicht anpassen. Die Frage ist, wie man sie anpasst.

Wie Sie der Beschreibung von MVICore entnehmen können, basieren sowohl Ansicht als auch Funktion auf der Schnittstelle von ObservableSource und Consumer. Wenn wir sie als Abstraktion verwenden, können wir die Implementierung ersetzen, ohne die Hauptteile zu ändern.



Also verwenden wir die Logik wieder, indem wir die Benutzeroberfläche teilen. Infolgedessen wird die Unterstützung viel bequemer.

Unterstützung


Betrachten Sie den A / B-Test für die Variation visueller Elemente. In diesem Fall ändert sich unsere Logik nicht, sodass wir die vorhandene Schnittstelle von ObservableSource und Consumer durch eine andere View-Implementierung ersetzen können.



Natürlich widersprechen manchmal neue Anforderungen bereits geschriebener Logik. In diesem Fall können wir jederzeit zum ursprünglichen Schema zurückkehren, in dem die Anwendung den gesamten Bildschirm bereitstellt. Für uns ist es eine Art "Black Box", und es spielt für den Container keine Rolle, was er an ihn weitergibt, solange seine Schnittstelle respektiert wird.

Integration


Wie die Praxis zeigt, verwenden die meisten Anwendungen Aktivität als Grundeinheit, deren Kommunikationsmittel seit langem bekannt sind. Wir mussten lediglich lernen, wie Komponenten in Activity verpackt und Daten durch Eingabe und Ausgabe übertragen werden. Wie sich herausstellte, funktioniert dieser Ansatz gut mit Fragmenten.

Bei Anwendungen mit einer Aktivität ändert sich nicht viel. Fast alle Frameworks bieten ihre Grundelemente an, in die sich RIB-Komponenten einwickeln lassen.

Zusammenfassend


Nach diesen Phasen haben wir den Prozentsatz der Wiederverwendung von Code zwischen den Projekten unseres Unternehmens erheblich erhöht. Derzeit nähert sich die Anzahl der Komponenten 100, und die meisten von ihnen implementieren Funktionen für mehrere Anwendungen gleichzeitig.

Unsere Erfahrung zeigt, dass:

  • Trotz der zunehmenden Komplexität beim Entwerfen gemeinsamer Komponenten ist ihre Unterstützung angesichts der Anforderungen unterschiedlicher Anwendungen auf lange Sicht viel einfacher.
  • Indem wir Komponenten isoliert voneinander bauen , haben wir ihre Integration in Anwendungen, die auf unterschiedlichen Prinzipien basieren, erheblich vereinfacht.
  • Prozessrevisionen wirken sich zusammen mit der Betonung der Komponentenentwicklung und -unterstützung positiv auf die Qualität der Gesamtfunktionalität aus.

Mein Kollege Zsolt Kocsi hat zuvor über MVICore und die Ideen dahinter geschrieben. Ich empfehle dringend, seine Artikel zu lesen, die wir in unserem Blog übersetzt haben ( 1 , 2 , 3 ).

Über RIBs können Sie den Originalartikel von Uber lesen. Und für praktisches Wissen empfehle ich, ein paar Lektionen von uns zu nehmen (auf Englisch).

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


All Articles