Las tres capas de MVVM

Model–View–ViewModel divide la pantalla en tres responsabilidades que no se mezclan:

  • Model (datos): el Repository y las fuentes de datos (Room, Retrofit). No sabe nada de Android ni de la UI.
  • ViewModel (lógica de presentación): transforma datos del Model en algo que la View puede mostrar. Sobrevive a rotaciones. No tiene referencias a Views.
  • View (Fragment/Activity): solo observa estado y manda acciones al ViewModel. No toma decisiones de negocio.

La regla de oroSi tu Fragment tiene un if que decide qué datos mostrar, algo está mal. Esa decisión debe estar en el ViewModel. El Fragment solo debe mapear estados a Views.

Flujo unidireccional de datos (UDF)

El patrón moderno para MVVM es Unidirectional Data Flow: los datos fluyen siempre en una dirección — del ViewModel hacia la View — y las acciones fluyen en la dirección opuesta:

// El ciclo completo:
// View  →  [acción]  →  ViewModel  →  [nuevo estado]  →  View

// 1. El usuario toca "Agregar al carrito"
// 2. El Fragment llama: viewModel.onAgregarAlCarrito(producto)
// 3. El ViewModel ejecuta la lógica, actualiza el UiState
// 4. El Fragment observa el nuevo UiState y redibuja la pantalla

Nunca en la dirección contraria: el ViewModel nunca llama a métodos del Fragment, nunca tiene una referencia a la View, y nunca actualiza la UI directamente.

UiState complejo y realista

Pantallas reales tienen estados complejos. En lugar de múltiples flags (isLoading, hasError, isEmpty...) que pueden crear combinaciones imposibles, modelá el estado como una data class completa:

// Estado de una pantalla de lista con búsqueda
data class ProductosUiState(
    val productos: List<Producto> = emptyList(),
    val isLoading: Boolean = false,
    val error: String? = null,
    val busqueda: String = "",
    val filtroCategoria: String? = null,
    val totalItems: Int = 0
) {
    // Propiedades derivadas — no las guardes, calcúlalas
    val isEmpty: Boolean get() = !isLoading && productos.isEmpty() && error == null
    val productosFiltrados: List<Producto> get() =
        if (filtroCategoria == null) productos
        else productos.filter { it.categoria == filtroCategoria }
}

class ProductosViewModel(private val repo: ProductoRepository) : ViewModel() {

    private val _uiState = MutableStateFlow(ProductosUiState())
    val uiState: StateFlow<ProductosUiState> = _uiState.asStateFlow()

    // update{} es thread-safe: garantiza que no haya race conditions
    fun onBusquedaCambio(texto: String) {
        _uiState.update { it.copy(busqueda = texto) }
    }

    fun onFiltroSeleccionado(categoria: String?) {
        _uiState.update { it.copy(filtroCategoria = categoria) }
    }
}

Modelar acciones del usuario

En pantallas complejas, una sealed class para las acciones hace el ViewModel más limpio y testeable:

// Todas las acciones posibles de la pantalla
sealed class ProductosAction {
    data class Buscar(val texto: String) : ProductosAction()
    data class FiltrarPorCategoria(val categoria: String?) : ProductosAction()
    data class EliminarProducto(val id: Int) : ProductosAction()
    data class AgregarAlCarrito(val producto: Producto) : ProductosAction()
    object Recargar : ProductosAction()
}

class ProductosViewModel(...) : ViewModel() {
    fun onAction(action: ProductosAction) {
        when (action) {
            is ProductosAction.Buscar -> buscar(action.texto)
            is ProductosAction.FiltrarPorCategoria -> filtrar(action.categoria)
            is ProductosAction.EliminarProducto -> eliminar(action.id)
            is ProductosAction.AgregarAlCarrito -> agregarAlCarrito(action.producto)
            ProductosAction.Recargar -> cargar()
        }
    }
}

// En el Fragment — interfaz de un solo punto de entrada:
binding.btnEliminar.setOnClickListener {
    viewModel.onAction(ProductosAction.EliminarProducto(producto.id))
}

Eventos únicos vs estado persistente

Hay una distinción fundamental que muchos pasan por alto:

  • Estado: algo que es verdad ahora y seguirá siendo verdad si la pantalla se recrea (ej: la lista de productos, el texto del buscador).
  • Evento: algo que pasa una sola vez y no debe repetirse al rotar (ej: mostrar un Snackbar, navegar a otra pantalla).
// Para eventos únicos: Channel convertido a Flow
class ProductosViewModel(...) : ViewModel() {

    private val _eventos = Channel<UiEvento>(Channel.BUFFERED)
    val eventos: Flow<UiEvento> = _eventos.receiveAsFlow()

    sealed class UiEvento {
        data class MostrarSnackbar(val mensaje: String) : UiEvento()
        data class NavADetalle(val id: Int) : UiEvento()
        object NavAtras : UiEvento()
    }

    fun guardarProducto(producto: Producto) {
        viewModelScope.launch {
            repo.guardar(producto)
            _eventos.send(UiEvento.MostrarSnackbar("Guardado"))
            _eventos.send(UiEvento.NavAtras)
        }
    }
}

Channel vs SharedFlow para eventosAmbos funcionan. Channel(BUFFERED) garantiza que el evento no se pierde si el Fragment no está colectando en ese momento (ej: pantalla en background). Es la opción más segura para navegación y Snackbars.

Errores en la UI — el patrón correcto

// Los errores SON estado — viven en el UiState
data class UiState(
    val datos: List<Item> = emptyList(),
    val isLoading: Boolean = false,
    val errorMensaje: String? = null   // null = sin error
)

// En el ViewModel: limpiar el error cuando el usuario lo descarta
fun onErrorDismissed() {
    _uiState.update { it.copy(errorMensaje = null) }
}

// En el Fragment: mostrar el error y notificar al ViewModel cuando se cierra
viewModel.uiState.collectLatest { state ->
    state.errorMensaje?.let { mensaje ->
        Snackbar.make(binding.root, mensaje, Snackbar.LENGTH_LONG)
            .addCallback(object : Snackbar.Callback() {
                override fun onDismissed(sb: Snackbar, event: Int) {
                    viewModel.onErrorDismissed()
                }
            }).show()
    }
}

Anti-patterns comunes

  • Lógica en el Fragment: cálculos, transformaciones de datos, decisiones de negocio — todo eso va en el ViewModel.
  • context en el ViewModel: si necesitás un Context, usá AndroidViewModel (con cuidado) o mejor, mové esa dependencia a una clase separada.
  • Observar en onCreate: siempre observá en onViewCreated con viewLifecycleOwner.
  • MutableStateFlow expuesto: nunca expongas el _uiState mutable. Siempre exponé la versión inmutable.
  • Repository en el Fragment: el Fragment no debe acceder directamente al Repository. Solo habla con el ViewModel.