Kotlin Lab

Aprender / Compose para quien viene de Flutter

Compose para quien viene de Flutter

No es un manual completo de Compose. Es el mapa mental que necesitas para no traducir widgets de Flutter a ciegas y para entender los tickets de Kontor.

1. Un composable es una función

En Flutter, un widget es una clase con un método build. En Compose, un composable es una función anotada:

@Composable
fun Greeting(name: String) {
    Text("Hola, $name")
}

No hay árbol de widgets que tú reconstruyas. Compose ejecuta funciones y decide qué partes vuelven a ejecutarse cuando cambia el estado. Eso se llama recomposición.

Consecuencias prácticas:

  • Un composable no debe tener efectos secundarios en el cuerpo (llamar a una API, escribir en disco, lanzar una coroutine sin control).

  • Un composable debe ser barato y idempotente: con las mismas entradas, mismo resultado.

  • Si una función no dibuja nada, no necesita ser @Composable.

2. Layout: Column, Row, Box y Modifier

FlutterCompose
ColumnColumn
RowRow
StackBox
ContainerBox + Modifier
PaddingModifier.padding
ExpandedModifier.weight(1f)
SpacerSpacer(Modifier.height(8.dp))
SizedBoxModifier.size(...)
CenterBox(contentAlignment = Alignment.Center)
ListView.builderLazyColumn
GridView.builderLazyVerticalGrid

El orden de los modificadores importa. Modifier.padding(16.dp).background(Red) pinta el fondo después del padding; Modifier.background(Red).padding(16.dp) pinta el fondo detrás del padding. No es lo mismo.

Row(
    modifier = Modifier
        .fillMaxWidth()
        .clickable(onClick = onClick)
        .padding(horizontal = 20.dp, vertical = 12.dp),
    verticalAlignment = Alignment.CenterVertically,
) {
    Icon(Icons.Outlined.AccountBalance, contentDescription = null)
    Spacer(Modifier.width(12.dp))
    Column(Modifier.weight(1f)) {
        Text(account.name, style = MaterialTheme.typography.titleMedium)
        Text(account.maskedIban, style = MaterialTheme.typography.bodySmall)
    }
    Text(Money.format(account.balanceCents))
}

Regla: si un Modifier no hace lo que esperas, revisa el orden antes de inventar un contenedor nuevo.

3. Estado: remember, rememberSaveable y state hoisting

@Composable
fun Counter() {
    var count by remember { mutableIntStateOf(0) }
    Button(onClick = { count++ }) {
        Text("Has pulsado $count veces")
    }
}
  • remember sobrevive a recomposiciones, no a la muerte del proceso.

  • rememberSaveable sobrevive a recreación si el valor es guardable.

  • mutableStateOf dispara recomposición cuando cambia.

  • Si mutas una lista mutable sin crear una nueva, Compose puede no recomponer. Usa mutableStateListOf o copia la lista.

State hoisting: el estado sube, los eventos bajan.

@Composable
fun SearchField(
    query: String,
    onQueryChange: (String) -> Unit,
) {
    OutlinedTextField(
        value = query,
        onValueChange = onQueryChange,
        label = { Text("Buscar") },
    )
}

La pantalla dueña del estado decide qué hacer. El campo no guarda el estado, no conoce el ViewModel y se puede previsualizar con cualquier valor.

Qué va en remember y qué va en ViewModel

EstadoDónde
Texto de un campo mientras escribesrememberSaveable o ViewModel
Filtro que debe sobrevivir a rotación y a process deathViewModel + SavedStateHandle
Loading/error/content de una pantallaViewModel
Animación de un botónremember
Posición de scrollrememberLazyListState
Estado de un diálogo efímeroremember

Si el estado necesita tests, lógica o sobrevivir a la pantalla, no lo metas en el composable.

4. Listas: LazyColumn y claves

LazyColumn(
    contentPadding = PaddingValues(horizontal = 20.dp, vertical = 12.dp),
    verticalArrangement = Arrangement.spacedBy(8.dp),
) {
    items(
        items = transactions,
        key = { it.id },
    ) { transaction ->
        TransactionRow(transaction = transaction, onClick = { onOpen(transaction.id) })
    }
}
  • Usa key para que Compose sepa qué elemento es cuál al insertar, borrar o reordenar.

  • No uses Column con verticalScroll para listas largas: pierdes reciclaje.

  • contentPadding es mejor que un padding exterior si quieres que el scroll llegue hasta el borde.

  • Para listas con cabeceras, usa item { ... } y items(...).

5. Efectos: LaunchedEffect, DisposableEffect y rememberCoroutineScope

El cuerpo de un composable se ejecuta muchas veces. Los efectos existen para hacer cosas fuera de la composición de forma controlada.

@Composable
fun AccountDetail(
    accountId: String,
    viewModel: AccountDetailViewModel,
) {
    LaunchedEffect(accountId) {
        viewModel.load(accountId)
    }

    val state by viewModel.state.collectAsStateWithLifecycle()
    AccountDetailContent(state)
}
  • LaunchedEffect(key) lanza una coroutine y la cancela cuando la key cambia o el composable sale.

  • DisposableEffect(key) sirve para registrar y limpiar recursos no suspendidos (listeners, callbacks, sensores).

  • rememberCoroutineScope() te da un scope ligado a la composición para lanzar acciones desde callbacks.

Alternativa que verás en code reviews: que el ViewModel lea accountId de su SavedStateHandle y cargue en init (o con stateIn), sin LaunchedEffect. Así la carga no depende de que la UI se componga.

No uses LaunchedEffect(Unit) para llamar a un callback de navegación. Para eventos de UI, usa un callback normal.

6. Navegación type-safe

@Serializable
data object Home

