Plantilla arquitectónica MVI en Kotlin Multiplataforma, Parte 1



Hace aproximadamente un año, me interesé en la nueva tecnología Kotlin Multiplatform. Le permite escribir código común y compilarlo para diferentes plataformas, mientras tiene acceso a su API. Desde entonces, he estado experimentando activamente en esta área y promoviendo esta herramienta en nuestra empresa. Un resultado, por ejemplo, es nuestra biblioteca Reaktive - Extensiones reactivas para Kotlin Multiplataforma.

En las aplicaciones Badoo and Bumble para el desarrollo de Android, utilizamos la plantilla arquitectónica MVI (para más detalles sobre nuestra arquitectura, lea el artículo de Zsolt Kocsi: “ Arquitectura moderna MVI basada en Kotlin"). Trabajando en varios proyectos, me convertí en un gran fanático de este enfoque. Por supuesto, no podía perder la oportunidad de probar MVI en Kotlin Multiplatform. Además, el caso era adecuado: necesitábamos escribir ejemplos para la biblioteca Reaktive. Después de estos experimentos míos, me sentí aún más inspirado por MVI.

Siempre presto atención a cómo los desarrolladores usan Kotlin Multiplatform y cómo construyen la arquitectura de tales proyectos. Según mis observaciones, el desarrollador promedio de Kotlin Multiplatform es en realidad un desarrollador de Android que usa la plantilla MVVM en su trabajo simplemente porque está tan acostumbrado. Algunos aplican adicionalmente "arquitectura limpia". Sin embargo, en mi opinión, MVI es el más adecuado para Kotlin Multiplatform, y la "arquitectura limpia" es una complicación innecesaria.

Por lo tanto, decidí escribir esta serie de tres artículos sobre los siguientes temas:

  1. Una breve descripción de la plantilla MVI, enunciado del problema y la creación de un módulo común usando Kotlin Multiplatform.
  2. Integración de un módulo común en aplicaciones iOS y Android.
  3. Pruebas de unidad e integración.

A continuación se muestra el primer artículo de la serie. Será de interés para todos los que ya usan o solo planean usar Kotlin Multiplatform.

Noto de inmediato que el propósito de este artículo no es enseñarle cómo trabajar con la multiplataforma Kotlin. Si siente que no tiene suficiente conocimiento en esta área, le recomiendo que primero se familiarice con la introducción y la documentación (especialmente las secciones " Concurrencia " e " Inmutabilidad " para comprender las características del modelo de memoria Kotlin / Native). En este artículo no describiré la configuración del proyecto, los módulos y otras cosas que no están relacionadas con el tema.

MVI


Primero, recordemos qué es MVI. La abreviatura significa Model-View-Intent. Solo hay dos componentes principales en el sistema:

  • modelo: una capa de lógica y datos (el modelo también almacena el estado actual del sistema);
  • (View) — UI-, (states) (intents).

El siguiente diagrama probablemente ya sea familiar para muchos:



Entonces, vemos esos componentes muy básicos: el modelo y la presentación. Todo lo demás son los datos que circulan entre ellos.

Es fácil ver que los datos se mueven solo en una dirección. Los estados provienen del modelo y entran en la vista para su visualización, las intenciones provienen de la vista y entran en el modelo para su procesamiento. Esta circulación se llama flujo de datos unidireccional.

En la práctica, un modelo a menudo está representado por una entidad llamada Tienda (se toma prestada de Redux). Sin embargo, esto no siempre sucede. Por ejemplo, en nuestra biblioteca MVICore, el modelo se llama Feature.

También vale la pena señalar que MVI está muy relacionado con la reactividad. La presentación de flujos de datos y su transformación, así como la gestión del ciclo de vida de las suscripciones, es muy conveniente de implementar utilizando bibliotecas de programación reactiva. Un número bastante grande de ellos ahora está disponible, sin embargo, al escribir código general en Kotlin Multiplatform, solo podemos usar bibliotecas multiplataforma. Necesitamos abstracción para flujos de datos, necesitamos la capacidad de conectar y desconectar sus entradas y salidas, así como realizar transformaciones. Por el momento, sé de dos bibliotecas de este tipo:

  • nuestra biblioteca Reaktive : implementación de extensiones reactivas en Kotlin Multiplatform;
  • Coroutines and Flow : implementación de corrientes frías con corotinas Kotlin.

Formulación del problema


El propósito de este artículo es mostrar cómo usar la plantilla MVI en Kotlin Multiplatform y cuáles son las ventajas y desventajas de este enfoque. Por lo tanto, no me apegaré a ninguna implementación específica de MVI. Sin embargo, usaré Reaktive, porque todavía se necesitan flujos de datos. Si lo desea, habiendo entendido la idea, Reaktive puede ser reemplazado por coroutines y Flow. En general, intentaré hacer que nuestro MVI sea lo más simple posible, sin complicaciones innecesarias.

Para demostrar MVI, intentaré implementar el proyecto más simple que cumpla con los siguientes requisitos:

  • soporte para Android e iOS;
  • Demostración de operación asincrónica (entrada-salida, procesamiento de datos, etc.);
  • tanto código común como sea posible;
  • Implementación de UI con herramientas nativas de cada plataforma;
  • falta de Rx en el lado de la plataforma (para que no tenga que especificar dependencias en Rx como una "api").

Como ejemplo, elegí una aplicación muy simple: una pantalla con un botón, al hacer clic en la cual se descargará y mostrará una lista con imágenes arbitrarias de gatos. Para cargar imágenes, usaré la API abierta: https://thecatapi.com . Esto le permitirá cumplir con el requisito de operación asincrónica, ya que debe descargar listas de la Web y analizar el archivo JSON.

Puede encontrar todo el código fuente del proyecto en nuestro GitHub .

Primeros pasos: abstracciones para MVI


Primero necesitamos introducir algunas abstracciones para nuestro MVI. Necesitaremos los componentes muy básicos, el modelo y la vista, y un par de tipos.

Typealiases


Para procesar intenciones, presentamos un actor (Actor), una función que acepta la intención y el estado actual y devuelve una secuencia de resultados (Efecto):

typealias Actor < Estado , Intención , Efecto > = ( Estado , Intención ) - > Observable < Efecto >
ver en bruto MviKmpActor.kt alojado con ❤ por GitHub

También necesitamos un reductor (Reductor), una función que tiene efecto y el estado actual y devuelve un nuevo estado:

typealias Reducer < Estado , Efecto > = ( Estado , Efecto ) - > Estado

Tienda


La tienda presentará un modelo de MVI. Debe aceptar intenciones y dar una corriente de estados. Al suscribirse a una secuencia de estado, se debe emitir el estado actual.

Vamos a presentar la interfaz adecuada:

interface Store < en Intent : Any , out State : Any > : Consumer < Intent >, Observable < State >, Desechable

Entonces, nuestra tienda tiene las siguientes propiedades:

  • tiene dos parámetros genéricos: intento de entrada y estado de salida;
  • es un consumidor de intenciones (Consumer <Intent>);
  • es una secuencia de estado (Observable <Estado>);
  • Es destructible (desechable).

Como no es muy conveniente implementar una interfaz de este tipo cada vez, necesitaremos cierto asistente:

class StoreHelper < en Intent : Any , out State : Any , en Effect : Any > (
initialState : State ,
privado val actor : Actor < Estado , Intención , Efecto >,
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 ( efecto : efecto ) {
subject.onNext (reductor (subject.value, effect))
}
anular diversión suscribirse ( observador : ObservableObserver < Estado >) {
sujeto.suscribirse (observador)
}
}


StoreHelper es una clase pequeña que nos facilitará la creación de una tienda. Tiene las siguientes propiedades:

  • tiene tres parámetros genéricos: intento de entrada y efecto y estado de salida;
  • acepta el estado inicial a través del constructor, el actor y la caja de cambios;
  • es una corriente de estados;
  • destructible (desechable);
  • no congelación (para que los suscriptores tampoco estén congelados );
  • implementa DisposableScope (interfaz de Reaktive para gestionar suscripciones);
  • acepta y procesa intenciones y efectos.

Mira el diagrama de nuestra tienda. El actor y la caja de cambios que contiene son detalles de implementación:



consideremos el método onIntent con más detalle:

  • acepta intenciones como argumento;
  • llama al actor y le pasa la intención y el estado actual;
  • se suscribe a la secuencia de efectos devuelta por el actor;
  • dirige todos los efectos al método onEffect;
  • La suscripción a los efectos se realiza utilizando el indicador isThreadLocal (esto evita la congelación en Kotlin / Native).

Ahora echemos un vistazo más de cerca al método onEffect:

  • toma efectos como argumento;
  • llama a la caja de cambios y transfiere el efecto y el estado actual a ella;
  • pasa el nuevo estado al BehaviorSubject, lo que lleva a la recepción del nuevo estado por parte de todos los suscriptores.

Ver


Ahora entremos en la presentación. Debe aceptar modelos para mostrar y dar una secuencia de eventos. También haremos una interfaz separada:

interfaz MviView < en Modelo : Cualquiera , fuera Evento : Cualquiera > {
val events : Observable < Evento >
render divertido ( modelo : Modelo )
}


Una vista tiene las siguientes propiedades:

  • tiene dos parámetros genéricos: modelo de entrada y evento de salida;
  • acepta modelos para mostrar utilizando el método de renderizado;
  • despacha una secuencia de eventos utilizando la propiedad de eventos.

Agregué el prefijo Mvi al nombre MviView para evitar confusiones con Android View. Además, no expandí las interfaces de consumidor y observable, sino que simplemente usé la propiedad y el método. Esto es para que pueda configurar la interfaz de presentación en la plataforma para la implementación (Android o iOS) sin exportar Rx como una dependencia "api". El truco es que los clientes no interactuarán directamente con la propiedad "eventos", sino que implementarán la interfaz MviView, expandiendo la clase abstracta.

Inmediatamente agregue esta clase abstracta para representar:

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

Esta clase nos ayudará con la emisión de eventos, así como también evitará que la plataforma interactúe con Rx.

Aquí hay un diagrama que muestra cómo funcionará esto: La



Tienda produce estados que se transforman en modelos y se muestran en la vista. Este último produce eventos que se convierten en intenciones y se entregan a la Tienda para su procesamiento. Este enfoque elimina la coherencia entre Tienda y presentación. Pero en casos simples, una vista puede trabajar directamente con estados e intenciones.

Eso es todo lo que necesitamos para implementar MVI. Escribamos el código general.

Código común


Plan


  1. Haremos un módulo general cuya tarea es descargar y mostrar una lista de imágenes de gatos.
  2. UI abstraemos la interfaz y transferiremos su implementación al exterior.
  3. Ocultaremos nuestra implementación detrás de una fachada conveniente.

Kittenstore


Comencemos con lo principal: cree un KittenStore que cargará una lista de imágenes:

interfaz interna KittenStore : Store < Intent , State > {
Intención de clase sellada {
objeto Recargar : Intención ()
}
clase de datos Estado (
val isLoading : Boolean = false ,
Datos val : Datos = Datos . Imágenes ()
) {
Datos de clase sellados {
clase de datos Imágenes ( val urls : List < String > = emptyList ()) : Data ()
Error de objeto : Datos ()
}
}
}


Hemos ampliado la interfaz de la Tienda con tipos de intención y tipos de estado. Tenga en cuenta: la interfaz se declara como interna. Nuestro KittenStore es los detalles de implementación del módulo. Nuestra intención es solo una: recargar, provoca la carga de una lista de imágenes. Pero vale la pena considerar la condición con más detalle:

  • el indicador isLoading indica si la descarga está actualmente en curso o no;
  • La propiedad de datos puede tomar una de dos opciones:
    • Imágenes: una lista de enlaces a imágenes;
    • Error: significa que se ha producido un error.

Ahora comencemos la implementación. Lo haremos por etapas. Primero, cree una clase KittenStoreImpl vacía que implementará la interfaz KittenStore:

clase interna KittenStoreImpl (
) : KittenStore , DisposableScope de DisposableScope () {
anular diversión en Siguiente ( valor : Intención ) {
}
anular diversión suscribirse ( observador : ObservableObserver < Estado >) {
}
}


También implementamos la interfaz familiar DisposableScope. Esto es necesario para una gestión de suscripción conveniente.

Tendremos que descargar la lista de imágenes de la Web y analizar el archivo JSON. Declare las dependencias correspondientes:

clase interna KittenStoreImpl (
red val privada : red ,
el analizador de valores privado : analizador
) : KittenStore, DisposableScope by DisposableScope() {
override fun onNext(value: Intent) {
}
override fun subscribe(observer: ObservableObserver<State>) {
}
interface Network {
fun load(): Maybe<String>
}
interface Parser {
fun parse ( json : String ) : Quizás < List < String >>
}
}

La red descargará el texto para el archivo JSON de la red, y el analizador analizará el archivo JSON y devolverá una lista de enlaces de imágenes. En caso de error, Quizás simplemente termine sin resultado. En este artículo, no estamos interesados ​​en el tipo de error.

Ahora declara los efectos y el reductor:

clase interna KittenStoreImpl (
red val privada : red ,
el analizador de valores privado : analizador
) : 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 {
fun parse ( json : String ) : Quizás < List < String >>
}
}

