Durante años, construir una pantalla en Android significaba abrir un XML, colocar vistas, y luego ir al Activity o Fragment a inflar ese layout con setContentView o inflate. Después tocaba conectar cada elemento con findViewById, o con ViewBinding si eras de los afortunados que llegaron antes de que todo se complicara con los adapters de RecyclerView. Funcionaba. Pero era tedioso, repetitivo y propenso a bugs de estado difíciles de rastrear.
Jetpack Compose llegó para cambiar eso, y en 2026 ya no es una apuesta arriesgada ni una tecnología emergente: es el estándar. Google lo usa en todas sus apps propias, todas las guías oficiales de Android parten de él, y los proyectos nuevos que empiezan con Views tienen que justificarlo.
Compose vs el sistema de Views tradicional
El sistema de Views de Android lleva con nosotros desde Android 1.0. Tiene sus virtudes: es maduro, está documentado hasta el agotamiento y casi cualquier problema que tengas ya lo ha resuelto alguien en Stack Overflow. Pero tiene una forma de trabajar imperativa: tú le dices a la vista cómo cambiar. textView.text = "nuevo valor". button.isEnabled = false. Tienes que acordarte de sincronizar el estado de la UI con el estado de tu app, y ese trabajo manual es la fuente de un buen porcentaje de los bugs de cualquier app Android.
Compose funciona al revés. Tú describes cómo quieres que se vea la interfaz dado un estado concreto, y Compose se encarga de actualizar lo que haga falta cuando ese estado cambia. No hay notifyDataSetChanged(), no hay invalidate(), no hay que recordar qué vistas dependen de qué datos. El framework hace ese seguimiento por ti.
Sin XML. Sin inflar layouts. Sin adapters para listas. La UI son funciones de Kotlin.
Composables: las funciones que construyen la UI
La unidad básica de Compose es el composable: una función Kotlin anotada con @Composable.
@Composable
fun MiBoton(text: String, onClick: () -> Unit) {
Button(onClick = onClick) {
Text(text = text)
}
}
Eso es todo. No hereda de ninguna clase. No tiene onResume ni onDestroy. Es una función que, cuando Compose la llama, produce un fragmento de interfaz.
La stdlib de Compose incluye los composables que vas a usar constantemente:
Text: muestra texto.Button: un botón clicable.ColumnyRow: contenedores vertical y horizontal.LazyColumn: la lista eficiente, el reemplazo de RecyclerView.Image: imágenes, con soporte para Coil o Glide.TextField: campo de texto con estado controlado.
Cuando el estado que lee un composable cambia, Compose lo recompone: vuelve a llamar a esa función para actualizar la UI. Solo recompone lo que cambia, no toda la pantalla. Eso es lo que hace que sea eficiente.
Estado: remember y mutableStateOf
El estado en Compose tiene una particularidad: los composables se recomponen, y si no guardas un valor de forma explícita, se pierde en cada recomposición. Para eso está remember.
@Composable
fun Contador() {
var count by remember { mutableStateOf(0) }
Column {
Text(text = "Valor: $count")
Button(onClick = { count++ }) {
Text("Incrementar")
}
}
}
remember { mutableStateOf(0) } crea un estado observable que Compose monitoriza. Cuando count cambia, el composable se recompone y la pantalla se actualiza. La delegación con by (en vez de .value) es azúcar sintáctico que hace el código más limpio.
Si necesitas que el valor sobreviva a una rotación de pantalla o a un cambio de configuración, usas rememberSaveable:
var count by rememberSaveable { mutableStateOf(0) }
Y si necesitas un valor derivado que solo se recalcula cuando cambia su fuente:
val activeItems by remember {
derivedStateOf { list.filter { it.active } }
}
Esto evita recomposiciones innecesarias cuando cambia algo que no afecta al resultado filtrado.
Hoisting de estado: el patrón correcto
Un composable que gestiona su propio estado es stateful. Funciona, pero es difícil de testear y de reutilizar porque el estado está encapsulado dentro. El patrón recomendado es hacer hoisting del estado: sacarlo fuera del composable y pasarlo por parámetros.
// Stateless: recibe estado y eventos, no sabe nada más
@Composable
fun ContadorUI(count: Int, onIncrement: () -> Unit) {
Column {
Text(text = "Valor: $count")
Button(onClick = onIncrement) {
Text("Incrementar")
}
}
}
// Stateful: gestiona el estado y pasa los valores abajo
@Composable
fun ContadorScreen() {
var count by remember { mutableStateOf(0) }
ContadorUI(count = count, onIncrement = { count++ })
}
ContadorUI es completamente testeable sin tener que simular nada. Le pasas un número y un lambda, y compruebas que se muestra bien.
En apps reales con arquitectura MVVM, el estado viene de un ViewModel como StateFlow y se observa con collectAsStateWithLifecycle():
@Composable
fun MiPantalla(viewModel: MiViewModel = viewModel()) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
// usar uiState aquí
}
Si te interesa profundizar en Kotlin moderno antes de meterte de lleno en Compose, estos artículos te vienen bien: Kotlin en Android: el lenguaje que hace posible Compose y Kotlin moderno y el desarrollo Android en 2026.
Layouts: Column, Row, Box y LazyColumn
Los contenedores básicos son tres:
Column { }: coloca sus hijos en vertical.Row { }: los coloca en horizontal.Box { }: los superpone, útil para overlays y posicionamiento absoluto.
Los modificadores controlan el aspecto y el comportamiento de cualquier composable. Se encadenan con el operador .:
Text(
text = "Hola",
modifier = Modifier
.padding(16.dp)
.fillMaxWidth()
.background(Color.LightGray)
)
Para listas largas, LazyColumn solo renderiza los elementos visibles, igual que hacía RecyclerView, pero sin el boilerplate del adapter:
LazyColumn {
items(listaDeItems) { item ->
ItemComposable(item = item)
}
}
Para grids hay LazyVerticalGrid y LazyHorizontalGrid, con lazy loading incluido. En 2026 ya están estables y cubren la mayoría de los casos que antes requerían GridLayoutManager.
Navigation Compose: navegación sin fragmentos
Navigation Compose llegó para reemplazar la navegación basada en Fragments, y en 2026 con la versión 3.x ya tiene soporte de back stack tipado y gestos predictivos de Android 14+.
@Composable
fun AppNavigation() {
val navController = rememberNavController()
NavHost(navController = navController, startDestination = "home") {
composable("home") { HomeScreen(navController) }
composable("detail/{id}") { backStackEntry ->
val id = backStackEntry.arguments?.getString("id")
DetailScreen(id = id)
}
}
}
// Navegar desde cualquier composable:
navController.navigate("detail/$itemId")
La versión 3.x introduce rutas tipadas con objetos serializables en vez de strings, lo que elimina los errores de typo en los destinos y hace la navegación más segura en tiempo de compilación.
Material3: diseño con colores dinámicos
Compose viene integrado con Material3, el sistema de diseño de Google que incluye soporte para los colores dinámicos de Android 12 (Material You). El wallpaper del usuario puede influir en la paleta de colores de tu app sin que tengas que hacer nada especial.
@Composable
fun MiApp() {
val context = LocalContext.current
val colorScheme = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
dynamicLightColorScheme(context)
} else {
lightColorScheme()
}
MaterialTheme(colorScheme = colorScheme) {
// contenido de la app
}
}
Los componentes de Material3 más usados:
FilledButton,OutlinedButton,TextButton: variantes del botón según jerarquía visual.Card: contenedor con elevación y esquinas redondeadas.BottomAppBar: barra inferior con acciones.NavigationBar: barra de navegación entre secciones principales.TopAppBar: cabecera con título y acciones.
Si vienes de Material2, la mayoría de los componentes tienen equivalente directo en Material3, aunque algunos han cambiado de nombre y los parámetros de color funcionan diferente.
Testing de composables
Una de las ventajas menos publicitadas de Compose es lo directo que resulta testear la UI. No necesitas un emulador para los tests de composables básicos: con createComposeRule() renderizas en JVM.
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun boton_muestra_texto_correcto() {
composeTestRule.setContent {
MiBoton(text = "Aceptar", onClick = {})
}
composeTestRule.onNodeWithText("Aceptar").assertIsDisplayed()
}
@Test
fun click_en_boton_llama_al_callback() {
var clicked = false
composeTestRule.setContent {
MiBoton(text = "Aceptar", onClick = { clicked = true })
}
composeTestRule.onNodeWithText("Aceptar").performClick()
assert(clicked)
}
Con testTag puedes marcar composables para buscarlos en los tests sin depender del texto visible:
LazyColumn(modifier = Modifier.testTag("lista")) { ... }
// En el test:
composeTestRule.onNodeWithTag("lista").assertIsDisplayed()
Comparado con los tests instrumentados clásicos de Views, que requerían Espresso y un emulador, esto es considerablemente más rápido de escribir y de ejecutar.
Por dónde empezar
Si tienes una app existente con Views, no hace falta reescribirla de cero. Compose es interoperable con el sistema de Views: puedes añadir composables dentro de Activities y Fragments con ComposeView, y puedes embeber Views tradicionales dentro de Compose con AndroidView. La migración puede ser gradual, pantalla a pantalla.
Si empiezas un proyecto nuevo, la plantilla de Android Studio ya genera un proyecto Compose por defecto. En 2026 eso dice bastante sobre dónde está el estándar.
Imagen: Pexels / Andrey Matveev
