De XML a Compose en 2026: Por qué la migración ya no es opcional
Experiencia práctica migrando 43K LOC de ChutApp desde XML a Jetpack Compose. Kotlin 2.2, strong-skip mode y Material 3 Expressive en producción.
El debate XML vs Compose ha terminado. En 2026, mantenerse en XML ya no es una decisión técnica sensata, es resistencia al cambio. Durante los últimos seis meses migré ChutApp —una red social de fútbol base con 43K LOC en Android— desde XML puro a Jetpack Compose. Esto es lo que aprendí en el proceso.
El contexto: ChutApp antes de Compose
ChutApp empezó como un proyecto típico de Android en 2024. XML layouts, ViewBinding, ViewModel pattern, la trinidad clásica que todos conocemos. 43.000 líneas de Kotlin, más de 200 layouts XML, y un setup que funcionaba… hasta que no.
// Antes: El infierno de XML + ViewBinding
class ProfileFragment : Fragment() {
private var _binding: FragmentProfileBinding? = null
private val binding get() = _binding!!
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
_binding = FragmentProfileBinding.inflate(inflater, container, false)
return binding.root
}
private fun setupViews() {
binding.apply {
userNameText.text = viewModel.userName
userImageView.load(viewModel.userImage)
followButton.setOnClickListener {
viewModel.toggleFollow()
}
// Y así con otros 15 views...
}
}
}
Esa función setupViews() tenía 80 líneas. Para cambiar un simple padding necesitaba tocar tres archivos: el layout XML, el binding en el fragment, y posiblemente el ViewModel. La preview de Android Studio se crasheaba cada dos veces que intentaba usarla.
Kotlin 2.2 y strong-skip mode: el game changer
Lo que me convenció definitivamente de migrar no fue Compose en sí, sino las mejoras de rendimiento de Kotlin 2.2. El strong-skip mode cambia completamente las reglas del juego en recomposición.
// Antes en Kotlin 2.1
@Composable
fun PlayerCard(player: Player, onTap: () -> Unit) {
Card(
modifier = Modifier.clickable { onTap() }
) {
Column {
Text(player.name) // Se recompone aunque name no cambie
Text(player.position) // Idem
Text("${player.goals} goles") // Y esto también
}
}
}
Con strong-skip mode en Kotlin 2.2, Compose es mucho más agresivo saltándose recomposiciones innecesarias:
// Ahora con Kotlin 2.2 + strong-skip
@Composable
fun PlayerCard(player: Player, onTap: () -> Unit) {
Card(
modifier = Modifier.clickable { onTap() }
) {
Column {
// Solo se recompone si player.name cambió de verdad
Text(player.name)
Text(player.position)
Text("${player.goals} goles")
}
}
}
La diferencia en rendimiento es brutal. En ChutApp, la lista principal de partidos pasó de 120ms de tiempo de composición inicial a 45ms. Para listas con scroll rápido, la diferencia se nota.
Material 3 Expressive: diseño que no da vergüenza
Una de las excusas más frecuentes para no migrar a Compose era “Material Design se ve genérico”. Material 3 Expressive, introducido a finales de 2025, rompe completamente esa limitación.
// Sistema de colores expresivo personalizado
val ChutAppColorScheme = lightColorScheme(
primary = Color(0xFF1B5E20), // Verde fútbol
secondary = Color(0xFF4CAF50),
tertiary = Color(0xFFFFD54F), // Amarillo tarjeta
surfaceVariant = Color(0xFFF1F8E9),
// Nuevos tokens expresivos
primaryContainer = Color(0xFFC8E6C9),
onPrimaryContainer = Color(0xFF2E7D32),
surfaceTint = Color(0xFF81C784),
scrim = Color(0xFF000000).copy(alpha = 0.32f)
)
@Composable
fun ChutAppTheme(content: @Composable () -> Unit) {
MaterialTheme(
colorScheme = ChutAppColorScheme,
typography = ChutAppTypography,
shapes = ChutAppShapes, // Formas personalizadas
content = content
)
}
Los componentes Material 3 Expressive permiten personalizaciones que antes requerían componentes completamente custom:
@Composable
fun MatchCard(match: Match) {
ElevatedCard(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
colors = CardDefaults.elevatedCardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
),
shape = RoundedCornerShape(
topStart = 16.dp,
topEnd = 16.dp,
bottomStart = 4.dp,
bottomEnd = 4.dp
) // Forma asimétrica nativa
) {
Column(
modifier = Modifier.padding(16.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
TeamInfo(match.homeTeam)
ScoreDisplay(match.homeScore, match.awayScore)
TeamInfo(match.awayTeam)
}
Spacer(modifier = Modifier.height(12.dp))
MatchMetadata(
date = match.date,
venue = match.venue,
competition = match.competition
)
}
}
}
El resultado visual es indistinguible de un diseño custom, pero con toda la accesibilidad y comportamiento nativo de Material 3.
La migración práctica: enfoque gradual
No migré todo de golpe. Empecé por componentes nuevos, luego fui reemplazando pantallas completas. La clave fue usar ComposeView para introducir Compose gradualmente:
// Transición: Compose dentro de XML
class MatchListFragment : Fragment() {
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
return ComposeView(requireContext()).apply {
setContent {
ChutAppTheme {
MatchListScreen(
viewModel = viewModel,
onMatchClick = { match ->
findNavController().navigate(
MatchListFragmentDirections
.actionToMatchDetail(match.id)
)
}
)
}
}
}
}
}
Esta aproximación me permitió migrar pantalla por pantalla sin romper la navegación existente. El truco está en mantener la interfaz con ViewModels intacta:
@Composable
fun MatchListScreen(
viewModel: MatchListViewModel,
onMatchClick: (Match) -> Unit
) {
val matches by viewModel.matches.collectAsState()
val isLoading by viewModel.isLoading.collectAsState()
LazyColumn {
if (isLoading) {
item {
Box(
modifier = Modifier.fillMaxWidth(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
}
} else {
items(matches) { match ->
MatchCard(
match = match,
onClick = { onMatchClick(match) }
)
}
}
}
}
Problemas reales y cómo los resolví
1. Rendimiento en listas grandes
ChutApp tiene listas de miles de partidos. En XML usaba RecyclerView con ViewHolder recycling. En Compose inicial, el rendimiento era peor.
Solución: LazyColumn con key estable y elementos optimizados:
@Composable
fun OptimizedMatchList(matches: List<Match>) {
LazyColumn {
items(
items = matches,
key = { it.id } // Crucial para recycling
) { match ->
MatchCard(match = match)
}
}
}
@Composable
fun MatchCard(match: Match) {
// Usar remember para cálculos costosos
val formattedDate = remember(match.date) {
DateFormatter.format(match.date)
}
// Evitar lambdas inline que causan recomposiciones
val onClickCallback = remember(match.id) {
{ onMatchClick(match) }
}
Card(
onClick = onClickCallback,
modifier = Modifier.fillMaxWidth()
) {
// Contenido del card
}
}
2. Interoperabilidad con librerías nativas
ChutApp usa Mapbox para mostrar ubicaciones de campos. Integrar un MapView nativo en Compose requirió AndroidView:
@Composable
fun MapboxMapView(
initialLocation: LatLng,
onLocationSelected: (LatLng) -> Unit,
modifier: Modifier = Modifier
) {
AndroidView(
modifier = modifier,
factory = { context ->
MapView(context).apply {
getMapAsync { mapboxMap ->
mapboxMap.setStyle(Style.MAPBOX_STREETS) { style ->
// Configuración inicial del mapa
}
}
}
},
update = { mapView ->
// Actualizar mapa cuando cambien los datos
}
)
}
3. Animaciones complejas
Las transiciones entre pantallas en XML eran simples SharedElementTransitions. En Compose necesité repensar completamente las animaciones:
@OptIn(ExperimentalSharedTransitionApi::class)
@Composable
fun MatchToDetailTransition(
match: Match,
navController: NavController
) {
SharedTransitionLayout {
Box {
MatchCard(
match = match,
modifier = Modifier
.sharedElement(
state = rememberSharedContentState(key = "match-${match.id}"),
animatedVisibilityScope = this@SharedTransitionLayout
)
.clickable {
navController.navigate("match_detail/${match.id}")
}
)
}
}
}
Los números: antes y después
Después de 6 meses de migración gradual, los números hablan solos:
Tiempo de desarrollo de UI:
- Antes (XML): 3-4 horas para una pantalla nueva
- Después (Compose): 1-2 horas para la misma complejidad
Líneas de código:
- Layouts XML eliminados: 8.200 líneas
- Kotlin añadido (Compose): 2.800 líneas
- Reducción neta: 5.400 líneas (~12%)
Tiempo de build:
- Antes: 45-50 segundos para clean build
- Después: 38-42 segundos (mejora del compiler de Kotlin 2.2)
Crashes por UI:
- Antes: 12 crashes/mes relacionados con binding o lifecycle
- Después: 2 crashes/mes (principalmente problemas de data)
Compose Multiplatform: el futuro inevitable
Lo que empezó como una migración Android se está convirtiendo en algo más grande. Con Compose Multiplatform estable para iOS, estamos evaluando compartir la UI entre ChutApp Android y la próxima versión iOS.
// UI compartida entre Android e iOS
@Composable
expect fun PlatformSpecificMatchCard(match: Match)
@Composable
fun CommonMatchCard(match: Match) {
Card {
Column {
Text(match.homeTeam.name)
Text("vs")
Text(match.awayTeam.name)
// Componente específico por plataforma
PlatformSpecificMatchCard(match)
}
}
}
Todavía estamos en fase de pruebas, pero la capacidad de compartir 70-80% de la UI entre plataformas es demasiado atractiva para ignorarla.
Mi recomendación en 2026
Si tu app Android sigue usando XML en 2026, estás acumulando deuda técnica. No es solo una cuestión de “estar al día” con las tendencias. Es que Compose se ha vuelto objetivamente superior en casi todos los aspectos:
- Rendimiento: Kotlin 2.2 + strong-skip mode da mejor rendimiento que RecyclerView en la mayoría de casos
- Productividad: Escribir UI es 2x más rápido sin la fricción XML/binding
- Mantenibilidad: Un solo lenguaje, un solo paradigma declarativo
- Futuro: Todas las nuevas APIs de Android son Compose-first
La migración no es trivial, especialmente en apps grandes. Pero el enfoque gradual funciona. Empieza por componentes nuevos, usa ComposeView para introducir Compose en pantallas existentes, y ve reemplazando progresivamente.
En 2026, la pregunta ya no es si migrar a Compose. Es si entiendes Compose lo suficiente como para escribirlo bien. Porque XML ya no es una opción viable para el desarrollo Android moderno.
El ecosistema ha decidido. Es hora de seguirlo.