Antes de comenzar la descarga, mostramos el efecto LoadingStarted, que hace que se establezca el indicador isLoading. Una vez completada la descarga, emitimos LoadingFinished o LoadingFailed. En el primer caso, borramos la bandera isLoading y aplicamos la lista de imágenes, en el segundo, también borramos la bandera y aplicamos el estado de error. Tenga en cuenta que los efectos son la API privada de nuestro KittenStore.

Ahora implementamos la descarga en sí:

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

Aquí vale la pena prestar atención al hecho de que pasamos Network and Parser a la función de recarga, a pesar de que ya están disponibles para nosotros como propiedades del constructor. Esto se hace para evitar referencias a esto y, como resultado, congelar todo el KittenStore.

Bueno, finalmente, use StoreHelper y finalice la implementación de KittenStore:

clase interna KittenStoreImpl (
red val privada : red ,
el analizador de valores privado : analizador
) : KittenStore , DisposableScope de 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 () : Quizás < Cadena >
}
interfaz Analizador {
fun parse ( json : String ) : Quizás < List < String >>
}
}

¡Nuestro KittenStore está listo! Pasamos a la presentación.

Kittenview


Declare la siguiente interfaz:

interfaz KittenView : MviView < Modelo , Evento > {
Modelo de clase de datos (
val isLoading : booleano ,
val isError : booleano ,
val imageUrls : List < String >
)
Evento de clase sellada {
objeto RefreshTriggered : Evento ()
}
}

