MVI-Architekturmuster in Kotlin Multiplatform, Teil 2





Dies ist der zweite von drei Artikeln zum Anwenden des MVI-Architekturmusters in Kotlin Multiplatform. Im ersten Artikel haben wir uns daran erinnert, was MVI ist, und es angewendet, um allgemeinen Code für iOS und Android zu schreiben. Wir haben einfache Abstraktionen wie Store and View und einige Hilfsklassen eingeführt und daraus ein gemeinsames Modul erstellt.



Der Zweck dieses Moduls besteht darin, Links zu Bildern aus dem Web herunterzuladen und Geschäftslogik mit einer als Kotlin-Schnittstelle dargestellten Benutzeroberfläche zu verknüpfen, die auf jeder Plattform nativ implementiert werden muss. Dies werden wir in diesem Artikel tun.



Wir werden plattformspezifische Teile des gemeinsamen Moduls implementieren und in iOS- und Android-Anwendungen integrieren. Nach wie vor gehe ich davon aus, dass der Leser bereits über Grundkenntnisse in Kotlin Multiplatform verfügt, sodass ich in Kotlin Multiplatform nicht über Projektkonfigurationen und andere Dinge sprechen werde, die nicht mit MVI zusammenhängen.



Ein aktualisiertes Beispielprojekt ist auf unserem GitHub verfügbar .



Planen



Im ersten Artikel haben wir die KittenDataSource-Schnittstelle in unserem generischen Kotlin-Modul definiert. Diese Datenquelle ist für das Herunterladen von Links zu Bildern aus dem Internet verantwortlich. Jetzt ist es Zeit, es für iOS und Android zu implementieren. Zu diesem Zweck verwenden wir eine Kotlin-Multiplattform-Funktion wie " Erwarten / Ist" . Anschließend integrieren wir unser generisches Kittens-Modul in die iOS- und Android-Apps. Für iOS verwenden wir SwiftUI und für Android verwenden wir reguläre Android-Ansichten.



Der Plan lautet also wie folgt:



  • KittenDataSource-seitige Implementierung

    • Für iOS
    • Für Android
  • Integration des Kittens-Moduls in die iOS-App

    • KittenView-Implementierung mit SwiftUI
    • KittenComponent in SwiftUI View integrieren
  • Kätzchen-Modul in Android App integrieren

    • KittenView-Implementierung mit Android Views
    • Integration von KittenComponent in Android Fragment




Implementierung von KittenDataSource



Erinnern wir uns zunächst daran, wie diese Schnittstelle aussieht:



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


Und hier ist der Header der Factory-Funktion, die wir implementieren werden:



internal expect fun KittenDataSource(): KittenDataSource


Sowohl die Schnittstelle als auch die Factory-Funktion werden als intern deklariert und sind Implementierungsdetails des Kittens-Moduls. Durch die Verwendung von Expect / Actual können wir auf die API jeder Plattform zugreifen.



KittenDataSource für iOS



Lassen Sie uns zuerst eine Datenquelle für iOS implementieren. Um auf die iOS-API zugreifen zu können, müssen wir unseren Code in den Quellensatz „iosCommonMain“ einfügen. Es ist so konfiguriert, dass es von commonMain abhängt. Die Zielsätze des Quellcodes (iosX64Main und iosArm64Main) hängen wiederum von iosCommonMain ab. Die vollständige Konfiguration finden Sie hier .



Hier ist die Datenquellenimplementierung:




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



Die Verwendung von NSURLSession ist die primäre Methode zum Herunterladen von Daten aus dem Internet unter iOS. Es ist asynchron, daher ist kein Threadwechsel erforderlich. Wir schließen den Anruf einfach in Vielleicht ein und fügen die Antwort-, Fehler- und Stornierungsbehandlung hinzu.



Und hier ist die Implementierung der Factory-Funktion:



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


Zu diesem Zeitpunkt können wir unser gemeinsames Modul für iosX64 und iosArm64 kompilieren.



KittenDataSource für Android



Um auf die Android-API zugreifen zu können, müssen wir unseren Code in den androidMain-Quellcodesatz einfügen. So sieht die Implementierung der Datenquelle aus:



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


Für Android haben wir HttpURLConnection implementiert. Auch dies ist eine beliebte Methode zum Laden von Daten in Android, ohne Bibliotheken von Drittanbietern zu verwenden. Diese API blockiert, daher müssen wir mit dem Operator subscribeOn zum Hintergrund-Thread wechseln.



Die Implementierung der Factory-Funktion für Android ist identisch mit der für iOS:



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


