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
| Flutter | Compose |
|---|---|
Column | Column |
Row | Row |
Stack | Box |
Container | Box + Modifier |
Padding | Modifier.padding |
Expanded | Modifier.weight(1f) |
Spacer | Spacer(Modifier.height(8.dp)) |
SizedBox | Modifier.size(...) |
Center | Box(contentAlignment = Alignment.Center) |
ListView.builder | LazyColumn |
GridView.builder | LazyVerticalGrid |
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")
}
}
remembersobrevive a recomposiciones, no a la muerte del proceso.rememberSaveablesobrevive a recreación si el valor es guardable.mutableStateOfdispara recomposición cuando cambia.Si mutas una lista mutable sin crear una nueva, Compose puede no recomponer. Usa
mutableStateListOfo 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
| Estado | Dónde |
|---|---|
| Texto de un campo mientras escribes | rememberSaveable o ViewModel |
| Filtro que debe sobrevivir a rotación y a process death | ViewModel + SavedStateHandle |
| Loading/error/content de una pantalla | ViewModel |
| Animación de un botón | remember |
| Posición de scroll | rememberLazyListState |
| Estado de un diálogo efímero | remember |
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
keypara que Compose sepa qué elemento es cuál al insertar, borrar o reordenar.No uses
ColumnconverticalScrollpara listas largas: pierdes reciclaje.contentPaddinges mejor que unpaddingexterior si quieres que el scroll llegue hasta el borde.Para listas con cabeceras, usa
item { ... }yitems(...).
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
SavedStateHandlerecibe 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()
stateInconvierte unFlowfrío enStateFlowcaliente con un valor inicial.WhileSubscribed(5_000)mantiene el flujo activo un poco después del último colector; útil ante rotaciones.collectAsStateWithLifecycledeja 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.colorSchemeyMaterialTheme.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()
}
}
createComposeRulepara 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:
Usa
keyen listas.No hagas trabajo pesado en el cuerpo de un composable; usa
remembero el ViewModel.Evita crear objetos grandes en cada recomposición sin necesidad.
No pases lambdas nuevas a componentes que se recomponen mucho si puedes estabilizarlas; primero mide.
Usa
derivedStateOfcuando un estado se calcula a partir de otro y quieres evitar recomposiciones.No uses
Modifier.composedpor moda.No metas un
ViewModeldentro 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
@Composablees un widget. Es una función; no tiene ciclo de vida propio.Usar
rememberpara 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
LaunchedEffecto el ViewModel.Usar
Column+verticalScrollpara listas largas. UsaLazyColumn.Pasar el ViewModel hacia abajo. Pasa estado y callbacks.
Usar
collectAsStateen Android. UsacollectAsStateWithLifecycle.Olvidar
keyenitems. La lista se comporta raro al cambiar.Confundir
paddingconoffset. No son lo mismo.No previsualizar estados. Un
@Previewpor estado ahorra media hora de emulador.
12. Ejercicios
Porta una pantalla de lista de
my-goloso-fluttera Compose conLazyColumn, loading, empty y error.Añade un campo de búsqueda con state hoisting y un
@Previewcon texto vacío y con texto.Añade un
LaunchedEffectque cargue datos al entrar y unDisposableEffectque limpie un listener.Crea una ruta type-safe de detalle y pasa un
idal ViewModel.Escribe un test de Compose que verifique un mensaje de error.
Haz los tickets 1–6 de
docs/KONTOR_TICKETS.md.
Para profundizar
Rutas desde la raíz de kotlin-lab:
docs/FLUTTER_TO_KOTLIN.md: tabla general Dart/Flutter → Kotlin/Compose.docs/KONTOR_TICKETS.md: 20 cambios guiados sobre una app real.docs/RESOURCES.md: documentación oficial y codelabs.feature/accountsyfeature/transferde Kontor: código de referencia.