MVVM moderno en Android — el punto de partida

MVVM es el patrón recomendado por Google y la base de la guía de arquitectura de Android. En su forma moderna — con StateFlow y Kotlin Coroutines — se ve así:

// MVVM moderno — múltiples StateFlows por concepto
class ProductosViewModel @Inject constructor(
    private val repo: ProductoRepository
) : ViewModel() {

    private val _productos = MutableStateFlow<List<Producto>>(emptyList())
    val productos = _productos.asStateFlow()

    private val _isLoading = MutableStateFlow(false)
    val isLoading = _isLoading.asStateFlow()

    private val _error = MutableStateFlow<String?>(null)
    val error = _error.asStateFlow()

    fun cargar() {
        viewModelScope.launch {
            _isLoading.value = true
            try {
                _productos.value = repo.getProductos()
                _error.value = null
            } catch (e: Exception) {
                _error.value = e.message
            } finally {
                _isLoading.value = false
            }
        }
    }

    fun seleccionar(producto: Producto) {
        // Navegar — pero ¿cómo? ¿Otro StateFlow? ¿Un evento?
        // Acá empieza el problema...
    }
}

Este código funciona. Para muchas apps, funciona perfectamente bien. Pero tiene una característica que, a medida que el ViewModel crece, se convierte en un problema real.

El problema que MVI viene a resolver

Con MVVM clásico, el estado está fragmentado en múltiples variables. Tres flows independientes — productos, isLoading, error — pueden combinarse en estados imposibles:

// Estado imposible pero técnicamente representable en MVVM clásico:
_isLoading.value = true
_error.value = "Algo falló"
// ¿Estamos cargando Y hay un error al mismo tiempo? ¿Qué muestra la UI?

// Otro estado ambiguo:
_productos.value = listOf(p1, p2)
_isLoading.value = true
// ¿Hay productos cargados MIENTRAS se está cargando? ¿Son los anteriores o los nuevos?

// Con tres flows independientes hay 2^3 = 8 combinaciones de estado posibles.
// Con cinco flows hay 32.
// Solo algunas de esas combinaciones son estados válidos.
// La UI tiene que adivinar cuáles son válidas y cuáles no.

En pantallas simples esto no importa. En pantallas con mucho estado — filtros, paginación, selección múltiple, modos de edición — la complejidad crece exponencialmente y los bugs de estado inconsistente aparecen.

MVI resuelve esto con una idea simple: un solo objeto de estado que representa toda la pantalla en un momento dado.

El modelo MVI — los tres conceptos

MVI (Model-View-Intent) tiene tres componentes:

  • Intent — las acciones que el usuario puede realizar. No el Intent de Android — es el término de la arquitectura para "intención del usuario". Se modelan como una sealed class.
  • State — un único objeto inmutable que describe todo el estado de la pantalla en un momento. Solo hay un estado válido a la vez.
  • Model / Reducer — la función que toma el estado actual + un intent y produce el nuevo estado. Siempre es pura y determinista.
# El flujo unidireccional de MVI:
#
# Usuario toca "Buscar"
#      ↓
# View emite Intent.Buscar("android")
#      ↓
# ViewModel recibe el intent y llama al reducer
#      ↓
# Reducer produce un nuevo State (isLoading = true)
#      ↓
# View observa el nuevo State y se renderiza
#      ↓
# (datos llegan de la API)
#      ↓
# Reducer produce nuevo State (productos = [...], isLoading = false)
#      ↓
# View se renderiza con los datos
#
# El flujo es estrictamente unidireccional — nunca al revés.

MVI en código — el mismo ViewModel reescrito

// ── STATE — un solo objeto que describe toda la pantalla ────
data class ProductosState(
    val productos: List<Producto> = emptyList(),
    val isLoading: Boolean = false,
    val error: String? = null,
    val query: String = "",
    val filtroCategoria: String? = null
    // todos los campos de estado en un solo lugar
)
// Con data class, equals() es automático y el diff es trivial.
// Solo 1 combinación de estado es la "verdad" en cada momento.

// ── INTENT — las acciones posibles ──────────────────────────
sealed class ProductosIntent {
    object Cargar : ProductosIntent()
    data class Buscar(val query: String) : ProductosIntent()
    data class FiltrarPorCategoria(val categoria: String?) : ProductosIntent()
    data class SeleccionarProducto(val id: Int) : ProductosIntent()
    object Reintentar : ProductosIntent()
}