Jetzt können wir unser gemeinsames Modul für Android kompilieren.



Integration des Kittens-Moduls in die iOS-App



Dies ist der schwierigste (und interessanteste) Teil des Jobs. Angenommen, wir haben unser Modul wie in der iOS-App README beschrieben kompiliert . Wir haben auch ein grundlegendes SwiftUI-Projekt in Xcode erstellt und unser Kittens-Framework hinzugefügt. Es ist Zeit, KittenComponent in Ihre iOS-App zu integrieren.



KittenView-Implementierung



Beginnen wir mit der Implementierung von KittenView. Erinnern wir uns zunächst daran, wie die Benutzeroberfläche in Kotlin aussieht:



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

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


Unser KittenView nimmt also Modelle auf und feuert Ereignisse ab. Um das Modell in SwiftUI zu rendern, müssen wir einen einfachen Proxy erstellen:



import Kittens

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


Proxy implementiert zwei Schnittstellen (Protokolle): KittenView und ObservableObject. Das KittenViewModel wird mithilfe der @ Published-Eigenschaft des Modells verfügbar gemacht, sodass unsere SwiftUI-Ansicht es abonnieren kann. Wir haben die AbstractMviView-Klasse verwendet, die wir im vorherigen Artikel erstellt haben. Wir müssen nicht mit der Reaktive Bibliothek interagieren - wir können die Versandmethode verwenden, um Ereignisse zu versenden.



Warum vermeiden wir reaktive (oder Coroutines / Flow) Bibliotheken in Swift? Weil die Kotlin-Swift-Kompatibilität mehrere Einschränkungen aufweist. Beispielsweise werden generische Parameter für Schnittstellen (Protokolle) nicht exportiert, Erweiterungsfunktionen können nicht auf die übliche Weise aufgerufen werden usw. Die meisten Einschränkungen sind auf die Tatsache zurückzuführen, dass die Kotlin-Swift-Kompatibilität über Objective-C erfolgt (alle Einschränkungen finden Sie hier)) Aufgrund des kniffligen Kotlin / Native-Speichermodells ist es meiner Meinung nach am besten, so wenig Kotlin-iOS-Interaktion wie möglich zu haben.



Jetzt ist es Zeit, eine SwiftUI-Ansicht zu erstellen. Beginnen wir mit der Erstellung eines Skeletts:



struct KittenSwiftView: View {
    @ObservedObject var proxy: KittenViewProxy

    var body: some View {
    }
}


Wir haben unsere SwiftUI-Ansicht deklariert, die von KittenViewProxy abhängt. Eine mit @ObservedObject gekennzeichnete Proxy-Eigenschaft abonniert ein ObservableObject (KittenViewProxy). Unser KittenSwiftView wird automatisch aktualisiert, wenn sich KittenViewProxy ändert.



Beginnen wir nun mit der Implementierung der Ansicht:



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


Der Hauptteil hier ist Inhalt. Wir nehmen das aktuelle Modell vom Proxy und zeigen eine von drei Optionen an: nichts (EmptyView), eine Fehlermeldung oder eine Liste von Bildern.



Der Hauptteil der Ansicht könnte folgendermaßen aussehen:



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


Wir zeigen den Inhalt in der Navigationsansicht an, indem wir einen Titel, einen Lader und eine Schaltfläche zum Aktualisieren hinzufügen.



Jedes Mal, wenn sich das Modell ändert, wird die Ansicht automatisch aktualisiert. Eine Ladeanzeige wird angezeigt, wenn das Flag isLoading auf true gesetzt ist. Das RefreshTriggered-Ereignis wird ausgelöst, wenn auf die Schaltfläche "Aktualisieren" geklickt wird. Eine Fehlermeldung wird angezeigt, wenn das isError-Flag true ist. Andernfalls wird eine Liste der Bilder angezeigt.



KittenComponent-Integration



Jetzt, da wir eine KittenSwiftView haben, ist es Zeit, unsere KittenComponent zu verwenden. SwiftUI hat nichts als Ansicht, daher müssen wir KittenSwiftView und KittenComponent in eine separate SwiftUI-Ansicht einschließen.



Der Lebenszyklus der SwiftUI-Ansicht besteht aus nur zwei Ereignissen: onAppear und onDisappear. Die erste wird ausgelöst, wenn die Ansicht auf dem Bildschirm angezeigt wird, und die zweite wird ausgelöst, wenn sie ausgeblendet ist. Es gibt keine ausdrückliche Mitteilung über die Zerstörung der Einreichung. Daher verwenden wir den Block „deinit“, der aufgerufen wird, wenn der vom Objekt belegte Speicher freigegeben wird.