@Serializable
data class TransactionDetail(val id: String)
NavHost(
    navController = navController,
    startDestination = Home,
) {
    composable<Home> {
        HomeScreen(onOpenTransaction = { id ->
            navController.navigate(TransactionDetail(id))
        })
    }

    composable<TransactionDetail> { backStackEntry ->
        val route = backStackEntry.toRoute<TransactionDetail>()
        TransactionDetailScreen(id = route.id)
    }
}

Ventajas frente a rutas con strings:

  • el compilador comprueba los argumentos;

  • no hay "detail/{id}" disperso;

  • el SavedStateHandle recibe el argumento con el nombre de la propiedad.

7. ViewModel + Flow + Compose

El patrón de Kontor:

@HiltViewModel
class AccountsViewModel @Inject constructor(
    repository: BankRepository,
) : ViewModel() {

    val state: StateFlow<AccountsUiState> = combine(
        repository.observeAccounts(),
        repository.observeRecentTransactions(6),
    ) { accounts, transactions ->
        AccountsUiState(
            isLoading = false,
            accounts = accounts,
            recentTransactions = transactions,
        )
    }.stateIn(
        scope = viewModelScope,
        started = SharingStarted.WhileSubscribed(5_000),
        initialValue = AccountsUiState(),
    )
}

En la pantalla:

val state by viewModel.state.collectAsStateWithLifecycle()
  • stateIn convierte un Flow frío en StateFlow caliente con un valor inicial.

  • WhileSubscribed(5_000) mantiene el flujo activo un poco después del último colector; útil ante rotaciones.

  • collectAsStateWithLifecycle deja de colectar cuando la pantalla no está visible.

No pases el ViewModel a componentes hijos. Pasa state y callbacks.

8. Tema y estilos

MaterialTheme(
    colorScheme = if (darkTheme) darkColorScheme() else lightColorScheme(),
    typography = KontorTypography,
) {
    Surface(color = MaterialTheme.colorScheme.background) {
        AppContent()
    }
}
  • Usa MaterialTheme.colorScheme y MaterialTheme.typography, no colores y tamaños hardcodeados.

  • Comprueba siempre tema claro y oscuro.

  • Para tamaños de texto, usa la escala tipográfica; si necesitas un tamaño puntual, justifícalo.

  • Los colores de marca pueden vivir en el tema, no en cada pantalla.

9. Testing de Compose

class TransferScreenTest {

    @get:Rule
    val compose = createComposeRule()

    @Test
    fun showsRecipientError() {
        compose.setContent {
            KontorTheme {
                TransferScreen(
                    state = TransferUiState(
                        errors = TransferValidation(recipientError = "Enter the recipient's name"),
                    ),
                    onBack = {},
                    onDone = {},
                    onAccountSelected = {},
                    onRecipientChange = {},
                    onIbanChange = {},
                    onAmountChange = {},
                    onReferenceChange = {},
                    onUseBeneficiary = {},
                    onContinue = {},
                    onEdit = {},
                    onConfirm = {},
                    onDismissError = {},
                )
            }
        }

        compose.onNodeWithText("Enter the recipient's name").assertIsDisplayed()
    }
}
  • createComposeRule para tests de UI sin Activity.

  • onNodeWithText, onNodeWithContentDescription, onNodeWithTag.

  • assertIsDisplayed, assertExists, assertHasClickAction.

  • No uses Hilt ni red en un test de pantalla; pasa un estado falso.

10. Rendimiento: lo que importa de verdad

Antes de optimizar, mide. Estas son las reglas que evitan problemas reales:

  1. Usa key en listas.

  2. No hagas trabajo pesado en el cuerpo de un composable; usa remember o el ViewModel.

  3. Evita crear objetos grandes en cada recomposición sin necesidad.

  4. No pases lambdas nuevas a componentes que se recomponen mucho si puedes estabilizarlas; primero mide.

  5. Usa derivedStateOf cuando un estado se calcula a partir de otro y quieres evitar recomposiciones.

  6. No uses Modifier.composed por moda.

  7. No metas un ViewModel dentro de un componente reutilizable.

Una recomposición no es un fallo. El objetivo no es “cero recomposiciones”, es que la UI no haga trabajo innecesario.

11. Errores típicos viniendo de Flutter

  • Pensar que @Composable es un widget. Es una función; no tiene ciclo de vida propio.

  • Usar remember para todo. El estado de pantalla va al ViewModel.

  • Mutar una lista y esperar recomposición. Crea una lista nueva o usa mutableStateListOf.

  • Llamar a una API en el cuerpo del composable. Usa LaunchedEffect o el ViewModel.

  • Usar Column + verticalScroll para listas largas. Usa LazyColumn.

  • Pasar el ViewModel hacia abajo. Pasa estado y callbacks.

  • Usar collectAsState en Android. Usa collectAsStateWithLifecycle.

  • Olvidar key en items. La lista se comporta raro al cambiar.

  • Confundir padding con offset. No son lo mismo.

  • No previsualizar estados. Un @Preview por estado ahorra media hora de emulador.

12. Ejercicios

  1. Porta una pantalla de lista de my-goloso-flutter a Compose con LazyColumn, loading, empty y error.

  2. Añade un campo de búsqueda con state hoisting y un @Preview con texto vacío y con texto.

  3. Añade un LaunchedEffect que cargue datos al entrar y un DisposableEffect que limpie un listener.

  4. Crea una ruta type-safe de detalle y pasa un id al ViewModel.

  5. Escribe un test de Compose que verifique un mensaje de error.

  6. Haz los tickets 1–6 de docs/KONTOR_TICKETS.md.

Para profundizar

Rutas desde la raíz de kotlin-lab:

Fuente: docs/COMPOSE.md