Anunciamos un modelo de vista con indicadores de carga y error y una lista de enlaces de imágenes. Solo tenemos un evento: RefreshTriggered. Se emite cada vez que el usuario invoca una actualización. KittenView es la API pública de nuestro módulo.

KittenDataSource


La tarea de esta fuente de datos es descargar texto para un archivo JSON de la Web. Como de costumbre, declara la interfaz:

interfaz interna KittenDataSource {
carga divertida ( límite : Int , desplazamiento : Int ) : Quizás < String >
}

Las implementaciones de la fuente de datos se realizarán para cada plataforma por separado. Por lo tanto, podemos declarar un método de fábrica usando expect / actual:

interno esperar diversión KittenDataSource () : KittenDataSource

Las implementaciones de la fuente de datos se discutirán en la siguiente parte, donde implementaremos aplicaciones para iOS y Android.

Integración


La etapa final es la integración de todos los componentes.

Implementación de interfaz de red:

clase interna KittenStoreNetwork (
Val DataSource privado : KittenDataSource
) : KittenStoreImpl . Red {
anular fun load () : Quizás < String > = dataSource.load (limit = 50 , offset = 0 )
}


Implementación de la interfaz del analizador:

objeto interno KittenStoreParser : KittenStoreImpl . Analizador {
anular fun parse ( json : String ) : Quizás < List < String >> =
maybeFromFunction {
Json ( JsonConfiguration . Estable )
.parseJson (json)
.jsonArray
.map {it.jsonObject.getPrimitive ( " url " ) .content}
}
.subscribeOn (computationScheduler)
.onErrorComplete ()
}

