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.

kotlin jetpack-compose android migracion

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:

  1. Rendimiento: Kotlin 2.2 + strong-skip mode da mejor rendimiento que RecyclerView en la mayoría de casos
  2. Productividad: Escribir UI es 2x más rápido sin la fricción XML/binding
  3. Mantenibilidad: Un solo lenguaje, un solo paradigma declarativo
  4. 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.