// ── VIEWMODEL — recibe intents, produce estados ─────────────
class ProductosViewModel @Inject constructor(
    private val repo: ProductoRepository
) : ViewModel() {

    private val _state = MutableStateFlow(ProductosState())
    val state = _state.asStateFlow()

    // Canal de side effects (eventos de un solo uso — ver sección siguiente)
    private val _effects = Channel<ProductosEffect>(Channel.BUFFERED)
    val effects = _effects.receiveAsFlow()

    fun onIntent(intent: ProductosIntent) {
        when (intent) {
            is ProductosIntent.Cargar          -> cargar()
            is ProductosIntent.Buscar          -> buscar(intent.query)
            is ProductosIntent.FiltrarPorCategoria -> filtrar(intent.categoria)
            is ProductosIntent.SeleccionarProducto -> seleccionar(intent.id)
            is ProductosIntent.Reintentar      -> cargar()
        }
    }

    private fun cargar() {
        viewModelScope.launch {
            // Actualizar estado de forma atómica con update{}
            _state.update { it.copy(isLoading = true, error = null) }
            try {
                val productos = repo.getProductos()
                _state.update { it.copy(
                    productos = productos,
                    isLoading = false
                )}
            } catch (e: Exception) {
                _state.update { it.copy(
                    isLoading = false,
                    error = e.message
                )}
            }
        }
    }

    private fun buscar(query: String) {
        viewModelScope.launch {
            _state.update { it.copy(query = query, isLoading = true) }
            val resultados = repo.buscar(query)
            _state.update { it.copy(productos = resultados, isLoading = false) }
        }
    }

    private fun seleccionar(id: Int) {
        viewModelScope.launch {
            // Los side effects (navegación, snackbars) van por el canal
            _effects.send(ProductosEffect.NavigarADetalle(id))
        }
    }
}

// ── En el Fragment — un solo colector para todo ─────────────
viewLifecycleOwner.lifecycleScope.launch {
    viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
        // Un solo lugar donde se renderiza toda la pantalla
        launch {
            viewModel.state.collect { state -> renderState(state) }
        }
        // Side effects en canal separado
        launch {
            viewModel.effects.collect { effect -> handleEffect(effect) }
        }
    }
}

// Acciones del usuario → intents
binding.btnBuscar.setOnClickListener {
    viewModel.onIntent(ProductosIntent.Buscar(binding.etQuery.text.toString()))
}

private fun renderState(state: ProductosState) {
    binding.progressBar.isVisible = state.isLoading
    binding.recyclerView.isVisible = !state.isLoading && state.error == null
    binding.tvError.isVisible = state.error != null
    binding.tvError.text = state.error
    adapter.submitList(state.productos)
    // Toda la lógica de renderizado en un solo método
}

Comparación directa — lo que cambia

# ── ESTADO ───────────────────────────────────────────────────
# MVVM: múltiples StateFlows independientes
#   → Estado puede estar fragmentado y ser inconsistente
#   → Más difícil de razonar sobre combinaciones

# MVI: un solo objeto State
#   → Solo un estado posible en cada momento
#   → Fácil de hacer snapshot para testing o debugging

# ── ACCIONES DE LA UI ────────────────────────────────────────
# MVVM: métodos directos en el ViewModel
#   → viewModel.cargar(), viewModel.buscar(query), viewModel.seleccionar(id)
#   → Más simple, menos boilerplate

# MVI: intents tipados
#   → viewModel.onIntent(ProductosIntent.Buscar(query))
#   → Más verboso, pero todas las acciones posibles están documentadas
#     en la sealed class — es un contrato explícito

# ── TESTABILIDAD ─────────────────────────────────────────────
# MVVM: testear varios flows simultáneamente
#   → Verificar que loading=false Y error=null Y productos=lista

# MVI: testear un solo state object
#   → assertThat(state.value).isEqualTo(ProductosState(productos=lista))
#   → Más simple y menos propenso a falsos positivos

# ── DEBUGGING ────────────────────────────────────────────────
# MVVM: reconstruir el estado viendo múltiples flows en el tiempo

# MVI: el historial de estados es una lista de objetos serializables
#   → Podés loggear cada State completo
#   → Time-travel debugging es trivial

Por qué el estado consistente importa tanto

El ejemplo que más clarifica la diferencia: una pantalla de checkout con múltiples pasos.

// MVVM — el estado está disperso
val direccionValida = MutableStateFlow(false)
val metodoPagoSeleccionado = MutableStateFlow<MetodoPago?>(null)
val resumenVisible = MutableStateFlow(false)
val procesando = MutableStateFlow(false)
val errorPago = MutableStateFlow<String?>(null)
val pedidoConfirmado = MutableStateFlow(false)
// 6 flows → 64 combinaciones posibles. ¿Cuántas son estados válidos?
// ¿Puede procesando=true y pedidoConfirmado=true al mismo tiempo?
// ¿Puede errorPago tener valor y procesando=true simultáneamente?

// MVI — el estado es un objeto con variantes
sealed class CheckoutState {
    object Inicial : CheckoutState()
    data class IngresandoDireccion(val errores: List<String>) : CheckoutState()
    data class SeleccionandoPago(val direccion: Direccion) : CheckoutState()
    data class Resumen(val direccion: Direccion, val metodo: MetodoPago) : CheckoutState()
    data class Procesando(val resumen: Resumen) : CheckoutState()
    data class Error(val mensaje: String, val resumen: Resumen) : CheckoutState()
    data class Confirmado(val numeroPedido: String) : CheckoutState()
}
// 7 estados posibles, todos válidos, ninguno ambiguo.
// La UI hace un when() y sabe exactamente qué mostrar en cada caso.

