El puente entre Compose y MVVM
El ViewModel no cambia. StateFlow, UiState, los use cases, el Repository — todo lo que aprendiste en el curso Avanzado sigue igual. Lo que cambia es cómo la View se suscribe al estado y reacciona a él.
En el View System usabas repeatOnLifecycle + collect desde un Fragment. En Compose usás collectAsStateWithLifecycle(), que convierte el StateFlow en un State<T> de Compose que dispara recomposiciones automáticamente.
collectAsStateWithLifecycle
// ViewModel — sin cambios respecto al curso Avanzado
@HiltViewModel
class ProductosViewModel @Inject constructor(
private val getProductos: GetProductosDisponiblesUseCase
) : ViewModel() {
val uiState: StateFlow<ProductosUiState> = getProductos()
.map { ProductosUiState(productos = it) }
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
initialValue = ProductosUiState(isLoading = true)
)
fun onAction(action: ProductosAction) { /* ... */ }
}
// Screen composable — acá es donde cambia todo
@Composable
fun ProductosScreen(viewModel: ProductosViewModel = hiltViewModel()) {
// collectAsStateWithLifecycle es lifecycle-aware como repeatOnLifecycle
// Deja de colectar cuando el composable sale del ciclo de vida activo
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
// Pasamos el estado y las acciones al composable de contenido (stateless)
ProductosContent(
uiState = uiState,
onAction = viewModel::onAction
)
}
collectAsStateWithLifecycle vs collectAsStatecollectAsState() siempre colecta, incluso en background. collectAsStateWithLifecycle() respeta el ciclo de vida y pausa la colección cuando la pantalla no está activa. Usá siempre la versión lifecycle-aware.
El patrón Screen / Content
La separación clave en Compose con MVVM: un composable "Screen" que sabe del ViewModel, y un composable "Content" puramente stateless que solo recibe datos y lambdas:
// Screen — conoce el ViewModel, no se puede hacer Preview fácilmente
@Composable
fun ProductosScreen(
onNavADetalle: (Int) -> Unit,
viewModel: ProductosViewModel = hiltViewModel()
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
// Manejo de eventos únicos — ver siguiente sección
val lifecycleOwner = LocalLifecycleOwner.current
LaunchedEffect(lifecycleOwner) {
viewModel.eventos.flowWithLifecycle(lifecycleOwner.lifecycle)
.collect { evento ->
when (evento) {
is ProductosEvento.NavADetalle -> onNavADetalle(evento.id)
}
}
}
ProductosContent(
uiState = uiState,
onAction = viewModel::onAction
)
}
// Content — stateless, fácil de previewar y testear
@Composable
fun ProductosContent(
uiState: ProductosUiState,
onAction: (ProductosAction) -> Unit
) {
Scaffold(
topBar = { TopAppBar(title = { Text("Productos") }) },
floatingActionButton = {
FloatingActionButton(onClick = { onAction(ProductosAction.AgregarNuevo) }) {
Icon(Icons.Default.Add, contentDescription = "Agregar")
}
}
) { padding ->
when {
uiState.isLoading -> {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
}
uiState.error != null -> {
ErrorView(
mensaje = uiState.error,
onReintentar = { onAction(ProductosAction.Recargar) }
)
}
uiState.isEmpty -> {
EmptyView(modifier = Modifier.padding(padding))
}
else -> {
LazyColumn(modifier = Modifier.padding(padding)) {
items(uiState.productos, key = { it.id }) { producto ->
TarjetaProducto(
producto = producto,
onClick = { onAction(ProductosAction.SeleccionarProducto(producto.id)) }
)
}
}
}
}
}
}
// Preview de Content — funciona perfectamente con datos ficticios
@Preview
@Composable
fun ProductosContentPreview() {
MiAppTheme {
ProductosContent(
uiState = ProductosUiState(
productos = listOf(
Producto(1, "Tablet", 299.0, 5, Categoria.ELECTRONICA),
Producto(2, "Notebook", 899.0, 2, Categoria.ELECTRONICA)
)
),
onAction = {}
)
}
}
LaunchedEffect — eventos únicos y efectos
LaunchedEffect lanza una coroutine ligada al ciclo de vida del composable. Se cancela cuando el composable sale de la composición y se reinicia cuando la key cambia:
// Caso 1: colectar eventos únicos del ViewModel (navegación, snackbars)
LaunchedEffect(Unit) {
viewModel.eventos.collect { evento ->
when (evento) {
is Evento.NavADetalle -> navController.navigate("detalle/${evento.id}")
is Evento.MostrarSnackbar -> snackbarHostState.showSnackbar(evento.mensaje)
}
}
}
// Caso 2: cargar datos cuando cambia un parámetro
LaunchedEffect(productoId) {
// Se ejecuta cuando productoId cambia
viewModel.cargarProducto(productoId)
}
// Caso 3: acción de una sola vez al entrar a la pantalla
LaunchedEffect(Unit) {
// Unit como key garantiza que solo se ejecuta una vez
viewModel.registrarVisualizacion()
}
No colectes flows dentro de composables sin LaunchedEffectLlamar a flow.collect { } directamente en el cuerpo de un composable lanzaría una coroutine en cada recomposición. LaunchedEffect garantiza que solo existe una coroutine activa.
Otros effect handlers
// SideEffect — sincronizar Compose con código no-Compose
// Se ejecuta en cada recomposición exitosa
SideEffect {
analytics.setCurrentScreen("Productos")
}
// DisposableEffect — para recursos que necesitan limpieza
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) viewModel.recargar()
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
}
}
// rememberCoroutineScope — para lanzar coroutines desde handlers de eventos
val scope = rememberCoroutineScope()
Button(onClick = {
// Los clicks son eventos, no composables — necesitás un scope manual
scope.launch { snackbarHostState.showSnackbar("Guardado") }
}) {
Text("Guardar")
}
El flujo completo de datos
// El ciclo completo con Compose + MVVM:
//
// Usuario toca un botón
// → onClick llama onAction(ProductosAction.EliminarProducto(id))
// → ProductosScreen pasa la lambda: onAction = viewModel::onAction
// → ViewModel.onAction() procesa la acción
// → viewModelScope.launch { repo.eliminar(id) }
// → Room actualiza la DB, el Flow emite la nueva lista
// → StateFlow en el ViewModel se actualiza
// → collectAsStateWithLifecycle() recibe el nuevo UiState
// → uiState en ProductosContent cambia
// → Compose recompone SOLO los composables que leen uiState.productos
// → LazyColumn anima la eliminación del item