Leider können Swift-Strukturen keine Deinit-Blöcke enthalten, daher müssen wir unsere KittenComponent in eine Klasse einschließen:



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


Lassen Sie uns abschließend unsere Hauptansicht für Kätzchen implementieren:



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


Wichtig hierbei ist, dass sowohl ComponentHolder als auch KittenViewProxy als gekennzeichnet sind Zustand... Ansichtsstrukturen werden bei jeder Aktualisierung der Benutzeroberfläche neu erstellt, die Eigenschaften jedoch als gekennzeichnetZustandgespeichert werden.



Der Rest ist ziemlich einfach. Wir verwenden KittenSwiftView. Wenn onAppear aufgerufen wird, übergeben wir KittenViewProxy (das das KittenView-Protokoll implementiert) an KittenComponent und starten die Komponente durch Aufrufen von onStart. Wenn onDisappear ausgelöst wird, nennen wir die entgegengesetzten Methoden des Lebenszyklus der Komponente. KittenComponent funktioniert weiterhin, bis es aus dem Speicher entfernt wird, auch wenn wir zu einer anderen Ansicht wechseln.



So sieht eine iOS-App aus:



Kätzchen-Modul in Android App integrieren



Diese Aufgabe ist viel einfacher als mit iOS. Angenommen, wir haben ein grundlegendes Android-Anwendungsmodul erstellt . Beginnen wir mit der Implementierung von KittenView.



Das Layout hat nichts Besonderes - nur SwipeRefreshLayout und 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>


KittenView-Implementierung:



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


Wie in iOS verwenden wir die AbstractMviView-Klasse, um die Implementierung zu vereinfachen. Das RefreshTriggered-Ereignis wird beim Aktualisieren mit einem Wisch ausgelöst. Wenn ein Fehler auftritt, wird die Snackbar angezeigt. KittenAdapter zeigt Bilder an und wird aktualisiert, wenn sich das Modell ändert. DiffUtil wird im Adapter verwendet, um unnötige Listenaktualisierungen zu vermeiden. Den vollständigen KittenAdapter-Code finden Sie hier .



Es ist Zeit, KittenComponent zu verwenden. Für diesen Artikel werde ich AndroidX-Snippets verwenden, mit denen alle Android-Entwickler vertraut sind. Ich empfehle jedoch, unsere RIBs zu prüfen , eine Reihe von RIBs von Uber. Dies ist eine leistungsfähigere und sicherere Alternative zu Fragmenten.



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


Die Implementierung ist sehr einfach. Wir instanziieren KittenComponent und rufen seine Lebenszyklusmethoden zum richtigen Zeitpunkt auf.



Und so sieht eine Android-App aus:



Fazit



In diesem Artikel haben wir das generische Kittens-Modul in iOS- und Android-Apps integriert. Zunächst haben wir eine interne KittensDataSource-Schnittstelle implementiert, die für das Laden von Bild-URLs aus dem Web verantwortlich ist. Wir haben NSURLSession für iOS und HttpURLConnection für Android verwendet. Anschließend haben wir die KittenComponent mithilfe von SwiftUI in das iOS-Projekt und mithilfe regulärer Android-Ansichten in das Android-Projekt integriert.



Unter Android war die Integration von KittenComponent sehr einfach. Wir haben mit RecyclerView und SwipeRefreshLayout ein einfaches Layout erstellt und die KittenView-Oberfläche durch Erweiterung der AbstractMviView-Klasse implementiert. Danach haben wir die KittenComponent in einem Fragment verwendet: Wir haben gerade eine Instanz erstellt und ihre Lebenszyklusmethoden aufgerufen.



Mit iOS waren die Dinge etwas komplizierter. Die SwiftUI-Funktionen haben uns gezwungen, einige zusätzliche Klassen zu schreiben:



  • KittenViewProxy: Diese Klasse ist gleichzeitig KittenView und ObservableObject. Das Ansichtsmodell wird nicht direkt angezeigt, sondern über das Eigenschaftsmodell @ Published verfügbar gemacht.
  • ComponentHolder: Diese Klasse enthält eine Instanz von KittenComponent und ruft ihre onDestroy-Methode auf, wenn sie aus dem Speicher entfernt wird.


Im dritten (und letzten) Artikel dieser Reihe werde ich Ihnen zeigen, wie testbar dieser Ansatz ist, indem ich demonstriere, wie Unit- und Integrationstests geschrieben werden.



Folgen Sie mir auf Twitter und bleiben Sie in Verbindung!



All Articles