Du copier-coller aux composants: réutiliser le code dans différentes applications



Badoo développe plusieurs applications, et chacune d'elles est un produit distinct avec ses propres caractéristiques, équipes de gestion, de produit et d'ingénierie. Mais nous travaillons tous ensemble dans le même bureau et résolvons des problèmes similaires.

Le développement de chaque projet s'est déroulé à sa manière. La base de code a été influencée non seulement par des délais et des solutions de produits différents, mais aussi par la vision des développeurs. En fin de compte, nous avons remarqué que les projets ont la même fonctionnalité, qui est fondamentalement différente dans la mise en œuvre.

Ensuite, nous avons décidé de parvenir à une structure qui nous donnerait la possibilité de réutiliser les fonctionnalités entre les applications. Maintenant, au lieu de développer des fonctionnalités dans des projets individuels, nous créons des composants communs qui s'intègrent dans tous les produits. Si vous êtes intéressé par la façon dont nous en sommes arrivés à cela, bienvenue chez cat.

Mais d'abord, attardons-nous sur les problèmes, dont la solution a conduit à la création de composants communs. Il y en avait plusieurs:

  • copier-coller entre les applications;
  • processus qui insèrent des bâtons dans les roues;
  • architecture différente des projets.



Cet article est une version texte de mon rapport avec AppsConf 2019 , qui peut être consulté ici .

Problème: copier coller


Il y a quelque temps, lorsque les arbres étaient plus flous, l'herbe était plus verte et j'avais un an de moins, nous avions souvent la situation suivante.

Il y a un développeur, appelons-le Lesha. Il crée un module sympa pour sa tâche, en parle à ses collègues et le met dans le référentiel de son application, où il l'utilise.

Le problème est que toutes nos applications sont dans des référentiels différents.



Le développeur Andrey travaille actuellement sur une autre application dans un référentiel différent. Il veut utiliser ce module dans sa tâche, qui est étrangement similaire à celle dans laquelle Lesha était engagé. Mais il y a un problème: le processus de réutilisation du code est complètement débogué.

Dans cette situation, Andrei rédigera sa décision (ce qui se produit dans 80% des cas) ou copiera-collera la solution de Lyosha et y changera tout pour qu'elle corresponde à son application, sa tâche ou son humeur.



Après cela, Lesha peut mettre à jour son module en ajoutant des modifications à son code pour sa tâche. Il ne connaît pas une autre version et ne mettra à jour que son référentiel.

Cette situation pose plusieurs problèmes.

Premièrement, nous avons plusieurs applications, chacune avec sa propre histoire de développement. Lorsqu'elle travaille sur chaque application, l'équipe produit crée souvent des solutions difficiles à apporter à une seule structure.

Deuxièmement, des équipes distinctes sont impliquées dans les projets, qui communiquent mal entre elles et, par conséquent, s'informent rarement mutuellement des mises à jour / réutilisation de l'un ou l'autre module.

Troisièmement, l'architecture de l'application est très différente: du MVP au MVI, de l'activité divine à l'activité unique.

Eh bien, le «point culminant du programme»: les applications sont dans différents référentiels, chacun avec ses propres processus.

Au début de la lutte contre ces problèmes, nous avons fixé l'objectif ultime: réutiliser nos meilleures pratiques (logiques et UI) entre toutes les applications.

Décisions: nous établissons des processus


Parmi les problèmes ci-dessus, deux sont liés aux processus:

  1. Deux référentiels qui partageaient des projets avec un mur impénétrable.
  2. Équipes distinctes sans communication établie et exigences différentes des équipes d'application de produit.

Commençons par le premier: nous avons affaire à deux référentiels avec la même version de module. Théoriquement, nous pourrions utiliser git-subtree ou des solutions similaires et placer les modules de projet communs dans des référentiels séparés.



Le problème se produit lors de la modification. Contrairement aux projets open source, qui ont une API stable et sont distribués via des sources externes, des changements se produisent souvent dans les composants internes qui cassent tout. Lorsque vous utilisez un sous-arbre, chacune de ces migrations devient pénible.

Mes collègues de l'équipe iOS ont une expérience similaire, et cela n'a pas été un grand succès, comme Anton Schukin en a parlé lors de la conférence Mobius l'année dernière.

Après avoir étudié et compris leur expérience, nous sommes passés à un référentiel unique. Toutes les applications Android se trouvent désormais au même endroit, ce qui nous offre certains avantages:

  • vous pouvez réutiliser le code en toute sécurité à l'aide des modules Gradle;
  • nous avons réussi à connecter la chaîne d'outils sur CI en utilisant une seule infrastructure pour les builds et les tests;
  • ces changements ont supprimé la barrière physique et mentale entre les équipes, puisque nous sommes maintenant libres d'utiliser les développements et les solutions des uns et des autres.

