Architecture MVI moderne basée sur Kotlin



Au cours des deux dernières années, les développeurs Android de Badoo ont parcouru un long chemin épineux de MVP à une approche complètement différente de l'architecture des applications. ANublo et moi voulons partager une traduction d'un article de notre collègue Zsolt Kocsi , décrivant les problèmes que nous avons rencontrés et leur solution.

Il s'agit du premier de plusieurs articles consacrés au développement d'une architecture MVI moderne sur Kotlin.

Commençons par le début: problèmes d'état


À chaque instant, l'application a un certain état qui détermine son comportement et ce que voit l'utilisateur. Si vous vous concentrez uniquement sur quelques classes, cet état inclut toutes les valeurs des variables - des simples indicateurs aux objets individuels. Chacune de ces variables vit sa propre vie et est contrôlée par différentes parties du code. Vous pouvez déterminer l'état actuel de l'application uniquement en les vérifiant tous un par un.

En travaillant sur le code, nous créons un modèle existant du travail du système dans nos têtes. Nous mettons facilement en œuvre des cas idéaux lorsque tout se déroule comme prévu, mais sommes totalement incapables de calculer tous les problèmes et conditions possibles de l'application. Et tôt ou tard, l'une des conditions que nous n'avons pas envisagées nous dépassera et nous rencontrerons un bug.

Initialement, le code est écrit conformément à nos idées sur le fonctionnement du système. Mais à l'avenir, en passant par les cinq étapes du débogage , il est nécessaire de tout refaire douloureusement, en changeant simultanément le modèle du système déjà créé qui s'est développé dans ma tête. Il reste à espérer que tôt ou tard nous comprendrons ce qui s'est mal passé et le bug sera corrigé.

Mais c'est loin d'être toujours chanceux. Plus le système est complexe, plus il est susceptible de rencontrer un état imprévu, dont le débogage sera un rêve pendant longtemps dans les cauchemars.

Dans Badoo, toutes les applications sont sensiblement asynchrones - non seulement en raison des nombreuses fonctionnalités disponibles pour l'utilisateur via l'interface utilisateur, mais également en raison de la possibilité d'envoi de données à sens unique par le serveur. L'état et le comportement de l'application sont influencés par de nombreux facteurs, de la modification du statut de paiement à de nouvelles correspondances et demandes de vérification.

En conséquence, dans notre module de chat, nous avons rencontré plusieurs bugs étranges et difficiles à reproduire qui ont gâté beaucoup de sang pour tout le monde. Parfois, les testeurs ont réussi à les écrire, mais ils n'ont pas été répétés sur l'appareil du développeur. En raison du code asynchrone, la répétition complète d'une chaîne d'événements était extrêmement improbable. Et comme l'application n'a pas planté, nous n'avions même pas de trace de pile indiquant où commencer la recherche.

L'architecture propre n'a pas non plus pu nous aider. Même après avoir réécrit le module de chat, les tests A / B ont révélé de petites mais importantes différences dans le nombre de messages des utilisateurs utilisant les nouveaux et les anciens modules. Nous avons décidé que cela était dû à la difficile reproductibilité des bugs et à l'état de la race. L'écart a persisté après avoir vérifié tous les autres facteurs. Les intérêts de l'entreprise ont souffert, il était difficile pour les développeurs de maintenir le code.

Vous ne pouvez pas publier un nouveau composant s'il fonctionne moins bien que le composant existant, mais vous ne pouvez pas non plus le publier - car il a fallu une mise à jour, il y avait une raison. Donc, vous devez comprendre pourquoi dans un système qui semble tout à fait normal et qui ne plante pas, le nombre de messages diminue.

Par où commencer la recherche?

Spoiler: ce n'est pas la faute de l'architecture propre - comme toujours, le facteur humain est à blâmer. À la fin, bien sûr, nous avons corrigé ces bugs, mais nous y avons consacré beaucoup de temps et d'efforts. Nous avons ensuite pensé: existe-t-il un moyen plus simple d'éviter ces problèmes?

La lumière au bout du tunnel ...


Des termes à la mode comme Model-View-Intent et «flux de données unidirectionnel» nous sont familiers. Si ce n'est pas le cas dans votre cas, je vous conseille de les rechercher sur Google - il existe de nombreux articles sur ces sujets sur Internet. Les développeurs Android recommandent particulièrement le matériel en huit pièces de Hannes Dorfman .

Nous avons commencé à jouer avec ces idées tirées du développement Web au début de 2017. Des approches comme Flux et Redux se sont avérées très utiles - elles nous ont aidés à résoudre de nombreux problèmes.