Aquí utilizamos la biblioteca kotlinx.serialization . El análisis se realiza en un programador de cómputo para evitar bloquear el hilo principal.

Convertir estado para ver modelo:

Estado interno de diversión . toModel () : Modelo =
Modelo (
isLoading = isLoading,
isError = when ( data ) {
es Estado . Los Datos . Imágenes - > falso
es Estado . Los Datos . Error - > verdadero
},
imageUrls = when ( data ) {
es Estado . Los Datos . Imágenes - > datos .urls
es Estado . Los Datos . Error - > emptyList ()
}
)

Convierta eventos en intenciones:

Evento de diversión interna . toIntent () : Intent =
cuando ( esto ) {
Es Evento . Actualizar Activado - > Intención . Recargar
}

Preparación de fachadas:

clase KittenComponent {
diversión onViewCreated ( ver : KittenView ) {
}
fun onStart () {
}
fun onStop () {
}
diversión onViewDestroyed () {
}
diversión onDestroy () {
}
}

Ciclo de vida familiar para muchos desarrolladores de Android. Es genial para iOS e incluso JavaScript. El diagrama de la transición entre los estados del ciclo de vida de nuestra fachada se ve así:


explicaré brevemente lo que está sucediendo aquí:

  • primero, se llama al método onCreate, luego - onViewCreated, y luego - onStart: esto pone la fachada en condiciones de trabajo (iniciada);
  • en algún momento después de esto, se llama al método onStop: esto pone la fachada en un estado detenido (detenido);
  • en un estado detenido, se puede llamar a uno de dos métodos: onStart o onViewDestroyed, es decir, la fachada se puede iniciar de nuevo o se puede destruir su vista;
  • cuando se destruye la vista, se puede volver a crear (onViewCreated) o se puede destruir toda la fachada (onDestroy).