Bien entendu, cette solution présente également des inconvénients. Nous avons un énorme projet, qui n'est parfois pas soumis à l'IDE et à Gradle. Le problème pourrait être partiellement résolu par les modules de chargement / déchargement dans Android Studio, mais il est difficile de les utiliser si vous devez travailler simultanément sur toutes les applications et souvent basculer.

Le deuxième problème - l'interaction entre les équipes - consistait en plusieurs parties:

  • des équipes distinctes sans communication établie;
  • répartition indistincte de la responsabilité des modules communs;
  • différentes exigences des équipes de produits.

Pour le résoudre, nous avons formé des équipes engagées dans l'implémentation de certaines fonctionnalités dans chaque application: par exemple, le chat ou l'inscription. En plus du développement, ils sont également chargés d'intégrer ces composants dans l'application.

Les équipes de produits ont déjà entre les mains des composants existants, les améliorant et les adaptant aux besoins d'un projet particulier.

Ainsi, la création d'un composant réutilisable fait désormais partie du processus pour toute l'entreprise, de l'étape de l'idée au démarrage de la production.

Solutions: rationaliser l'architecture


Notre prochaine étape vers la réutilisation a été de rationaliser l'architecture. Pourquoi avons-nous fait ça?

Notre base de code porte l'héritage historique de plusieurs années de développement. Avec le temps et les gens, les approches ont changé. Nous nous sommes donc retrouvés dans une situation avec tout un zoo d'architectures, ce qui a entraîné les problèmes suivants:

  1. L'intégration de modules communs a été presque plus lente que la création de nouveaux modules. En plus des caractéristiques de la fonctionnalité, il était nécessaire de supporter la structure du composant et de l'application.
  2. Les développeurs qui devaient passer d'une application à l'autre passaient très souvent beaucoup de temps à maîtriser de nouvelles approches.
  3. Souvent, les wrappers étaient écrits d'une approche à une autre, ce qui représentait la moitié du code de l'intégration du module.

Au final, nous avons opté pour l'approche MVI, que nous avons structurée dans notre bibliothèque MVICore ( GitHub ). Nous étions particulièrement intéressés par l'une de ses fonctionnalités - les mises à jour de l'état atomique, qui garantissent toujours la validité. Nous sommes allés un peu plus loin et avons combiné les états des couches logiques et de présentation, réduisant la fragmentation. Ainsi, nous arrivons à une structure où la seule entité est responsable de la logique, et la vue affiche uniquement le modèle créé à partir de l'état.



La séparation des responsabilités passe par la transformation des modèles entre les niveaux. Grâce à cela, nous obtenons un bonus sous forme de réutilisation. Nous connectons les éléments de l'extérieur, c'est-à-dire que chacun d'eux ne soupçonne pas que l'autre existe - ils donnent simplement des modèles et réagissent à ce qui leur vient. Cela vous permet de retirer des composants et de les utiliser ailleurs en écrivant des adaptateurs pour leurs modèles.

Regardons un exemple d'écran simple à quoi il ressemble en réalité.



Nous utilisons les interfaces de base de RxJava pour indiquer les types avec lesquels l'élément fonctionne. L'entrée est indiquée par l'interface Consumer <T>, la sortie - ObservableSource <T>.

// 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 } } 

En utilisant ces interfaces, nous pouvons exprimer View en tant que Consumer <ViewModel> et ObservableSource <Event>. Notez que le ViewModel ne contient que l'état de l'écran et a peu à voir avec MVVM. Après avoir reçu le modèle, nous pouvons afficher les données de celui-ci, et lorsque nous cliquons sur le bouton, nous envoyons l'événement, qui est transmis à l'extérieur.

 // 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) } } } 

La fonctionnalité implémente déjà ObservableSource et Consumer pour nous; nous devons y transférer l'état initial (compteur égal à 0) et indiquer comment changer cet état.

Après le transfert de Wish, Reducer est appelé, ce qui en crée un nouveau en fonction du dernier état. En plus de Reducer, la logique peut être décrite par d'autres composants. Vous pouvez en savoir plus à leur sujet ici .

Après avoir créé les deux éléments, il nous reste à les connecter.


 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) } 

Tout d'abord, nous indiquons comment nous transformons un élément d'un type en un autre. Ainsi, ButtonClick devient Increment et le champ de compteur State passe au texte.

