Patrón arquitectónico MVI en Kotlin Multiplataforma, parte 2





Este es el segundo de tres artículos sobre la aplicación del patrón arquitectónico MVI en Kotlin Multiplatform. En el primer artículo, recordamos qué es MVI y lo aplicamos para escribir código común para iOS y Android. Introdujimos abstracciones simples como Store and View y algunas clases auxiliares y las usamos para crear un módulo común.



El propósito de este módulo es descargar enlaces a imágenes desde la Web y asociar la lógica empresarial con una interfaz de usuario representada como una interfaz Kotlin, que debe implementarse de forma nativa en cada plataforma. Esto es lo que haremos en este artículo.



Implementaremos partes específicas de la plataforma del módulo común y las integraremos en aplicaciones iOS y Android. Como antes, supongo que el lector ya tiene conocimientos básicos de Kotlin Multiplatform, por lo que no hablaré sobre configuraciones de proyectos y otras cosas no relacionadas con MVI en Kotlin Multiplatform.



Un proyecto de muestra actualizado está disponible en nuestro GitHub .



Plan



En el primer artículo, definimos la interfaz KittenDataSource en nuestro módulo genérico Kotlin. Esta fuente de datos es responsable de descargar enlaces a imágenes de la web. Ahora es el momento de implementarlo para iOS y Android. Para hacer esto, usaremos una función Kotlin Multiplataforma como se espera / real . Luego integramos nuestro módulo genérico Kittens en las aplicaciones iOS y Android. Para iOS, usamos SwiftUI, y para Android, usamos vistas de Android regulares.



Entonces el plan es el siguiente:



  • Implementación del lado de KittenDataSource

    • Para iOS
    • Para Android
  • Integrando Kittens Module en la aplicación iOS

    • Implementación de KittenView usando SwiftUI
    • Integrando KittenComponent en SwiftUI View
  • Integrando Kittens Module en la aplicación de Android

    • Implementación de KittenView usando vistas de Android
    • Integrando KittenComponent en el Fragmento de Android




Implementación de KittenDataSource



Recordemos primero cómo se ve esta interfaz:



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


Y aquí está el encabezado de la función de fábrica que vamos a implementar:



internal expect fun KittenDataSource(): KittenDataSource


Tanto la interfaz como su función de fábrica se declaran internas y son detalles de implementación del módulo Kittens. Mediante el uso de expect / actual, podemos acceder a la API de cada plataforma.



KittenDataSource para iOS



Implementemos primero una fuente de datos para iOS. Para acceder a la API de iOS, necesitamos poner nuestro código en el conjunto de fuentes "iosCommonMain". Está configurado para depender de commonMain. Los conjuntos de destino del código fuente (iosX64Main y iosArm64Main), a su vez, dependen de iosCommonMain. Puede encontrar la configuración completa aquí .



Aquí está la implementación de la fuente de datos:




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



Usar NSURLSession es la forma principal de descargar datos de la web en iOS. Es asíncrono, por lo que no se requiere cambio de hilo. Simplemente ajustamos la llamada en Quizás y agregamos respuesta, error y manejo de cancelación.



Y aquí está la implementación de la función de fábrica:



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


En este punto, podemos compilar nuestro módulo común para iosX64 y iosArm64.



KittenDataSource para Android



Para acceder a la API de Android, necesitamos poner nuestro código en el conjunto de código fuente de androidMain. Así es como se ve la implementación de la fuente de datos:



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


Para Android, hemos implementado HttpURLConnection. Nuevamente, esta es una forma popular de cargar datos en Android sin usar bibliotecas de terceros. Esta API está bloqueando, por lo que debemos cambiar al subproceso en segundo plano utilizando el operador subscribeOn.



La implementación de la función de fábrica para Android es idéntica a la utilizada para iOS:



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


Ahora podemos compilar nuestro módulo común para Android.



Integrando Kittens Module en la aplicación iOS



Esta es la parte más difícil (y más interesante) del trabajo. Digamos que hemos compilado nuestro módulo como se describe en la aplicación README de iOS. También creamos un proyecto SwiftUI básico en Xcode y le agregamos nuestro marco de Kittens. Es hora de integrar KittenComponent en su aplicación iOS.



Implementación de KittenView



Comencemos implementando KittenView. Primero, recordemos cómo se ve su interfaz en 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()
    }
}


Entonces nuestro KittenView toma modelos y dispara eventos. Para renderizar el modelo en SwiftUI, tenemos que hacer 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 implementa dos interfaces (protocolos): KittenView y ObservableObject. KittenViewModel se expone utilizando la propiedad @ Publicada del modelo, por lo que nuestra vista SwiftUI puede suscribirse. Utilizamos la clase AbstractMviView que creamos en el artículo anterior. No tenemos que interactuar con la biblioteca Reaktive, podemos usar el método de despacho para despachar eventos.



¿Por qué estamos evitando las bibliotecas Reaktive (o coroutines / Flow) en Swift? Porque la compatibilidad Kotlin-Swift tiene varias limitaciones. Por ejemplo, los parámetros genéricos no se exportan para las interfaces (protocolos), las funciones de extensión no se pueden invocar de la manera habitual, etc. La mayoría de las limitaciones se deben al hecho de que la compatibilidad de Kotlin-Swift se realiza a través de Objective-C (puede encontrar todas las limitaciones aquí) Además, debido al complicado modelo de memoria Kotlin / Native, creo que es mejor tener la menor interacción Kotlin-iOS posible.



