Modèle architectural MVI dans Kotlin Multiplatform, partie 2





Ceci est le deuxième de trois articles sur l'application du modèle architectural MVI dans Kotlin Multiplatform. Dans le premier article, nous nous sommes souvenus de ce qu'est MVI et l'avons appliqué pour écrire du code commun pour iOS et Android. Nous avons introduit des abstractions simples telles que Store et View, ainsi que certaines classes d'assistance, et les avons utilisées pour créer un module commun.



Le but de ce module est de télécharger des liens vers des images depuis le Web et d'associer une logique métier à une interface utilisateur représentée sous la forme d'une interface Kotlin, qui doit être implémentée nativement sur chaque plateforme. C'est ce que nous allons faire dans cet article.



Nous implémenterons des parties spécifiques à la plate-forme du module commun et les intégrerons dans les applications iOS et Android. Comme précédemment, je suppose que le lecteur a déjà des connaissances de base sur Kotlin Multiplatform, je ne parlerai donc pas des configurations de projet et d'autres choses non liées à MVI dans Kotlin Multiplatform.



Un exemple de projet mis Ă  jour est disponible sur notre GitHub .



Plan



Dans le premier article, nous avons défini l'interface KittenDataSource dans notre module générique Kotlin. Cette source de données est responsable du téléchargement des liens vers des images à partir du Web. Il est maintenant temps de le mettre en œuvre pour iOS et Android. Pour ce faire, nous utiliserons une fonctionnalité Kotlin Multiplatform telle qu'attendue / réelle . Nous intégrons ensuite notre module générique Kittens dans les applications iOS et Android. Pour iOS, nous utilisons SwiftUI, et pour Android, nous utilisons des vues Android régulières.



Le plan est donc le suivant:



  • ImplĂ©mentation cĂ´tĂ© KittenDataSource

    • Pour iOS
    • Pour Android
  • IntĂ©gration du module Kittens dans l'application iOS

    • ImplĂ©mentation de KittenView Ă  l'aide de SwiftUI
    • IntĂ©gration de KittenComponent dans SwiftUI View
  • IntĂ©gration du module Kittens dans l'application Android

    • ImplĂ©mentation de KittenView Ă  l'aide d'Android Views
    • IntĂ©gration de KittenComponent dans un fragment Android




Implémentation de KittenDataSource



Rappelons-nous d'abord Ă  quoi ressemble cette interface:



internal interface KittenDataSource {
    fun load(limit: Int, offset: Int): Maybe<String>
}


Et voici l'en-tête de la fonction d'usine que nous allons implémenter:



internal expect fun KittenDataSource(): KittenDataSource


L'interface et sa fonction d'usine sont déclarées internes et sont des détails d'implémentation du module Kittens. En utilisant expect / actual, nous pouvons accéder à l'API de chaque plateforme.



KittenDataSource pour iOS



Implémentons d'abord une source de données pour iOS. Pour accéder à l'API iOS, nous devons placer notre code dans l'ensemble de sources «iosCommonMain». Il est configuré pour dépendre de commonMain. Les ensembles cibles de code source (iosX64Main et iosArm64Main) dépendent à leur tour de iosCommonMain. Vous pouvez trouver la configuration complète ici .



Voici l'implémentation de la source de données:




internal class KittenDataSourceImpl : KittenDataSource {
    override fun load(limit: Int, offset: Int): Maybe<String> =
        maybe<String> { emitter ->
            val callback: (NSData?, NSURLResponse?, NSError?) -> Unit =
                { data: NSData?, _, error: NSError? ->
                    if (data != null) {
                        emitter.onSuccess(NSString.create(data, NSUTF8StringEncoding).toString())
                    } else {
                        emitter.onComplete()
                    }
                }

            val task =
                NSURLSession.sharedSession.dataTaskWithURL(
                    NSURL(string = makeKittenEndpointUrl(limit = limit, offset = offset)),
                    callback.freeze()
                )
            task.resume()
            emitter.setDisposable(Disposable(task::cancel))
        }
            .onErrorComplete()
}



L'utilisation de NSURLSession est le principal moyen de télécharger des données à partir du Web dans iOS. Il est asynchrone, donc aucun changement de thread n'est requis. Nous enveloppons simplement l'appel dans Maybe et ajoutons la gestion des réponses, des erreurs et des annulations.