Sealed class como State es la claveCuando el State es una sealed class (en lugar de una data class con flags booleanos), le estás diciendo al compilador que estos son los únicos estados posibles. El when(state) sin else garantiza que si agregás un estado nuevo, el compilador te obliga a manejarlo en todos los when del código. Cero estados sin manejar.

Side effects — el tema que más confusión genera

Un side effect es algo que la UI necesita hacer una sola vez y que no es estado persistente: navegar a otra pantalla, mostrar un Snackbar, reproducir un sonido. No encaja en el State porque no es algo que la UI deba "recordar" entre recomposiciones.

// Side effects en MVI — Channel de un solo uso
sealed class ProductosEffect {
    data class NavigarADetalle(val productoId: Int) : ProductosEffect()
    data class MostrarSnackbar(val mensaje: String) : ProductosEffect()
    object CerrarPantalla : ProductosEffect()
}

// En el ViewModel:
private val _effects = Channel<ProductosEffect>(Channel.BUFFERED)
val effects = _effects.receiveAsFlow()

private fun seleccionar(id: Int) {
    viewModelScope.launch {
        _effects.send(ProductosEffect.NavigarADetalle(id))
    }
}

// En el Fragment:
viewModel.effects.collect { effect ->
    when (effect) {
        is ProductosEffect.NavigarADetalle ->
            findNavController().navigate(
                ProductosFragmentDirections.actionToDetalle(effect.productoId)
            )
        is ProductosEffect.MostrarSnackbar ->
            Snackbar.make(binding.root, effect.mensaje, Snackbar.LENGTH_SHORT).show()
        ProductosEffect.CerrarPantalla ->
            requireActivity().finish()
    }
}

// ¿Por qué Channel y no StateFlow para los effects?
// StateFlow guarda el último valor y lo re-emite a nuevos colectores.
// Si la pantalla se recrea (rotación), un StateFlow con effect=Navigate
// volvería a navegar. Channel entrega cada evento exactamente una vez.

Cuándo MVI es la elección correcta

  • Pantallas con estado complejo: múltiples modos (vista, edición, selección múltiple), flujos de varios pasos, checkouts, onboarding.
  • Equipos grandes: el contrato explícito de intents y states hace más fácil trabajar en paralelo sin pisarse. Cuando alguien agrega una acción nueva, la añade a la sealed class y el compilador señala todos los lugares donde hay que manejarla.
  • Apps con requisitos de auditabilidad: loggear la secuencia completa de states es trivial y da un historial de lo que hizo el usuario.
  • Cuando necesitás time-travel debugging: guardar snapshots del estado en Redux DevTools o herramientas equivalentes.

Cuándo MVVM sigue siendo la elección correcta

  • Pantallas simples: una lista que se carga, un formulario con dos campos, una pantalla de detalle. El overhead de MVI no agrega valor cuando hay tres estados posibles.
  • Proyectos en solitario o equipos chicos: el boilerplate de intents y effects es código extra que hay que mantener. Si el beneficio del contrato explícito no se aprovecha, es ruido.
  • Cuando ya tenés MVVM funcionando bien: refactorizar a MVI solo porque "es mejor" no tiene sentido si la app no tiene los problemas que MVI resuelve.
  • Para empezar: MVVM es más simple de aprender y de explicar. MVI asume que entendés MVVM primero.

No son mutuamente excluyentes en un proyectoPodés tener pantallas simples con MVVM y pantallas complejas con MVI en el mismo proyecto. La clave es que el patrón siga la complejidad de la pantalla — no al revés.

Cómo responder esta pregunta en una entrevista

La pregunta "¿qué diferencia hay entre MVVM y MVI?" en una entrevista técnica no busca que recites las siglas. Busca ver si entendés los trade-offs reales. Una respuesta que funciona:

"Los dos son patrones de arquitectura unidireccional. La diferencia principal es cómo manejan el estado. En MVVM típico tenés múltiples StateFlows que la UI combina — lo que puede llevar a estados inconsistentes en pantallas complejas. MVI resuelve esto con un único objeto de estado inmutable que representa toda la pantalla en un momento. El costo es más boilerplate: tenés que modelar explícitamente todas las acciones posibles como intents y todos los estados como una sealed class. Para pantallas simples ese costo no vale la pena. Para pantallas con mucho estado o flujos de varios pasos, MVI hace el código más predecible y más fácil de testear."

Si después te preguntan "¿en qué proyecto usarías cada uno?", la respuesta es concreta: MVVM para pantallas de lista/detalle, MVI para checkout, onboarding multistep o cualquier pantalla donde el estado tenga más de tres o cuatro dimensiones independientes.

Lo que no hay que decir en una entrevista: "son básicamente lo mismo" o "depende del gusto personal". No son lo mismo — tienen trade-offs reales — y la elección debería seguir la complejidad del problema, no las preferencias estéticas.