Tout d'abord, il est très utile de contenir tous les éléments d'état (variables qui affectent l'interface utilisateur et déclenchent diverses actions) dans un seul objet - État . Lorsque tout est stocké au même endroit, l'image globale est mieux visible. Par exemple, si vous souhaitez imaginer le chargement de données à l'aide de cette approche, vous avez besoin des champs de charge utile et de chargement . En les regardant, vous verrez quand les données sont reçues ( charge utile ) et si l'animation ( isLoading ) est montrée à l'utilisateur.

De plus, si nous nous éloignons de l'exécution de code parallèle avec des rappels et exprimons les changements dans l'état de l'application comme une série de transactions, nous aurons un point d'entrée unique. Nous vous présentons Reducer , qui nous est venu de la programmation fonctionnelle. Il prend l'état actuel et les données sur d'autres actions ( intention ) et crée un nouvel état à partir d'eux:

Reducer = (State, Intent) -> State

En poursuivant l'exemple précédent avec le chargement des données, nous obtenons les actions suivantes:

  • DémarrageChargement
  • FinishedWithSuccess


Ensuite, vous pouvez créer un réducteur avec les règles suivantes:

  1. Dans le cas de StartedLoading, créez un nouvel objet State en copiant l'ancien et définissez la valeur isLoading sur true.
  2. Dans le cas de FinishedWithSuccess, créez un nouvel objet State , en copiant l'ancien, dans lequel la valeur isLoading sera définie sur false et la valeur de la charge utile sera
    match téléchargé.

Si nous sortons la série d' état résultante dans le journal, nous verrons ce qui suit:

  1. State ( payload = null, isLoading = false) - l'état initial.
  2. État ( payload = null, isLoading = true) - après StartedLoading.
  3. État ( charge utile = données, isLoading = faux) - après FinishedWithSuccess.

En connectant ces états à l'interface utilisateur, vous verrez toutes les étapes du processus: d'abord un écran vide, puis un écran de chargement et, enfin, les données nécessaires.

Cette approche présente de nombreux avantages.

  • Premièrement, en changeant l'état de manière centralisée à l'aide d'une série de transactions, nous n'autorisons pas l'état de la race et de nombreux bogues ennuyeux invisibles.
  • Deuxièmement, après avoir étudié une série de transactions, nous pouvons comprendre ce qui s'est passé, pourquoi cela s'est produit et comment cela a affecté l'état de l'application. De plus, avec Reducer, il est beaucoup plus facile d'imaginer tous les changements d'état avant le premier lancement de l'application sur l'appareil.
  • Enfin, nous sommes en mesure de créer une interface simple. Étant donné que tous les états sont stockés en un seul endroit (Store), qui prend en compte les intentions (Intents), apporte des modifications à l'aide de Reducer et montre une chaîne d'états, vous pouvez mettre toute la logique métier dans le Store et utiliser l'interface pour lancer les intentions et afficher les états.


Ou pas?

... peut-être que le train se précipite sur toi


Le réducteur seul n'est clairement pas suffisant. Qu'en est-il des tâches asynchrones avec des résultats différents? Comment répondre aux push depuis le serveur? Qu'en est-il du lancement de tâches supplémentaires (par exemple, vider le cache ou charger des données de la base de données locale) après un changement d'état? Il s'avère que soit nous n'incluons pas toute cette logique dans Reducer (c'est-à-dire qu'une bonne moitié de la logique métier ne sera pas couverte, et cela devra être pris en charge par ceux qui décident d'utiliser notre composant), soit nous forcons Reducer à tout faire en même temps.

Exigences du framework MVI


Bien sûr, nous aimerions inclure toute la logique métier d'une fonctionnalité individuelle dans un composant indépendant, avec lequel les développeurs d'autres équipes pourraient facilement travailler en créant simplement une instance de celle-ci et en souscrivant à son état.

De plus:

  • Il devrait facilement interagir avec d'autres composants du système;
  • dans sa structure interne, il devrait y avoir une séparation claire des tâches;
  • toutes les parties internes du composant doivent être complètement déterministes;
  • la mise en œuvre de base d'un tel composant ne devrait être simple et compliquée que si des éléments supplémentaires sont nécessaires.

Nous ne sommes pas immédiatement passés de Reducer à la solution que nous utilisons aujourd'hui. Chaque équipe a été confrontée à des problèmes en utilisant différentes approches, et le développement d'une solution universelle qui conviendrait à tout le monde semblait peu probable.

Et pourtant, l'état actuel des choses convient à tout le monde. Nous sommes heureux de vous présenter MVICore! Le code source de la bibliothèque est ouvert et disponible sur GitHub .

Quel est le bon MVICore


  • Un moyen simple d'implémenter des fonctionnalités métier de programmation réactive avec un flux de données unidirectionnel.
  • Mise à l'échelle: l'implémentation de base comprend uniquement Reducer, et dans des cas plus complexes, vous pouvez utiliser des composants supplémentaires.
  • Une solution pour travailler avec des événements que vous ne souhaitez pas inclure dans l'état ( problème SingleLiveEvent ).
  • Une API simple pour lier des fonctionnalités (et d'autres composants réactifs de votre système) à l'interface utilisateur et les uns aux autres avec prise en charge du cycle de vie Android (et pas seulement).
  • Prise en charge du middleware (voir ci-dessous) pour chaque composant du système.
  • Enregistreur prêt à l'emploi et possibilité de déboguer dans le temps pour chaque composant.


Brève introduction à la fonctionnalité


Étant donné que des instructions pas à pas ont déjà été publiées sur GitHub, je vais omettre des exemples détaillés et me concentrer sur les principaux composants du cadre.

Fonctionnalité - l'élément central du cadre qui contient toute la logique métier du composant. La fonction est définie par trois paramètres: fonction d'interface <Souhait, État, Actualités>

Le souhait correspond à l'intention du modèle-vue-intention - ce sont les changements que nous voulons voir dans le modèle (puisque le terme intention a sa propre signification dans l'environnement des développeurs Android, nous avons dû trouver un nom différent). Le souhait est le point d'entrée de la fonctionnalité.

L'état est, comme vous l'avez déjà compris, l'état du composant. L'État n'est pas immuable: nous ne pouvons pas changer ses valeurs internes, mais nous pouvons créer de nouveaux États. Voici la sortie: chaque fois que nous créons un nouvel état, nous le transmettons au flux Rx.

Nouvelles - un composant pour le traitement des signaux qui ne devraient pas être dans l'État; Les news sont utilisées une fois lors de la création ( problème SingleLiveEvent ). L'utilisation de News est facultative (vous pouvez utiliser Nothing from Kotlin dans la signature de fonctionnalité).

Également dans la fonction doit être présent Réducteur .

La fonctionnalité peut contenir les composants suivants:

  • Acteur - effectue des tâches asynchrones et / ou des modifications d'état conditionnelles en fonction de l'état actuel (par exemple, la validation de formulaire). L'acteur lie le souhait à un numéro d'effet spécifique, puis le transmet au réducteur (en l'absence d'acteur, le réducteur reçoit directement le souhait).
  • NewsPublisher - Appelé lorsque Wish devient un effet produisant le résultat en tant que nouvel état. Sur la base de ces données, il décide de créer des News.
  • PostProcessor - également appelé après la création d'un nouvel État et sait également quel effet a conduit à sa création. Il lance certaines actions supplémentaires (Actions). Action - ce sont des «souhaits internes» (par exemple, vider le cache) qui ne peuvent pas être démarrés de l'extérieur. Ils sont exécutés dans l'Acteur, ce qui conduit à une nouvelle chaîne d'effets et d'états.
  • Bootstrapper est un composant qui peut exécuter des actions seul. Sa fonction principale est d'initialiser Feature et / ou de corréler les sources externes avec Action. Ces sources externes peuvent être des informations provenant d'une autre fonctionnalité ou des données de serveur qui devraient modifier l'état sans intervention de l'utilisateur.


Le diagramme peut sembler simple:


ou inclure tous les composants supplémentaires ci-dessus:


La fonctionnalité elle-même, contenant toute la logique métier et prête à l'emploi, ne semble nulle part plus facile:



Quoi d'autre?


Feature, la pierre angulaire du cadre, fonctionne à un niveau conceptuel. Mais la bibliothèque a bien plus à offrir.

  • Étant donné que tous les composants de Feature sont déterministes (à l'exception d'Actor, qui n'est pas complètement déterministe car il interagit avec des sources de données externes, mais même avec cela, la branche qu'il exécute est déterminée par les données d'entrée et non par des conditions externes), chacun d'eux peut être enveloppé dans un middleware. Dans le même temps, la bibliothèque contient déjà des solutions prêtes à l'emploi pour la journalisation et le débogage de voyage dans le temps .
  • Le middleware s'applique non seulement à Feature, mais également à tout autre objet qui implémente l'interface Consumer <T>, ce qui en fait un outil de débogage indispensable.
  • Lorsque vous utilisez un débogueur pour le débogage tout en vous déplaçant dans la direction opposée, vous pouvez implémenter le module DebugDrawer .
  • La bibliothèque comprend un plugin IDEA qui peut être utilisé pour ajouter des modèles pour les implémentations les plus courantes de Feature, ce qui fait gagner beaucoup de temps.
  • Il existe des classes d'assistance pour prendre en charge Android, mais la bibliothèque elle-même n'est pas liée à Android.
  • Il existe une solution prête à l'emploi pour lier des composants à l'interface utilisateur et les uns aux autres via une API élémentaire (cela sera discuté dans le prochain article).

Nous espérons que vous allez essayer notre bibliothèque et que son utilisation vous apportera autant de joie que nous - sa création!

Les 24 et 25 novembre, tentez votre chance et rejoignez-nous! Nous organiserons un événement de location mobile: en une journée il sera possible de passer par toutes les étapes de sélection et de recevoir une offre. Mes collègues des équipes iOS et Android viendront communiquer avec les candidats à Moscou. Si vous venez d'une autre ville, Badoo engage des frais de déplacement. Pour recevoir une invitation, passez le test de dépistage sur le lien . Bonne chance

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


All Articles