Et voici la mise en Ĺ“uvre de la fonction d'usine:



internal actual fun KittenDataSource(): KittenDataSource = KittenDataSourceImpl()


Ă€ ce stade, nous pouvons compiler notre module commun pour iosX64 et iosArm64.



KittenDataSource pour Android



Pour accéder à l'API Android, nous devons placer notre code dans l'ensemble de codes source androidMain. Voici à quoi ressemble l'implémentation de la source de données:



internal class KittenDataSourceImpl : KittenDataSource {
    override fun load(limit: Int, offset: Int): Maybe<String> =
        maybeFromFunction {
            val url = URL(makeKittenEndpointUrl(limit = limit, offset = offset))
            val connection = url.openConnection() as HttpURLConnection

            connection
                .inputStream
                .bufferedReader()
                .use(BufferedReader::readText)
        }
            .subscribeOn(ioScheduler)
            .onErrorComplete()
}


Pour Android, nous avons implémenté HttpURLConnection. Encore une fois, il s'agit d'un moyen populaire de charger des données dans Android sans utiliser de bibliothèques tierces. Cette API est bloquante, nous devons donc passer au thread d'arrière-plan à l'aide de l'opérateur subscribeOn.



L'implémentation de la fonction d'usine pour Android est identique à celle utilisée pour iOS:



internal actual fun KittenDataSource(): KittenDataSource = KittenDataSourceImpl()


Nous pouvons maintenant compiler notre module commun pour Android.



Intégration du module Kittens dans l'application iOS



C'est la partie la plus difficile (et la plus intéressante) du travail. Disons que nous avons compilé notre module comme décrit dans l'application iOS README . Nous avons également créé un projet SwiftUI de base dans Xcode et y avons ajouté notre framework Kittens. Il est temps d'intégrer KittenComponent dans votre application iOS.



Implémentation de KittenView



Commençons par implémenter KittenView. Tout d'abord, rappelons-nous à quoi ressemble son interface dans Kotlin:



interface KittenView : MviView<Model, Event> {
    data class Model(
        val isLoading: Boolean,
        val isError: Boolean,
        val imageUrls: List<String>
    )

    sealed class Event {
        object RefreshTriggered : Event()
    }
}


Ainsi, notre KittenView prend des modèles et déclenche des événements. Pour rendre le modèle dans SwiftUI, nous devons créer un proxy simple:



import Kittens

class KittenViewProxy : AbstractMviView<KittenViewModel, KittenViewEvent>, KittenView, ObservableObject {
    @Published var model: KittenViewModel?
    
    override func render(model: KittenViewModel) {
        self.model = model
    }
}


Proxy implémente deux interfaces (protocoles): KittenView et ObservableObject. Le KittenViewModel est exposé à l'aide de la propriété @ Published du model, de sorte que notre vue SwiftUI peut s'y abonner. Nous avons utilisé la classe AbstractMviView que nous avons créée dans l'article précédent. Nous n'avons pas à interagir avec la bibliothèque Reaktive - nous pouvons utiliser la méthode de répartition pour envoyer des événements.



Pourquoi évitons-nous les bibliothèques Reaktive (ou coroutines / Flow) dans Swift? Parce que la compatibilité Kotlin-Swift a plusieurs limitations. Par exemple, les paramètres génériques ne sont pas exportés pour les interfaces (protocoles), les fonctions d'extension ne peuvent pas être appelées de la manière habituelle, etc. La plupart des limitations sont dues au fait que la compatibilité Kotlin-Swift se fait via Objective-C (vous pouvez trouver toutes les limitations ici). De plus, en raison du modèle de mémoire délicat Kotlin / Native, je pense qu'il est préférable d'avoir le moins d'interaction Kotlin-iOS possible.



Il est maintenant temps de créer une vue SwiftUI. Commençons par créer un squelette:



struct KittenSwiftView: View {
    @ObservedObject var proxy: KittenViewProxy

    var body: some View {
    }
}


Nous avons déclaré notre vue SwiftUI, qui dépend de KittenViewProxy. Une propriété proxy marquée @ObservedObject s'abonne à un ObservableObject (KittenViewProxy). Notre KittenSwiftView se mettra automatiquement à jour chaque fois que le KittenViewProxy change.



Commençons maintenant à implémenter la vue:



struct KittenSwiftView: View {
    @ObservedObject var proxy: KittenViewProxy