Ahora es el momento de hacer una vista SwiftUI. Comencemos creando un esqueleto:



struct KittenSwiftView: View {
    @ObservedObject var proxy: KittenViewProxy

    var body: some View {
    }
}


Hemos declarado nuestra vista SwiftUI, que depende de KittenViewProxy. Una propiedad proxy marcada @ObservedObject se suscribe a un ObservableObject (KittenViewProxy). Nuestro KittenSwiftView se actualizará automáticamente cada vez que cambie el KittenViewProxy.



Ahora comencemos a implementar la vista:



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 parte principal aquí es el contenido. Tomamos el modelo actual del proxy y mostramos una de tres opciones: nada (EmptyView), un mensaje de error o una lista de imágenes.



El cuerpo de la vista podría verse así:



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


Mostramos el contenido dentro de NavigationView agregando un título, un cargador y un botón para actualizar.



Cada vez que cambia el modelo, la vista se actualizará automáticamente. Se muestra un indicador de carga cuando el indicador isLoading se establece en verdadero. El evento RefreshTriggered se distribuye cuando se hace clic en el botón Actualizar. Se muestra un mensaje de error si el indicador isError es verdadero; de lo contrario, se muestra una lista de imágenes.



KittenComponent Integration



Ahora que tenemos un KittenSwiftView, es hora de usar nuestro KittenComponent. SwiftUI no tiene nada más que Ver, por lo que tendremos que ajustar KittenSwiftView y KittenComponent en una vista separada de SwiftUI.



El ciclo de vida de la vista SwiftUI consta de solo dos eventos: onAppear y onDisappear. El primero se dispara cuando la vista se muestra en la pantalla, y el segundo se dispara cuando está oculto. No hay aviso explícito de destrucción de la presentación. Por lo tanto, usamos el bloque "deinit", que se llama cuando se libera la memoria ocupada por el objeto.



Desafortunadamente, las estructuras Swift no pueden contener bloques deinit, por lo que tendremos que envolver nuestro KittenComponent en una clase:



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


Finalmente, implementemos nuestra vista principal de Gatitos:



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


Lo importante aquí es que tanto ComponentHolder como KittenViewProxy están marcados como Estado. Las estructuras de vista se vuelven a crear cada vez que se actualiza la IU, pero las propiedades marcadas comoEstadoson salvados



El resto es bastante simple. Estamos usando KittenSwiftView. Cuando se llama a onAppear, pasamos KittenViewProxy (que implementa el protocolo KittenView) a KittenComponent e iniciamos el componente llamando a onStart. Cuando onDisappear dispara, llamamos a los métodos opuestos del ciclo de vida del componente. KittenComponent continuará funcionando hasta que se elimine de la memoria, incluso si cambiamos a una vista diferente.



Así es como se ve una aplicación iOS:



Integrando Kittens Module en la aplicación de Android



Esta tarea es mucho más fácil que con iOS. Supongamos nuevamente que hemos creado un módulo básico de aplicación de Android . Comencemos implementando KittenView.



El diseño no tiene nada de especial: solo SwipeRefreshLayout y 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>


Implementación de 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()
        }
    }
}


Al igual que en iOS, utilizamos la clase AbstractMviView para simplificar la implementación. El evento RefreshTriggered se envía al actualizar con un deslizamiento. Cuando se produce un error, se muestra la Snackbar. KittenAdapter muestra imágenes y se actualiza cada vez que cambia el modelo. DiffUtil se usa dentro del adaptador para evitar actualizaciones innecesarias de la lista. El código completo de KittenAdapter se puede encontrar aquí .



Es hora de usar KittenComponent. Para este artículo, usaré fragmentos de AndroidX con los que todos los desarrolladores de Android están familiarizados. Pero recomiendo revisar nuestras RIB , una bifurcación de RIB de Uber. Esta es una alternativa más potente y segura a los fragmentos.



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 implementación es muy simple. Instanciamos KittenComponent y llamamos a sus métodos de ciclo de vida en el momento adecuado.



Y así es como se ve una aplicación de Android:



Conclusión



En este artículo, hemos integrado el módulo genérico Kittens en aplicaciones iOS y Android. Primero, implementamos una interfaz interna KittensDataSource que es responsable de cargar las URL de imágenes desde la web. Utilizamos NSURLSession para iOS y HttpURLConnection para Android. Luego integramos el KittenComponent en el proyecto de iOS usando SwiftUI y en el proyecto de Android usando vistas normales de Android.



En Android, la integración de KittenComponent fue muy simple. Creamos un diseño simple con RecyclerView y SwipeRefreshLayout e implementamos la interfaz KittenView al extender la clase AbstractMviView. Después de eso, utilizamos el KittenComponent en un fragmento: acabamos de crear una instancia y llamamos a sus métodos de ciclo de vida.



Con iOS, las cosas fueron un poco más complicadas. Las características de SwiftUI nos obligaron a escribir algunas clases adicionales:



  • KittenViewProxy: esta clase es KittenView y ObservableObject al mismo tiempo; no muestra el modelo de vista directamente, pero lo expone a través del modelo de propiedad @ Publicado;
  • ComponentHolder: esta clase contiene una instancia de KittenComponent y llama a su método onDestroy cuando se elimina de la memoria.


En el tercer (y último) artículo de esta serie, le mostraré cuán comprobable es este enfoque demostrando cómo escribir pruebas unitarias y de integración.



¡Sígueme en Twitter y mantente conectado!



All Articles