Maintenant, nous pouvons créer chacune des chaînes avec la transformation souhaitée. Pour cela, nous utilisons Binder. Il vous permet de créer des relations entre ObservableSource et Consumer, en observant le cycle de vie. Et tout cela avec une belle syntaxe. Ce type de connexion nous conduit à un système flexible qui nous permet d'extraire et d'utiliser des éléments individuellement.

Les éléments MVICore fonctionnent assez bien avec notre «zoo» d'architectures après avoir écrit des wrappers depuis ObservableSource et Consumer. Par exemple, nous pouvons encapsuler les méthodes de cas d'utilisation de Clean Architecture dans Wish / State et les utiliser dans la chaîne au lieu de Feature.



Composant


Enfin, nous passons aux composants. À quoi ressemblent-ils?

Considérez l'écran dans l'application et divisez-le en parties logiques.



On peut le distinguer:

  • barre d'outils avec logo et boutons en haut;
  • une carte avec un profil et un logo;
  • Section Instagram.

Chacune de ces pièces est le composant même qui peut être réutilisé dans un contexte complètement différent. Ainsi, la section Instagram peut faire partie de l'édition de profil dans une autre application.



Dans le cas général, un composant est constitué de plusieurs vues, éléments logiques et composants imbriqués à l'intérieur, unis par des fonctionnalités communes. Et immédiatement la question se pose: comment les assembler en une structure supportée?

Le premier problème que nous avons rencontré est que MVICore aide à créer et à lier des éléments, mais n'offre pas de structure commune. Lors de la réutilisation d'éléments d'un module commun, il n'est pas clair où assembler ces pièces: à l'intérieur de la partie commune ou du côté application?

Dans le cas général, nous ne voulons certainement pas donner à l'application des pièces éparses. Idéalement, nous recherchons une sorte de structure qui nous permettra d'obtenir des dépendances et d'assembler le composant dans son ensemble avec le cycle de vie souhaité.

Au départ, nous avons divisé les composants en écrans. La connexion des éléments a eu lieu à côté de la création de conteneurs DI pour l'activité ou le fragment. Ces conteneurs connaissent déjà toutes les dépendances, ont accès à la vue et au cycle de vie.

 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()) } ) } 

Les problèmes ont commencé à deux endroits à la fois:

  1. DI a commencé à travailler avec la logique, ce qui a conduit à la description de l'ensemble du composant dans une seule classe.
  2. Étant donné que le conteneur est attaché à une activité ou à un fragment et décrit au moins tout l'écran, il y a beaucoup d'éléments sur un tel écran / conteneur, ce qui se traduit par une énorme quantité de code pour connecter toutes les dépendances de cet écran.

Pour résoudre les problèmes dans l'ordre, nous avons commencé par placer la logique dans un composant distinct. Ainsi, nous pouvons collecter toutes les fonctionnalités à l'intérieur de ce composant et communiquer avec View via les entrées et les sorties. Du point de vue de l'interface, il ressemble à un élément MVICore normal, mais en même temps il est créé à partir de plusieurs autres.