    var body: some View {
    }
    
    private var content: some View {
        let model: KittenViewModel! = self.proxy.model

        return Group {
            if (model == nil) {
                EmptyView()
            } else if (model.isError) {
                Text("Error loading kittens :-(")
            } else {
                List {
                    ForEach(model.imageUrls) { item in
                        RemoteImage(url: item)
                            .listRowInsets(EdgeInsets())
                    }
                }
            }
        }
    }
}


La partie principale ici est le contenu. Nous prenons le modèle actuel du proxy et affichons l'une des trois options suivantes: rien (EmptyView), un message d'erreur ou une liste d'images.



Le corps de la vue peut ressembler Ă  ceci:



struct KittenSwiftView: View {
    @ObservedObject var proxy: KittenViewProxy

    var body: some View {
        NavigationView {
            content
            .navigationBarTitle("Kittens KMP Sample")
            .navigationBarItems(
                leading: ActivityIndicator(isAnimating: self.proxy.model?.isLoading ?? false, style: .medium),
                trailing: Button("Refresh") {
                    self.proxy.dispatch(event: KittenViewEvent.RefreshTriggered())
                }
            )
        }
    }
    
    private var content: some View {
        // Omitted code
    }
}


Nous montrons le contenu à l'intérieur de NavigationView en ajoutant un titre, un chargeur et un bouton pour actualiser.



Chaque fois que le modèle change, la vue se met à jour automatiquement. Un indicateur de chargement s'affiche lorsque l'indicateur isLoading est défini sur true. L'événement RefreshTriggered est distribué lorsque l'utilisateur clique sur le bouton d'actualisation. Un message d'erreur s'affiche si l'indicateur isError est vrai; sinon, une liste d'images s'affiche.



Intégration KittenComponent



Maintenant que nous avons un KittenSwiftView, il est temps d'utiliser notre KittenComponent. SwiftUI n'a rien d'autre que View, nous devrons donc envelopper KittenSwiftView et KittenComponent dans une vue SwiftUI séparée.



Le cycle de vie de la vue SwiftUI se compose de deux événements seulement: onAppear et onDisappear. Le premier est déclenché lorsque la vue est affichée à l'écran, et le second est déclenché lorsqu'il est masqué. Il n'y a pas d'avis explicite de destruction de la soumission. Par conséquent, nous utilisons le bloc «deinit», qui est appelé lorsque la mémoire occupée par l'objet est libérée.



Malheureusement, les structures Swift ne peuvent pas contenir de blocs deinit, nous devrons donc envelopper notre KittenComponent dans une classe:



private class ComponentHolder {
    let component = KittenComponent()
    
    deinit {
        component.onDestroy()
    }
}


Enfin, implémentons notre vue principale Kittens:



struct Kittens: View {
    @State private var holder: ComponentHolder?
    @State private var proxy = KittenViewProxy()

    var body: some View {
        KittenSwiftView(proxy: proxy)
            .onAppear(perform: onAppear)
            .onDisappear(perform: onDisappear)
    }

    private func onAppear() {
        if (self.holder == nil) {
            self.holder = ComponentHolder()
        }
        self.holder?.component.onViewCreated(view: self.proxy)
        self.holder?.component.onStart()
    }

    private func onDisappear() {
        self.holder?.component.onViewDestroyed()
        self.holder?.component.onStop()
    }
}


L'important ici est que ComponentHolder et KittenViewProxy soient marqués comme Etat. Les structures de vue sont recréées à chaque actualisation de l'interface utilisateur, mais les propriétés marquées commeEtatsont enregistrés.



Le reste est assez simple. Nous utilisons KittenSwiftView. Lorsque onAppear est appelé, nous transmettons KittenViewProxy (qui implémente le protocole KittenView) à KittenComponent et démarrons le composant en appelant onStart. Lorsque onDisappear se déclenche, nous appelons les méthodes opposées du cycle de vie du composant. KittenComponent continuera à fonctionner jusqu'à ce qu'il soit supprimé de la mémoire, même si nous passons à une vue différente.



Voici Ă  quoi ressemble une application iOS:



Intégration du module Kittens dans l'application Android



Cette tâche est beaucoup plus facile qu'avec iOS. Supposons à nouveau que nous ayons créé un module d'application Android de base . Commençons par implémenter KittenView.



