Modèle architectural MVI dans Kotlin Multiplatform, Partie 1



Il y a environ un an, je me suis intéressé à la nouvelle technologie Kotlin Multiplatform. Il vous permet d'écrire du code commun et de le compiler pour différentes plateformes, tout en ayant accès à leur API. Depuis lors, j'ai expérimenté activement dans ce domaine et fait la promotion de cet outil dans notre entreprise. Un résultat, par exemple, est notre bibliothèque Reaktive - Reactive Extensions for Kotlin Multiplatform.

Dans les applications Badoo et Bumble pour le développement Android, nous utilisons le modèle architectural MVI (pour plus de détails sur notre architecture, lisez l'article de Zsolt Kocsi: « Architecture MVI moderne basée sur Kotlin"). Travaillant sur différents projets, je suis devenu un grand fan de cette approche. Bien sûr, je ne pouvais pas manquer l'occasion d'essayer MVI dans Kotlin Multiplatform. De plus, le cas était approprié: nous devions écrire des exemples pour la bibliothèque Reaktive. Après mes expériences, j'ai été encore plus inspiré par MVI.

Je fais toujours attention à la façon dont les développeurs utilisent Kotlin Multiplatform et comment ils construisent l'architecture de ces projets. Selon mes observations, le développeur Kotlin Multiplatform moyen est en fait un développeur Android qui utilise le modèle MVVM dans son travail simplement parce qu'il y est tellement habitué. Certains appliquent en outre une «architecture propre». Cependant, à mon avis, MVI est le mieux adapté pour Kotlin Multiplatform, et une «architecture propre» est une complication inutile.

J'ai donc décidé d'écrire cette série de trois articles sur les sujets suivants:

  1. Une brève description du modèle MVI, une déclaration du problème et la création d'un module commun à l'aide de Kotlin Multiplatform.
  2. Intégration d'un module commun dans les applications iOS et Android.
  3. Tests unitaires et d'intégration.

Voici le premier article de la série. Il intéressera tous ceux qui utilisent déjà ou prévoient d'utiliser Kotlin Multiplatform.

Je note tout de suite que le but de cet article n'est pas de vous apprendre à travailler avec le Kotlin Multiplatform lui-même. Si vous sentez que vous n'avez pas suffisamment de connaissances dans ce domaine, je vous recommande de vous familiariser d'abord avec l' introduction et la documentation (en particulier les sections « Concurrence » et « Immuabilité » pour comprendre les fonctionnalités du modèle de mémoire Kotlin / Native). Dans cet article, je ne décrirai pas la configuration du projet, les modules et d'autres choses qui ne sont pas liées au sujet.

MVI


Rappelons d'abord ce qu'est MVI. L'abréviation signifie Model-View-Intent. Il n'y a que deux composants principaux dans le système:

  • modèle - une couche de logique et de données (le modèle stocke également l'état actuel du système);
  • (View) — UI-, (states) (intents).

Le diagramme suivant est probablement déjà familier à beaucoup:



Donc, nous voyons ces composants très basiques: le modèle et la présentation. Tout le reste, ce sont les données qui circulent entre eux.

Il est facile de voir que les données ne se déplacent que dans une seule direction. Les états viennent du modèle et tombent dans la vue pour l'affichage, les intentions viennent de la vue et vont dans le modèle pour le traitement. Cette circulation est appelée flux de données unidirectionnel.

En pratique, un modèle est souvent représenté par une entité appelée Store (il est emprunté à Redux). Cependant, cela ne se produit pas toujours. Par exemple, dans notre bibliothèque MVICore, le modèle est appelé Feature.

Il convient également de noter que le MVI est très étroitement lié à la réactivité. La présentation des flux de données et de leur transformation, ainsi que la gestion du cycle de vie des abonnements est très pratique à mettre en œuvre à l'aide de bibliothèques de programmation réactive. Un assez grand nombre d'entre eux sont maintenant disponibles, cependant, lors de l'écriture de code général dans Kotlin Multiplatform, nous ne pouvons utiliser que des bibliothèques multi-plateformes. Nous avons besoin d'abstraction pour les flux de données, nous avons besoin de pouvoir connecter et déconnecter leurs entrées et sorties, ainsi que d'effectuer des transformations. Pour le moment, je connais deux de ces bibliothèques:

  • notre bibliothèque Reaktive - implémentation des extensions réactives sur Kotlin Multiplatform;
  • Coroutines et Flow - implémentation de flux froids avec les coroutines Kotlin.

Formulation du problème


Le but de cet article est de montrer comment utiliser le modèle MVI dans Kotlin Multiplatform et quels sont les avantages et les inconvénients de cette approche. Par conséquent, je ne m'attacherai à aucune implémentation MVI spécifique. Cependant, j'utiliserai Reaktive, car les flux de données sont toujours nécessaires. Si vous le souhaitez, après avoir compris l'idée, Reaktive peut être remplacé par des coroutines et Flow. En général, je vais essayer de rendre notre MVI aussi simple que possible, sans complications inutiles.

Pour démontrer MVI, je vais essayer de mettre en œuvre le projet le plus simple qui répond aux exigences suivantes:

  • prise en charge d'Android et iOS;
  • Démonstration du fonctionnement asynchrone (entrée-sortie, traitement des données, etc.);
  • autant de code commun que possible;
  • Implémentation de l'interface utilisateur avec des outils natifs de chaque plate-forme;
  • manque de Rx côté plateforme (pour que vous n'ayez pas à spécifier de dépendances sur Rx en tant qu '«api»).

A titre d'exemple, j'ai choisi une application très simple: un écran avec un bouton, en cliquant sur lequel une liste d'images arbitraires de chats sera téléchargée et affichée. Pour télécharger des images, j'utiliserai l'API ouverte: https://thecatapi.com . Cela vous permettra de répondre à l'exigence d'un fonctionnement asynchrone, car vous devez télécharger des listes à partir du Web et analyser le fichier JSON.

Vous pouvez trouver tout le code source du projet sur notre GitHub .

Mise en route: abstractions pour MVI


Nous devons d'abord introduire quelques abstractions pour notre MVI. Nous aurons besoin des composants très basiques - le modèle et la vue - et quelques typealias.

Typealiases


Pour traiter les intentions, nous introduisons un acteur (Acteur) - une fonction qui accepte l'intention et l'état actuel et renvoie un flux de résultats (Effet):

typealias Acteur < État , intention , effet > = ( État , intention ) - > Observable < Effet >
voir brut MviKmpActor.kt hébergé avec ❤ par GitHub

Nous avons également besoin d'un réducteur (Reducer) - une fonction qui prend effet et l'état actuel et renvoie un nouvel état:

typealias Reducer < State , Effect > = ( State , Effect ) - > State
voir brut MviKmpReducer.kt hébergé avec ❤ par GitHub

Boutique


Store présentera un modèle de MVI. Il doit accepter des intentions et donner un flot d'États. Lorsque vous vous abonnez à un flux d'état, l'état actuel doit être émis.

Présentons l'interface appropriée:

Interface Store < in Intent : Any , out State : Any > : Consumer < Intent >, Observable < State >, Jetable

Ainsi, notre magasin a les propriétés suivantes:

  • a deux paramètres génériques: intention d'entrée et état de sortie;
  • est un consommateur d'intentions (Consumer <Intent>);
  • est un flux d'état (Observable <Etat>);
  • il est destructible (jetable).

Puisqu'il n'est pas très pratique d'implémenter une telle interface à chaque fois, nous aurons besoin d'un certain assistant:

class StoreHelper < in Intent : Any , out State : Any , in Effect : Any > (
initialState : State ,
acteur val privé : Acteur < État , intention , effet >,
private val reducer: Reducer<State, Effect>
) : Observable<State>, DisposableScope by DisposableScope() {
init {
ensureNeverFrozen()
}
private val subject = BehaviorSubject(initialState)
fun onIntent(intent: Intent) {
actor(subject.value, intent).subscribeScoped(isThreadLocal = true, onNext = ::onEffect)
}
fun onEffect ( effet : effet ) {
subject.onNext (réducteur (subject.value, effect))
}
remplacer le fun subscribe ( observateur : ObservableObserver < State >) {
subject.subscribe (observateur)
}
}
voir brut MviKmpStoreHelper.kt hébergé avec ❤ par GitHub


StoreHelper est une petite classe qui nous facilitera la création d'un magasin. Il a les propriétés suivantes:

  • a trois paramètres génériques: intention et effet d'entrée et état de sortie;
  • accepte l'état initial par le constructeur, l'acteur et la boîte de vitesses;
  • est un flux d'États;
  • destructible (jetable);
  • non-gel (pour que les abonnés ne soient pas non plus gelés );
  • implémente DisposableScope (interface de Reaktive pour la gestion des abonnements);
  • accepte et traite les intentions et les effets.

Regardez le schéma de notre magasin. L'acteur et la boîte de vitesses sont des détails d'implémentation:



considérons plus en détail la méthode onIntent:

  • accepte les intentions comme argument;
  • appelle l'acteur et lui transmet l'intention et l'état actuel;
  • s'abonne au flux d'effets renvoyé par l'acteur;
  • dirige tous les effets vers la méthode onEffect;
  • La souscription aux effets est effectuée à l'aide du drapeau isThreadLocal (cela évite le gel dans Kotlin / Native).

Examinons maintenant de plus près la méthode onEffect:

  • prend les effets en argument;
  • appelle la boîte de vitesses et y transfère l'effet et l'état actuel;
  • transmet le nouvel état au BehaviorSubject, ce qui conduit à la réception du nouvel état par tous les abonnés.

Vue


Passons maintenant à la présentation. Il doit accepter les modèles à afficher et diffuser un flux d'événements. Nous ferons également une interface distincte:

interface MviView < in Modèle : Any , out Événement : Any > {
événements val : observable < événement >
rendu amusant ( modèle : modèle )
}
voir brut MviKmpMviView.kt hébergé avec ❤ par GitHub


Une vue a les propriétés suivantes:

  • a deux paramètres génériques: modèle d'entrée et événement de sortie;
  • accepte les modèles à afficher à l'aide de la méthode de rendu;
  • distribue un flux d'événements à l'aide de la propriété events.

J'ai ajouté le préfixe Mvi au nom MviView pour éviter toute confusion avec Android View. De plus, je n'ai pas développé les interfaces Consumer et Observable, mais j'ai simplement utilisé la propriété et la méthode. Cela vous permet de définir l'interface de présentation sur la plate-forme pour l'implémentation (Android ou iOS) sans exporter Rx en tant que dépendance «api». L'astuce est que les clients n'interagiront pas directement avec la propriété «events», mais implémenteront l'interface MviView, développant la classe abstraite.

Ajoutez immédiatement cette classe abstraite pour représenter:

abstract class AbstractMviView<in Model : Any, Event : Any> : MviView<Model, Event> {
private val subject = PublishSubject<Event>()
override val events: Observable<Event> = subject
protected fun dispatch(event: Event) {
subject.onNext(event)
}
}

Cette classe nous aidera à émettre des événements, ainsi qu'à sauver la plateforme d'interagir avec Rx.

Voici un diagramme qui montre comment cela fonctionnera: Le



magasin produit des états qui sont transformés en modèles et affichés par la vue. Ce dernier produit des événements qui sont convertis en intentions et livrés au magasin pour traitement. Cette approche supprime la cohérence entre Store et présentation. Mais dans des cas simples, une vue peut fonctionner directement avec les états et les intentions.

C'est tout ce dont nous avons besoin pour implémenter MVI. Écrivons le code général.

Code commun


Plan


  1. Nous réaliserons un module général dont la tâche est de télécharger et d'afficher une liste d'images de chats.
  2. UI nous résumons l'interface et nous transférerons son implémentation à l'extérieur.
  3. Nous cacherons notre mise en œuvre derrière une façade pratique.

Kittenstore


Commençons par l'essentiel - créer un KittenStore qui chargera une liste d'images:

interface interne KittenStore : Store < Intent , State > {
classe scellée Intention {
objet Recharger : Intent ()
}
État de la classe de données (
val isLoading : Boolean = false ,
val data : Data = Data . Images ()
) {
Données de classe scellées {
classe de données Images ( val urls : List < String > = emptyList ()) : Data ()
Erreur d' objet : Data ()
}
}
}


Nous avons étendu l'interface Store avec des types d'intention et des types d'état. Attention: l'interface est déclarée interne. Notre KittenStore est les détails d'implémentation du module. Notre intention n'est qu'une - Recharger, elle provoque le chargement d'une liste d'images. Mais la condition mérite d'être examinée plus en détail:

  • l'indicateur isLoading indique si le téléchargement est en cours ou non;
  • La propriété data peut prendre l'une des deux options suivantes:
    • Images - une liste de liens vers des images;
    • Erreur - signifie qu'une erreur s'est produite.

Commençons maintenant l'implémentation. Nous le ferons par étapes. Tout d'abord, créez une classe KittenStoreImpl vide qui implémentera l'interface KittenStore:

classe interne KittenStoreImpl (
) : KittenStore , DisposableScope par DisposableScope () {
remplacer fun onNext ( valeur : Intent ) {
}
remplacer le fun subscribe ( observateur : ObservableObserver < State >) {
}
}


Nous avons également implémenté l'interface familière DisposableScope. Cela est nécessaire pour une gestion pratique des abonnements.

Nous devrons télécharger la liste des images à partir du Web et analyser le fichier JSON. Déclarez les dépendances correspondantes:

classe interne KittenStoreImpl (
réseau de val privé : réseau ,
l' analyseur de val privé : analyseur
) : KittenStore, DisposableScope by DisposableScope() {
override fun onNext(value: Intent) {
}
override fun subscribe(observer: ObservableObserver<State>) {
}
interface Network {
fun load(): Maybe<String>
}
interface Parser {
analyse amusante ( json : String ) : Peut - être < List < String >>
}
}

Le réseau téléchargera le texte du fichier JSON à partir du réseau et l'analyseur analysera le fichier JSON et renverra une liste de liens d'image. En cas d'erreur, Maybe se terminera simplement sans résultat. Dans cet article, nous ne sommes pas intéressés par le type d'erreur.

Déclarez maintenant les effets et le réducteur:

classe interne KittenStoreImpl (
réseau de val privé : réseau ,
l' analyseur de val privé : analyseur
) : KittenStore, DisposableScope by DisposableScope() {
override fun onNext(value: Intent) {
}
override fun subscribe(observer: ObservableObserver<State>) {
}
private fun reduce(state: State, effect: Effect): State =
when (effect) {
is Effect.LoadingStarted -> state.copy(isLoading = true)
is Effect.LoadingFinished -> state.copy(isLoading = false, data = State.Data.Images(urls = effect.imageUrls))
is Effect.LoadingFailed -> state.copy(isLoading = false, data = State.Data.Error)
}
private sealed class Effect {
object LoadingStarted : Effect()
data class LoadingFinished(val imageUrls: List<String>) : Effect()
object LoadingFailed : Effect()
}
interface Network {
fun load(): Maybe<String>
}
interface Parser {
analyse amusante ( json : String ) : Peut - être < List < String >>
}
}

Avant de commencer le téléchargement, nous donnons l'effet LoadingStarted, ce qui provoque la définition de l'indicateur isLoading. Une fois le téléchargement terminé, nous émettons LoadingFinished ou LoadingFailed. Dans le premier cas, nous effaçons l'indicateur isLoading et appliquons la liste des images, dans le second, nous effaçons également l'indicateur et appliquons l'état d'erreur. Veuillez noter que les effets sont l'API privée de notre KittenStore.

Maintenant, nous implémentons le téléchargement lui-même:

classe interne KittenStoreImpl (
private val network: Network,
private val parser: Parser
) : KittenStore, DisposableScope by DisposableScope() {
override fun onNext(value: Intent) {
}
override fun subscribe(observer: ObservableObserver<State>) {
}
private fun reload(network: Network, parser: Parser): Observable<Effect> =
network
.load()
.flatMap(parser::parse)
.map(Effect::LoadingFinished)
.observeOn(mainScheduler)
.asObservable()
.defaultIfEmpty(Effect.LoadingFailed)
.startWithValue(Effect.LoadingStarted)
private fun reduce(state: State, effect: Effect): State =
// Omitted code
private sealed class Effect {
// Omitted code
}
interface Network {
fun load(): Maybe<String>
}
interface Parser {
fun parse(json: String): Maybe<List<String>>
}
}

Ici, il convient de prêter attention au fait que nous avons passé Network and Parser à la fonction de rechargement, malgré le fait qu'ils sont déjà disponibles pour nous en tant que propriétés du constructeur. Ceci est fait pour éviter les références à cela et, par conséquent, geler le KittenStore entier.

Enfin, utilisez StoreHelper et terminez l'implémentation de KittenStore:

classe interne KittenStoreImpl (
réseau de val privé : réseau ,
l' analyseur de val privé : analyseur
) : KittenStore , DisposableScope par DisposableScope () {
private val helper = StoreHelper(State(), ::handleIntent, ::reduce).scope()
override fun onNext(value: Intent) {
helper.onIntent(value)
}
override fun subscribe(observer: ObservableObserver<State>) {
helper.subscribe(observer)
}
private fun handleIntent(state: State, intent: Intent): Observable<Effect> =
when (intent) {
is Intent.Reload -> reload(network, parser)
}
private fun reload(network: Network, parser: Parser): Observable<Effect> =
// Omitted code
private fun reduce(state: State, effect: Effect): State =
// Omitted code
private sealed class Effect {
// Omitted code
}
interface Network {
fun load () : Peut - être < String >
}
analyseur d' interface {
analyse amusante ( json : String ) : Peut - être < List < String >>
}
}

Notre KittenStore est prêt! Nous passons à la présentation.

Kittenview


Déclarez l'interface suivante:

interface KittenView : MviView < modèle , événement > {
modèle de classe de données (
val isLoading : Boolean ,
val isError : Boolean ,
val imageUrls : List < String >
)
événement de classe scellé {
objet RefreshTriggered : Event ()
}
}

Nous avons annoncé un modèle de vue avec des indicateurs de charge et d'erreur et une liste de liens d'image. Nous avons un seul événement - RefreshTriggered. Il est émis chaque fois que l'utilisateur invoque une mise à jour. KittenView est l'API publique de notre module.

KittenDataSource


La tâche de cette source de données est de télécharger du texte pour un fichier JSON à partir du Web. Comme d'habitude, déclarez l'interface:

interface interne KittenDataSource {
charge amusante ( limite : Int , décalage : Int ) : Peut - être < String >
}

Des implémentations de source de données seront effectuées pour chaque plate-forme séparément. Par conséquent, nous pouvons déclarer une méthode d'usine en utilisant expect / actual:

interne attendre plaisir KittenDataSource () : KittenDataSource

Les implémentations de la source de données seront discutées dans la partie suivante, où nous implémenterons des applications pour iOS et Android.

L'intégration


La dernière étape est l'intégration de tous les composants.

Implémentation de l'interface réseau:

classe interne KittenStoreNetwork (
private val dataSource : KittenDataSource
) : KittenStoreImpl . Réseau {
remplacer fun load () : Peut - être < String > = dataSource.load (limit = 50 , offset = 0 )
}
voir brut KittenStoreNetwork.kt hébergé avec ❤ par GitHub


Implémentation de l'interface analyseur:

objet interne KittenStoreParser : KittenStoreImpl . Analyseur {
remplacer l' analyse amusante ( json : String ) : Peut - être < List < String >> =
peut-être de la fonction {
Json ( JsonConfiguration . Stable )
.parseJson (json)
.jsonArray
.map {it.jsonObject.getPrimitive ( " url " ) .content}
}
.subscribeOn (computationScheduler)
.onErrorComplete ()
}
voir brut KittenStoreParser.kt hébergé avec ❤ par GitHub

Ici, nous avons utilisé la bibliothèque kotlinx.serialization . L'analyse est effectuée sur un planificateur de calcul pour éviter de bloquer le thread principal.

Convertir l'état en modèle de vue:

État d' amusement interne . toModel () : Model =
Modèle (
isLoading = isLoading,
isError = when ( data ) {
est l' État . Les données . Images - > faux
est l' État . Les données . Erreur - > vrai
},
imageUrls = when ( data ) {
est l' État . Les données . Images - > data .urls
est l' État . Les données . Erreur - > emptyList ()
}
)
voir brut MviKmpStateToModel.kt hébergé avec ❤ par GitHub

Convertissez les événements en intentions:

événement amusant interne . toIntent () : Intent =
quand ( ce ) {
est un événement . RefreshTriggered - > Intent . Recharger
}
voir brut MviKmpEventToIntent.kt hébergé avec ❤ par GitHub

Préparation de la façade:

class KittenComponent {
fun onViewCreated ( vue : KittenView ) {
}
fun onStart () {
}
fun onStop () {
}
fun onViewDestroyed () {
}
fun onDestroy () {
}
}

Familier du cycle de vie de nombreux développeurs Android. C'est génial pour iOS, et même JavaScript. Le diagramme de la transition entre les états du cycle de vie de notre façade ressemble à ceci: je vais


expliquer brièvement ce qui se passe ici:

  • tout d'abord, la méthode onCreate est appelée, après elle - onViewCreated, puis - onStart: cela met la façade en état de fonctionnement (démarrée);
  • à un certain moment après cela, la méthode onStop est appelée: cela met la façade dans un état arrêté (arrêté);
  • à l'arrêt, l'une des deux méthodes peut être appelée: onStart ou onViewDestroyed, c'est-à-dire que la façade peut être redémarrée ou que sa vue peut être détruite;
  • lorsque la vue est détruite, soit elle peut être recréée (onViewCreated), soit la façade entière peut être détruite (onDestroy).

La mise en œuvre de la façade peut ressembler à ceci:

class KittenComponent {
magasin de val privé =
KittenStoreImpl (
network = KittenStoreNetwork (dataSource = KittenDataSource ()),
parser = KittenStoreParser
)
vue var privée : KittenView? = null
privé var startStopScope : DisposableScope? = null
fun onViewCreated ( vue : KittenView ) {
ce .view = voir
}
fun onStart() {
val view = requireNotNull(view)
startStopScope = disposableScope {
store.subscribeScoped(onNext = view::render)
view.events.map(Event::toIntent).subscribeScoped(onNext = store::onNext)
}
}
fun onStop() {
startStopScope?.dispose()
}
fun onViewDestroyed() {
view = null
}
fun onDestroy() {
store.dispose()
}
}

Comment ça fonctionne:

  • nous créons d'abord une instance de KittenStore;
  • dans la méthode onViewCreated, nous nous souvenons du lien vers KittenView;
  • dans onStart, nous signons KittenStore et KittenView l'un à l'autre;
  • dans onStop, nous les reflétons les uns des autres;
  • dans onViewDestroyed, nous effaçons le lien vers la vue;
  • dans onDestroy, détruisez KittenStore.

Conclusion


C'était le premier article de ma série MVI chez Kotlin Multiplatform. Dans ce document, nous:

  • rappelé ce qu'est MVI et comment il fonctionne;
  • fait l'implémentation MVI la plus simple sur Kotlin Multiplatform en utilisant la bibliothèque Reaktive;
  • créé un module commun pour charger une liste d'images à l'aide de MVI.

Notez les propriétés les plus importantes de notre module commun:

  • nous avons réussi à mettre tout le code dans le module multiplateforme à l'exception du code UI; toute la logique, plus les connexions et les conversions entre la logique et l'interface utilisateur sont communes;
  • la logique et l'interface utilisateur sont complètement indépendantes;
  • l'implémentation de l'interface utilisateur est très simple: il vous suffit d'afficher les modèles de vue entrants et de lancer des événements;
  • l'intégration du module est également simple: il vous suffit de:
    • implémenter l'interface KittenView (protocole);
    • créer une instance de KittenComponent;
    • appeler ses méthodes de cycle de vie au bon moment;
  • cette approche évite le «flux» de Rx (ou coroutine) dans les plateformes, ce qui signifie que nous n'avons à gérer aucun abonnement au niveau de l'application;
  • toutes les classes importantes sont résumées par des interfaces et testées.

Dans la partie suivante, je montrerai en pratique à quoi ressemble l'intégration de KittenComponent dans les applications iOS et Android.

Suivez-moi sur Twitter et restez connecté!

All Articles