En el mundo del desarrollo móvil, la elección de la arquitectura de aplicaciones adecuada juega un papel crítico en garantizar la calidad del código, la mantenibilidad y la escalabilidad. Cada año trae nuevos enfoques, bibliotecas y marcos diseñados para simplificar el proceso de desarrollo y hacer que el código sea más estructurado.En los últimos años, la arquitectura MVI (Model-View-Intent) ha ganado particular popularidad ofreciendo una solución elegante para gestionar el estado de la aplicación y organizar el flujo de datos unidireccional.
En este artículo, examinaremos SimpleMVI, una solución ligera pero poderosa para implementar el patrón MVI en proyectos multiplataforma de Kotlin. Exploraremos los componentes centrales de la biblioteca, sus características y analizaremos ejemplos prácticos que le ayudarán a entender cómo aplicar SimpleMVI en sus proyectos.
Qué es el patrón MVI y sus conceptos básicos
Model-View-Intent (MVI) es un patrón arquitectónico para el desarrollo de interfaces de usuario, inspirado en la programación funcional y los sistemas reactivos.
-
Unidirectional Data Flow — data moves in one direction, forming a cycle: from user action to model change, then to view update.
-
Immutable State — the application state is not changed directly; instead, a new state is created based on the previous one.
-
Determinism — the same user actions with the same initial state always lead to the same result.
En arquitectura de MVI:
- El modelo representa el estado inmutable de la aplicación que describe completamente los datos necesarios para mostrar la interfaz de usuario.
- View muestra pasivamente el estado actual y transmite las acciones del usuario como Intentos.
- Intent describe las intenciones del usuario o sistema que pueden potencialmente cambiar el estado de la aplicación.
Además de estos componentes básicos, MVI a menudo incluye:
- Reductor: una función que toma el estado actual y la intención, y devuelve un nuevo estado.
- Efectos secundarios: efectos secundarios que no afectan al estado, pero requieren interacción con sistemas externos (por ejemplo, navegación, notificaciones, solicitudes de API).
Breve historia de los patrones arquitectónicos
Los patrones arquitectónicos de la interfaz de usuario han evolucionado significativamente con el tiempo:
MVC (Controlador de Modelo de Visión)
Uno de los primeros patrones que dividió la aplicación en tres componentes:
- Modelo - Datos y Lógica de Negocios
- Ver - Interfaz de Usuario
- Controlador – manejo de la entrada de usuario
El principal problema con MVC es el acoplamiento estrecho entre los componentes y la separación no clara de las responsabilidades, lo que complica las pruebas y el mantenimiento.
MVP (Modelo de Presentación)
Una mejora sobre el MVC, donde:
- Modelo - Datos y Lógica de Negocios
- Vista - Interfaz de usuario pasiva
- Presenter - mediador entre el modelo y la vista
MVP resuelve el problema de la probabilidad, pero a menudo conduce a presentadores hinchados y un acoplamiento estrecho entre Presenter y View.
MVVM (Modelo de Visión)
El siguiente paso en la evolución:
- Modelo - Datos y Lógica de Negocios
- Ver - Interfaz de Usuario
- ViewModel — transforma los datos de Modelo en un formato conveniente para ver
MVVM utiliza el concepto de vinculación de datos, que reduce la cantidad de código de boilerplate pero puede causar problemas con el flujo de datos de seguimiento.
MVI (Intento de Modelo-Visión)
Un enfoque moderno que enfatiza:
- Predictibilidad: un enfoque determinista de la gestión estatal
- Immutabilidad: el estado no se cambia, sino que se sustituye
- Flujo de datos unidireccional: secuencia clara y transparente de eventos
MVI es particularmente eficaz para aplicaciones complejas y ricas en datos con numerosas interacciones de usuario y operaciones asíncronas.
Por qué se creó SimpleMVI y su lugar entre otras bibliotecas
SimpleMVI fue desarrollado para proporcionar a los desarrolladores una herramienta simple pero poderosa para implementar el patrón MVI en los proyectos Kotlin Multiplatform.
- Se centra en la lógica del dominio, sin imponer soluciones para la capa de interfaz de usuario
- Se adhiere al principio de "simplicidad por encima de todo", proporcionando un conjunto mínimo de componentes necesarios
- Es optimizado para Kotlin Multiplatform, garantizando la compatibilidad con varias plataformas
- Controla estrictamente la seguridad del hilo, garantizando que la interacción con el estado sólo ocurra en el hilo principal
- Proporciona una configuración flexible de gestión de errores a través del sistema de configuración
Las principales ventajas de SimpleMVI frente a las alternativas:
- Menos dependencias y tamaño de biblioteca más pequeño en comparación con soluciones más complejas
- Límite de entrada más bajo para comprender y usar
- Enfoque completo de Kotlin utilizando construcciones del lenguaje moderno
- DSL conveniente para describir la lógica empresarial
- Separación de responsabilidades entre los componentes
SimpleMVI no tiene como objetivo resolver todos los problemas de arquitectura de aplicaciones, pero proporciona una base fiable para organizar la lógica de negocio que puede integrarse con cualquier solución para UI, navegación y otros aspectos de la aplicación.
Conceptos y componentes básicos de SimpleMVI
SimpleMVI ofrece un enfoque minimalista para implementar la arquitectura MVI, centrándose en tres componentes clave: Store, Actor y Middleware.Cada uno de estos componentes tiene un papel único en asegurar el flujo de datos unidireccional y gestionar el estado de la aplicación.
Tienda - El elemento central de la arquitectura
Definición y función de la tienda
Store es el corazón de SimpleMVI: es un contenedor que contiene el estado de la aplicación, procesa las intenciones y genera efectos secundarios.
public interface Store<in Intent : Any, out State : Any, out SideEffect : Any> {
// Current state
public val state: State
// State flow
public val states: StateFlow<State>
// Side effects flow
public val sideEffects: Flow<SideEffect>
// Store initialization
@MainThread
public fun init()
// Intent processing
@MainThread
public fun accept(intent: Intent)
// Store destruction
@MainThread
public fun destroy()
}
Gran ciclo de vida
La tienda tiene un ciclo de vida claramente definido:
-
Creation - instantiating the Store object with necessary dependencies
-
Initialization - calling the
init()
method, preparing internal components -
Active use - processing intents through the
accept(intent)
method -
Destruction - calling the
destroy()
method, releasing resources
Es importante entender que:
- Todos los métodos de la Tienda pública deben ser llamados sólo en el hilo principal (marcado con la anotación @MainThread)
- Después de llamar destruir(), la Tienda no puede usarse; los intentos de acceder a una Tienda destruida resultarán en un error
- La tienda debe ser inicializada con el método init() antes de usar
Gestión del Estado
La tienda ofrece las siguientes capacidades para trabajar con el estado:
-
Access to the current state via the
state
property -
Observing state changes via the
states
flow -
Processing side effects via the
sideEffects
flow
SimpleMVI utiliza clases de Kotlin Coroutines para la implementación de flujo:StateFlow
Para los estados y regularesFlow
para efectos secundarios, asegurando la compatibilidad con los enfoques estándar de la programación reactiva en Kotlin.
Extensión conveniente para la tienda
SimpleMVI proporciona operadores convenientes para trabajar con intenciones:
// Instead of store.accept(intent)
store + MyStore.Intent.LoadData
// Instead of store.accept(intent)
store += MyStore.Intent.LoadData
Actor - Implementación de la lógica empresarial
Principios de trabajo del actor
Actor es el componente responsable de la lógica de negocio en SimpleMVI. Acepta intenciones, las procesa y puede producir un nuevo estado y efectos secundarios. Actor es el mediador entre la interfaz de usuario y los datos de la aplicación.
public interface Actor<Intent : Any, State : Any, out SideEffect : Any> {
@MainThread
public fun init(
scope: CoroutineScope,
getState: () -> State,
reduce: (State.() -> State) -> Unit,
onNewIntent: (Intent) -> Unit,
postSideEffect: (sideEffect: SideEffect) -> Unit,
)
@MainThread
public fun onIntent(intent: Intent)
@MainThread
public fun destroy()
}
Cada participante tiene acceso a:
- CoroutineScope - para el lanzamiento de operaciones asíncronas
- Función de estado actual (getState)
- Función de reducción del estado (reducción)
- Nueva función de envío de intención (onNewIntent)
- Función de envío de efectos secundarios (postSideEffect)
Intento de procesamiento
ElonIntent(intent: Intent)
El método es llamado por la Tienda cuando recibe una nueva intención y es el principal punto de entrada para la lógica empresarial.
- Determina el tipo de intención recibida
- Realizar la lógica de negocio necesaria
- Actualiza el estado
- Generar efectos secundarios si es necesario
DefaultActor y DslActor: diferentes enfoques de implementación
SimpleMVI ofrece dos enfoques diferentes para la implementación de Actor:
DefaultActor - Enfoque Orientado a Objetos
class CounterActor : DefaultActor<CounterIntent, CounterState, CounterSideEffect>() {
override fun handleIntent(intent: CounterIntent) {
when (intent) {
is CounterIntent.Increment -> {
reduce { copy(count = count + 1) }
}
is CounterIntent.Decrement -> {
reduce { copy(count = count - 1) }
}
is CounterIntent.Reset -> {
reduce { CounterState() }
sideEffect(CounterSideEffect.CounterReset)
}
}
}
override fun onInit() {
// Initialization code
}
override fun onDestroy() {
// Cleanup code
}
}
Ventajas de DefaultActor:
- Enfoque familiar de OOP
- Conveniente para la lógica de negocio compleja
- Adecuado para grandes proyectos
DslActor - Enfoque funcional con DSL
val counterActor = actorDsl<CounterIntent, CounterState, CounterSideEffect> {
onInit {
// Initialization code
}
onIntent<CounterIntent.Increment> {
reduce { copy(count = count + 1) }
}
onIntent<CounterIntent.Decrement> {
reduce { copy(count = count - 1) }
}
onIntent<CounterIntent.Reset> {
reduce { CounterState() }
sideEffect(CounterSideEffect.CounterReset)
}
onDestroy {
// Cleanup code
}
}
Ventajas de DslActor:
- Un enfoque más declarativo
- Menos códigos de boiler
- Más adecuado para proyectos pequeños y medianos
- Tipo de manejo seguro de intención
Ambos enfoques proporcionan la misma funcionalidad, y la elección entre ellos depende de las preferencias del desarrollador y las especificidades del proyecto.
Mediumware - Ampliación de la funcionalidad
Objetivos del Middleware
Middleware en SimpleMVI actúa como un observador de los eventos en la Tienda. Middleware no puede modificar los eventos, pero puede reaccionar a ellos, lo que lo hace ideal para implementar lógica interfuncional como el logging, la analítica o el debugging.
public interface Middleware<Intent : Any, State : Any, SideEffect : Any> {
// Called when Store is initialized
public fun onInit(state: State)
// Called when a new intent is received
public fun onIntent(intent: Intent, state: State)
// Called when state changes
public fun onStateChanged(oldState: State, newState: State)
// Called when a side effect is generated
public fun onSideEffect(sideEffect: SideEffect, state: State)
// Called when Store is destroyed
public fun onDestroy(state: State)
}
Capacidad de logging y debugging
SimpleMVI incluye una implementación de Middleware integrada para el registro —LoggingMiddleware
:
val loggingMiddleware = LoggingMiddleware<MyIntent, MyState, MySideEffect>(
name = "MyStore",
logger = DefaultLogger
)
LoggingMiddleware
captura todos los eventos en la Tienda y los saca al log:
MyStore | Initialization
MyStore | Intent | LoadData
MyStore | Old state | State(isLoading=false, data=null)
MyStore | New state | State(isLoading=true, data=null)
MyStore | SideEffect | ShowLoading
MyStore | Destroying
Esto es útil para el desgaste, ya que le permite rastrear todo el flujo de datos en la aplicación.
Implementación de Custom Middleware
Crear tu propio Middleware es muy simple:
class AnalyticsMiddleware<Intent : Any, State : Any, SideEffect : Any>(
private val analytics: AnalyticsService
) : Middleware<Intent, State, SideEffect> {
override fun onInit(state: State) {
analytics.logEvent("store_initialized")
}
override fun onIntent(intent: Intent, state: State) {
analytics.logEvent("intent_received", mapOf("intent" to intent.toString()))
}
override fun onStateChanged(oldState: State, newState: State) {
analytics.logEvent("state_changed")
}
override fun onSideEffect(sideEffect: SideEffect, state: State) {
analytics.logEvent("side_effect", mapOf("effect" to sideEffect.toString()))
}
override fun onDestroy(state: State) {
analytics.logEvent("store_destroyed")
}
}
Middleware se puede combinar, creando una cadena de manejadores:
val store = createStore(
name = storeName<MyStore>(),
initialState = MyState(),
actor = myActor,
middlewares = listOf(
loggingMiddleware,
analyticsMiddleware,
debugMiddleware
)
)
Casos de uso clave para Middleware
-
Logging — recording all events for debugging
-
Analytics — tracking user actions
-
Performance metrics — measuring intent processing time
-
Debugging — visualizing data flow through UI
-
Testing — verifying the correctness of event sequences
Es importante recordar que Middleware es un observador pasivo y no puede modificar los eventos que recibe.
Trabajar con la biblioteca
Instalación y instalación
Añadir la dependencia a su proyecto:
// build.gradle.kts
implementation("io.github.arttttt.simplemvi:simplemvi:<version>")
Crea tu primera tienda
La forma más sencilla de crear una Tienda es declarar una clase que implementa la interfaz de la Tienda:
class CounterStore : Store<CounterStore.Intent, CounterStore.State, CounterStore.SideEffect> by createStore(
name = storeName<CounterStore>(),
initialState = State(),
actor = actorDsl {
onIntent<Intent.Increment> {
reduce { copy(count = count + 1) }
}
onIntent<Intent.Decrement> {
reduce { copy(count = count - 1) }
}
}
) {
sealed interface Intent {
data object Increment : Intent
data object Decrement : Intent
}
data class State(val count: Int = 0)
sealed interface SideEffect
}
Uso de la tienda
// Creating an instance
val counterStore = CounterStore()
// Initialization
counterStore.init()
// Sending intents
counterStore.accept(CounterStore.Intent.Increment)
// or using operators
counterStore + CounterStore.Intent.Increment
counterStore += CounterStore.Intent.Decrement
// Getting the current state
val currentState = counterStore.state
// Subscribing to the state flow
val statesJob = launch {
counterStore.states.collect { state ->
// Useful work
}
}
// Subscribing to side effects
val sideEffectsJob = launch {
counterStore.sideEffects.collect { sideEffect ->
// Processing side effects
}
}
// Releasing resources
counterStore.destroy()
Soporte multiplataforma de Kotlin
SimpleMVI soporta varias plataformas a través de Kotlin Multiplatform:
- Android
- Los
- macos
- Título JS
El uso de mecanismos de aislamiento de código específicos de la plataformaexpect/actual
:
// Common code
public expect fun isMainThread(): Boolean
// Android implementation
public actual fun isMainThread(): Boolean {
return Looper.getMainLooper() == Looper.myLooper()
}
// iOS implementation
public actual fun isMainThread(): Boolean {
return NSThread.isMainThread
}
// wasm js implementation
public actual fun isMainThread(): Boolean {
return true // JavaScript is single-threaded
}
El logging se implementa de manera similar para diferentes plataformas:
// Common code
public expect fun logV(tag: String, message: String)
// Android implementation
public actual fun logV(tag: String, message: String) {
Log.v(tag, message)
}
// iOS/wasm js implementation
public actual fun logV(tag: String, message: String) {
println("$tag: $message")
}
Ejemplo práctico: Concierto
Definición del modelo de datos
class CounterStore : Store<CounterStore.Intent, CounterStore.State, CounterStore.SideEffect> {
// Intents - user actions
sealed interface Intent {
data object Increment : Intent
data object Decrement : Intent
data object Reset : Intent
}
// State
data class State(
val count: Int = 0,
val isPositive: Boolean = true
)
// Side effects - one-time events
sealed interface SideEffect {
data object CounterReset : SideEffect
}
}
Gran Implementación
class CounterStore : Store<CounterStore.Intent, CounterStore.State, CounterStore.SideEffect> by createStore(
name = storeName<CounterStore>(),
initialState = State(),
actor = actorDsl {
onIntent<Intent.Increment> {
reduce {
copy(
count = count + 1,
isPositive = count + 1 >= 0
)
}
}
onIntent<Intent.Decrement> {
reduce {
copy(
count = count - 1,
isPositive = count - 1 >= 0
)
}
}
onIntent<Intent.Reset> {
reduce { State() }
sideEffect(SideEffect.CounterReset)
}
}
) {
// Data model defined above
}
Conexión a la UI (Android Exemplo)
class CounterViewModel : ViewModel() {
private val store = CounterStore()
init {
// Built-in extension for automatic lifecycle management
attachStore(store)
}
val state = store.states.stateIn(
scope = viewModelScope,
started = SharingStarted.Eagerly,
initialValue = store.state
)
val sideEffects = store.sideEffects
fun increment() {
store.accept(CounterStore.Intent.Increment)
}
fun decrement() {
store.accept(CounterStore.Intent.Decrement)
}
fun reset() {
store.accept(CounterStore.Intent.Reset)
}
}
Características avanzadas
Configuración de la biblioteca
SimpleMVI ofrece un sistema de configuración flexible:
configureSimpleMVI {
// Strict error handling mode (throws exceptions)
strictMode = true
// Logger configuration
logger = object : Logger {
override fun log(message: String) {
// Your logging implementation
}
}
}
Errores de manejo
- strictMode = verdadero - la biblioteca funciona en modo estricto y lanza excepciones cuando se detectan errores
- strictMode = false (default) - la biblioteca funciona en modo lenient y solo registra errores sin interrumpir la ejecución
Actuación errónea
SimpleMVI tiene excepciones especiales:
- NotOnMainThreadException - cuando intenta llamar a los métodos de la tienda no desde el hilo principal
- StoreIsNotInitializedExcepción - cuando intenta usar una tienda no inicializada
- StoreIsAlreadyDestroyedExcepción - cuando intenta usar una tienda ya destruida
Prueba de componentes
Gracias a la separación limpia de responsabilidades, los componentes SimpleMVI son fáciles de probar:
// Example of Store testing
@Test
fun `increment should increase counter by 1`() {
// Arrange
val store = CounterStore()
store.init()
// Act
store.accept(CounterStore.Intent.Increment)
// Assert
assertEquals(1, store.state.count)
assertTrue(store.state.isPositive)
// Cleanup
store.destroy()
}
Conclusión
A medida que el desarrollo móvil se vuelve cada vez más complejo y los requisitos para la calidad del código y el mantenimiento de la aplicación crecen, la elección de la arquitectura correcta se convierte en una decisión crítica. SimpleMVI ofrece un enfoque moderno y elegante para la organización del código basado en los principios del patrón MVI y adaptado para el desarrollo multiplataforma con Kotlin.
Beneficios de SimpleMVI
Para resumir, se pueden destacar los siguientes puntos fuertes de la biblioteca:
Enfoque minimalista y pragmático
SimpleMVI proporciona sólo los componentes necesarios para implementar el patrón MVI, sin abstracciones y complejidades innecesarias.La biblioteca sigue el principio de "simplicidad por encima de todo", lo que lo hace fácil de entender y usar incluso para los desarrolladores que están acabando de familiarizarse con la arquitectura MVI.
Soporte completo de Kotlin multiplataforma
Construido en Kotlin desde el principio, SimpleMVI está optimizado para el desarrollo multiplataforma.La biblioteca aísla el código específico de la plataforma a través del mecanismo expect/real, asegurando la compatibilidad con Android, iOS, macOS y wasm js.
Gestión del Estado Predecible
El cumplimiento estricto de los principios de inmutabilidad de estado y flujo de datos unidireccional hace que las aplicaciones construidas en SimpleMVI sean más previsibles y menos propensas a errores.
Protección integrada contra problemas comunes
La biblioteca proporciona un control de seguridad estricto del hilo, asegurando que la interacción con el estado ocurra sólo en el hilo principal. Esto evita muchos errores comunes relacionados con el multithreading que pueden ser difíciles de detectar y corregir.
DSL conveniente para la descripción lógica declarativa
Gracias al soporte DSL, SimpleMVI permite describir la lógica empresarial en un estilo declarativo, haciendo el código más legible y comprensible.
Flexibilidad y extensibilidad
A pesar de su enfoque minimalista, SimpleMVI proporciona mecanismos para extender la funcionalidad a través del sistema Middleware. Esto hace que sea fácil agregar capacidades como el logging, la analítica o el borrado sin afectar a la lógica de negocio principal.
Casos típicos de uso
SimpleMVI es particularmente adecuado para los siguientes escenarios:
Proyectos multiplataforma de Kotlin
Si está desarrollando una aplicación que necesita trabajar en múltiples plataformas (Android e iOS, aplicaciones web), SimpleMVI le permite usar un único enfoque arquitectónico y código de lógica empresarial compartido.
Aplicaciones con interacciones complejas de estado y usuario
Para las aplicaciones que gestionan estados complejos y manejan numerosas interacciones de usuarios, el enfoque MVI proporciona una estructura clara y predictibilidad.
Proyectos con énfasis en la probabilidad
Gracias a la clara separación de las responsabilidades entre los componentes y el flujo de datos previsible, las aplicaciones construidas con SimpleMVI son fácilmente testables por unidad.
Migración de proyectos existentes a la arquitectura MVI
SimpleMVI se puede introducir gradualmente, comenzando con módulos o características individuales, por lo que es adecuado para la migración gradual de proyectos existentes a la arquitectura MVI.
Proyectos educativos y prototipos
Debido a su simplicidad y minimalismo, SimpleMVI es adecuado para la enseñanza de principios MVI y para el prototipo rápido.
Recursos para el aprendizaje adicional
Para aquellos que quieran profundizar su conocimiento de la arquitectura SimpleMVI y MVI en general, recomiendo los siguientes recursos:
- SimpleMVI repositorio de GitHub — código fuente de la biblioteca con ejemplos de uso
- Documentación SimpleMVI – Documentación oficial con descripción detallada de la API y recomendaciones
Pensamientos finales
SimpleMVI representa una solución equilibrada para organizar la lógica empresarial de las aplicaciones utilizando enfoques modernos a la arquitectura.La biblioteca ofrece una estructura clara y un flujo de datos previsible sin imponer complejidad innecesaria.
Al elegir una arquitectura para su proyecto, recuerde que no hay solución universal adecuada para todos los casos. SimpleMVI puede ser una excelente opción para proyectos donde se valora la simplicidad, la predictibilidad y el soporte multiplataforma, pero para algunos escenarios, otras bibliotecas o enfoques pueden ser más adecuados.
Experimenta, explora diferentes soluciones arquitectónicas y elige lo que mejor se adapte a las necesidades de tu proyecto y equipo.Y recuerda: la mejor arquitectura es aquella que te ayuda a resolver eficazmente las tareas a mano, no aquella que crea complejidad adicional.