Après avoir résolu ce problème, nous avons partagé la responsabilité de connecter les éléments. Mais nous partagions toujours les composants sur les écrans, ce qui n'était clairement pas à notre portée, ce qui entraînait un grand nombre de dépendances en un seul endroit.

 @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>( 

La bonne solution dans cette situation est de casser le composant. Comme nous l'avons vu ci-dessus, chaque écran se compose de nombreux éléments logiques que nous pouvons diviser en parties indépendantes.

Après une petite réflexion, nous sommes arrivés à une structure arborescente et, en la construisant naïvement à partir de composants existants, nous avons obtenu ce schéma:



Bien sûr, maintenir la synchronisation de deux arbres (depuis View et depuis la logique) est presque impossible. Cependant, si le composant est responsable de l'affichage de sa vue, nous pouvons simplifier ce schéma. Après avoir étudié les solutions déjà créées, nous avons repensé notre approche en nous appuyant sur les RIB d'Uber.



Les idées derrière cette approche sont très similaires aux bases de MVICore. Le RIB est une sorte de «boîte noire» avec laquelle la communication se fait via une interface strictement définie à partir des dépendances (à savoir, les entrées et les sorties). Malgré la complexité apparente de la prise en charge d'une telle interface dans un produit itératif rapide, nous avons de grandes opportunités pour réutiliser le code.

Ainsi, par rapport aux itérations précédentes, on obtient:

  • logique encapsulée à l'intérieur d'un composant;
  • prise en charge de l'imbrication, qui permet de diviser les écrans en parties;
  • interaction avec d'autres composants via une interface stricte d'entrée / sortie avec prise en charge de MVICore;
  • connexion sécurisée à la compilation des dépendances des composants (en se basant sur Dagger comme DI).

Bien sûr, c'est loin d'être tout. Le référentiel sur GitHub contient une description plus détaillée et à jour.

Et ici, nous avons un monde parfait. Il a des composants à partir desquels nous pouvons construire une arborescence entièrement réutilisable.

Mais nous vivons dans un monde imparfait.

Bienvenue dans la réalité!


Dans un monde imparfait, il y a un tas de choses que nous devons supporter. Nous nous inquiétons des éléments suivants:

  • différentes fonctionnalités: malgré toute l'unification, nous avons toujours affaire à des produits individuels avec des exigences différentes;
  • support: comment sans nouvelles fonctionnalités sous tests A / B?
  • Legacy (tout ce qui a été écrit avant notre nouvelle architecture).

La complexité des solutions augmente de façon exponentielle, car chaque application ajoute quelque chose de propre aux composants communs.

Considérez le processus d'enregistrement comme un exemple de composant commun qui s'intègre dans les applications. En général, l'enregistrement est une chaîne d'écrans avec des actions qui affectent l'ensemble du flux. Chaque application a des écrans différents et sa propre interface utilisateur. Le but ultime est de créer un composant réutilisable flexible, qui nous aidera également à résoudre les problèmes de la liste ci-dessus.



Exigences diverses


Chaque application a ses propres variations d'enregistrement uniques, à la fois du côté logique et du côté de l'interface utilisateur. Par conséquent, nous commençons à généraliser la fonctionnalité du composant avec un minimum: en téléchargeant des données et en acheminant l'ensemble du flux.



Un tel conteneur transfère les données à l'application depuis le serveur, qui est converti en un écran fini avec logique. Seule condition: les écrans transmis à un tel conteneur doivent satisfaire des dépendances pour interagir avec la logique de l'ensemble du flux.

Après avoir fait cette astuce avec quelques applications, nous avons remarqué que la logique des écrans est presque la même. Dans un monde idéal, nous créerions une logique commune en personnalisant la vue. La question est de savoir comment les personnaliser.

Comme vous vous en souvenez de la description de MVICore, View et Feature sont basés sur l'interface d'ObservableSource et Consumer. En les utilisant comme abstraction, nous pouvons remplacer l'implémentation sans changer les parties principales.



Nous réutilisons donc la logique en divisant l'interface utilisateur. En conséquence, le support devient beaucoup plus pratique.

Le soutien


Considérez le test A / B pour la variation des éléments visuels. Dans ce cas, notre logique ne change pas, ce qui nous permet de substituer une autre implémentation de View à l'interface existante d'ObservableSource et Consumer.



Bien sûr, parfois de nouvelles exigences contredisent une logique déjà écrite. Dans ce cas, nous pouvons toujours revenir au schéma d'origine, où l'application fournit la totalité de l'écran. Pour nous, c'est une sorte de «boîte noire», et peu importe pour le conteneur ce qu'il lui passe, tant que son interface est respectée.

Intégration


Comme le montre la pratique, la plupart des applications utilisent Activity comme unités de base, dont les moyens de communication sont connus depuis longtemps. Tout ce que nous avions à faire était d'apprendre à encapsuler les composants dans Activity et à transmettre les données via les entrées et les sorties. Il s'est avéré que cette approche fonctionne très bien avec des fragments.

Pour les applications à activité unique, rien ne change beaucoup. Presque tous les frameworks proposent leurs éléments de base dans lesquels les composants RIB se laissent envelopper.

En fin de compte


Après avoir franchi ces étapes, nous avons considérablement augmenté le pourcentage de réutilisation du code entre les projets de notre entreprise. À l'heure actuelle, le nombre de composants approche de 100, et la plupart d'entre eux implémentent des fonctionnalités pour plusieurs applications à la fois.

Notre expérience montre que:

  • malgré la complexité accrue de la conception de composants communs, compte tenu des exigences des différentes applications, leur prise en charge est beaucoup plus facile à long terme;
  • en construisant des composants isolés les uns des autres , nous avons grandement simplifié leur intégration dans des applications construites sur des principes différents;
  • Les révisions de processus, associées à l'accent mis sur le développement et le support des composants, ont un effet positif sur la qualité de la fonctionnalité globale.

Mon collègue Zsolt Kocsi a précédemment écrit sur MVICore et les idées derrière. Je recommande fortement de lire ses articles, que nous avons traduits sur notre blog ( 1 , 2 , 3 ).

À propos des semi-rigides, vous pouvez lire l'article original d' Uber . Et pour des connaissances pratiques, je recommande de prendre quelques leçons de notre part (en anglais).

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


All Articles