Il n'y a rien de spécial dans la mise en page - il suffit de SwipeRefreshLayout et RecyclerView:



<androidx.swiperefreshlayout.widget.SwipeRefreshLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:id="@+id/swype_refresh"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/recycler"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:contentDescription="@null"
        android:orientation="vertical"
        app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager" />
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>


Implémentation KittenView:



internal class KittenViewImpl(root: View) : AbstractMviView<Model, Event>(), KittenView {
    private val swipeRefreshLayout = root.findViewById<SwipeRefreshLayout>(R.id.swype_refresh)
    private val adapter = KittenAdapter()
    private val snackbar = Snackbar.make(root, R.string.error_loading_kittens, Snackbar.LENGTH_INDEFINITE)

    init {
        root.findViewById<RecyclerView>(R.id.recycler).adapter = adapter

        swipeRefreshLayout.setOnRefreshListener {
            dispatch(Event.RefreshTriggered)
        }
    }

    override fun render(model: Model) {
        swipeRefreshLayout.isRefreshing = model.isLoading
        adapter.setUrls(model.imageUrls)

        if (model.isError) {
            snackbar.show()
        } else {
            snackbar.dismiss()
        }
    }
}


Comme dans iOS, nous utilisons la classe AbstractMviView pour simplifier l'implémentation. L'événement RefreshTriggered est distribué lors de la mise à jour avec un balayage. Lorsqu'une erreur se produit, le Snackbar s'affiche. KittenAdapter affiche des images et est mis à jour chaque fois que le modèle change. DiffUtil est utilisé à l'intérieur de l'adaptateur pour éviter les mises à jour de liste inutiles. Le code complet de KittenAdapter peut être trouvé ici .



Il est temps d'utiliser KittenComponent. Pour cet article, je vais utiliser des extraits AndroidX que tous les développeurs Android connaissent. Mais je recommande de vérifier nos semi - rigides , une fourchette de semi-rigides d'Uber. C'est une alternative plus puissante et plus sûre aux fragments.



class MainFragment : Fragment(R.layout.main_fragment) {
    private lateinit var component: KittenComponent

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        component = KittenComponent()
    }

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        component.onViewCreated(KittenViewImpl(view))
    }

    override fun onStart() {
        super.onStart()
        component.onStart()
    }

    override fun onStop() {
        component.onStop()
        super.onStop()
    }

    override fun onDestroyView() {
        component.onViewDestroyed()
        super.onDestroyView()
    }

    override fun onDestroy() {
        component.onDestroy()
        super.onDestroy()
    }
}


La mise en œuvre est très simple. Nous instancions KittenComponent et appelons ses méthodes de cycle de vie au bon moment.



Et voici Ă  quoi ressemble une application Android:



Conclusion



Dans cet article, nous avons intégré le module générique Kittens dans les applications iOS et Android. Tout d'abord, nous avons implémenté une interface KittensDataSource interne qui est responsable du chargement des URL d'image à partir du Web. Nous avons utilisé NSURLSession pour iOS et HttpURLConnection pour Android. Nous avons ensuite intégré le KittenComponent dans le projet iOS en utilisant SwiftUI et dans le projet Android en utilisant des vues Android classiques.



Sur Android, l'intégration de KittenComponent était très simple. Nous avons créé une mise en page simple avec RecyclerView et SwipeRefreshLayout et implémenté l'interface KittenView en étendant la classe AbstractMviView. Après cela, nous avons utilisé le KittenComponent dans un fragment: nous venons de créer une instance et avons appelé ses méthodes de cycle de vie.



Avec iOS, les choses étaient un peu plus compliquées. Les fonctionnalités de SwiftUI nous ont obligés à écrire des classes supplémentaires:



  • KittenViewProxy: cette classe est Ă  la fois KittenView et ObservableObject en mĂŞme temps; il n'affiche pas directement le modèle de vue, mais l'expose via le modèle de propriĂ©tĂ© @ Published;
  • ComponentHolder: cette classe contient une instance de KittenComponent et appelle sa mĂ©thode onDestroy lorsqu'elle est supprimĂ©e de la mĂ©moire.


Dans le troisième (et dernier) article de cette série, je vais vous montrer à quel point cette approche est testable en montrant comment écrire des tests unitaires et d'intégration.



Suivez-moi sur Twitter et restez connecté!



All Articles