La implementación de la fachada puede verse así:

clase KittenComponent {
tienda val privada =
KittenStoreImpl (
red = KittenStoreNetwork (dataSource = KittenDataSource ()),
analizador = KittenStoreParser
)
vista var privada : KittenView? = nulo
private var startStopScope : DisposableScope? = nulo
diversión onViewCreated ( ver : KittenView ) {
this .view = view
}
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()
}
}

Cómo funciona:

  • primero creamos una instancia de KittenStore;
  • en el método onViewCreated recordamos el enlace a KittenView;
  • en onStart firmamos KittenStore y KittenView entre nosotros;
  • en onStop los reflejamos unos de otros;
  • en onViewDestroyed borramos el enlace a la vista;
  • en onDestroy, destruye KittenStore.

Conclusión


Este fue el primer artículo de mi serie MVI en Kotlin Multiplatform. En ella nosotros:

  • recordó qué es MVI y cómo funciona;
  • hizo la implementación MVI más simple en Kotlin Multiplatform usando la biblioteca Reaktive;
  • creó un módulo común para cargar una lista de imágenes usando MVI.

Tenga en cuenta las propiedades más importantes de nuestro módulo común:

  • logramos poner todo el código en el módulo multiplataforma excepto el código de la interfaz de usuario; toda lógica, más las conexiones y conversiones entre lógica y UI son comunes;
  • la lógica y la interfaz de usuario no tienen ninguna relación;
  • la implementación de la interfaz de usuario es muy simple: solo necesita mostrar los modelos de vista entrantes y lanzar eventos;
  • La integración del módulo también es simple: todo lo que necesita es:
    • implementar la interfaz KittenView (protocolo);
    • crear una instancia de KittenComponent;
    • llame a sus métodos de ciclo de vida en el momento adecuado;
  • este enfoque evita el "flujo" de Rx (o corutina) en las plataformas, lo que significa que no tenemos que administrar ninguna suscripción en el nivel de la aplicación;
  • Todas las clases importantes se abstraen mediante interfaces y se prueban.

En la siguiente parte, mostraré en la práctica cómo se ve la integración de KittenComponent en aplicaciones iOS y Android.

¡Sígueme en Twitter y mantente conectado!

All Articles