Interoperar con Vistas: AndroidView y ComposeView
Las dos direcciones del puente entre Compose y el sistema de vistas: AndroidView para hospedar una vista clásica dentro de la composición, ComposeView para inyectar composición dentro de una jerarquía de vistas, las costuras de ciclo de vida, tema y desplazamiento anidado, y dónde conviene trazar la frontera.
Casi ninguna app real es solo Compose. Hay mapas, reproductores de vídeo, navegadores incrustados y componentes propios con años de historia que viven en el sistema de vistas y no van a reescribirse esta semana, y hay pantallas nuevas en Compose que deben convivir con actividades y fragmentos que aún no lo son. El puente existe en ambas direcciones y funciona bien, pero une dos modelos con ideas incompatibles sobre quién es dueño del estado, quién decide el tamaño y cuándo se destruye algo. Conocer las costuras exactas de ese puente es lo que evita las fugas de memoria, los tamaños colapsados y los desplazamientos que se pelean entre sí.
- Hospedar una vista clásica dentro de la composición con
AndroidViewy su ciclo de creación y actualización. - Insertar composición dentro de una jerarquía de vistas con
ComposeViewdesde código o desde XML. - Reconocer las costuras: propietarios de ciclo de vida, tema, desplazamiento anidado y reciclaje.
- Decidir dónde trazar la frontera para que la interoperación sea barata y localizada.
AndroidView: una vista dentro de la composición
AndroidView es una función componible que emite un nodo especial capaz de hospedar una instancia de View. Su firma se organiza alrededor de tres momentos claramente separados, y confundirlos es el error más frecuente al usarla.
@Composable
fun MapaClasico(centro: LatLng, modifier: Modifier = Modifier) {
AndroidView(
modifier = modifier,
factory = { contexto ->
// se ejecuta UNA vez: aqui se construye y se configura lo inmutable
MapView(contexto).apply { onCreate(null) }
},
update = { vista ->
// se ejecuta tras la creacion y en cada recomposicion que cambie sus lecturas
vista.moverA(centro)
},
onRelease = { vista ->
// la vista sale de la composicion y no volvera: libera recursos
vista.onDestroy()
},
)
}
La lambda de creación se invoca una sola vez por identidad de composición y recibe el contexto. Todo lo que dependa de datos cambiantes no va ahí, sino en la lambda de actualización, que es el punto donde el mundo declarativo se traduce en llamadas imperativas. Esa lambda tiene además una propiedad que conviene entender: se comporta como un efecto que se reejecuta cuando cambia alguno de los valores observables que lee, de modo que solo se dispara cuando de verdad hay algo que sincronizar.
La medida es la primera costura real. El nodo hospedador traduce las restricciones de Compose en especificaciones de medida del sistema de vistas y le pide a la vista que se mida con ellas. Cuando la vista está declarada con dimensiones envolventes y Compose le pasa restricciones sin máximo —dentro de una columna con desplazamiento, por ejemplo—, el resultado puede ser una altura de cero o, al revés, una vista que se estira sin control. La solución no es pelearse con la vista: es acotar el hueco desde la cadena de modificadores para que las restricciones que bajan estén bien definidas.
Cuando lo que quieres inflar es un diseño XML completo con vinculación de vistas, existe AndroidViewBinding, que hace el inflado y expone el objeto de vinculación en la lambda de actualización. Es la ruta cómoda para reutilizar pantallas enteras heredadas sin desmontarlas.
Hay además una variante de AndroidView que acepta una lambda de reinicio, pensada para contenedores que reciclan sus elementos: permite devolver la vista a un estado limpio para reutilizarla en otro elemento en lugar de destruirla y volver a crearla. Si la vista hospedada es cara de construir —y las superficies de mapa o de vídeo lo son—, esa distinción entre soltar y reiniciar es la diferencia entre un desplazamiento fluido y uno con tirones.
Queda un matiz de coste que conviene tener presente. Cada vista hospedada arrastra su propio subárbol del sistema de vistas, con su medida en dos pasos, su invalidación y su dibujo, dentro de un fotograma que Compose ya estaba gestionando por su cuenta. No es dramático para un puñado de instancias y es perfectamente asumible para un mapa o un reproductor; sí lo es cuando la tentación es hospedar decenas de vistas pequeñas en lugar de reescribirlas.
ComposeView: composición dentro de las vistas
La dirección contraria la cubre ComposeView, una View normal y corriente cuyo contenido se define con una función componible. Puedes construirla en código o declararla en un XML como cualquier otro widget, lo que permite introducir Compose en una app existente pantalla a pantalla, o incluso fila a fila.
class FragmentoPerfil : Fragment() {
override fun onCreateView(
inflater: LayoutInflater,
contenedor: ViewGroup?,
estado: Bundle?,
): View = ComposeView(requireContext()).apply {
setViewCompositionStrategy(
ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed
)
setContent {
MiTema { PantallaPerfil() }
}
}
}
La línea que decide la estrategia de composición es la que separa una integración correcta de una fuga de memoria. Esa estrategia responde a una pregunta que en Compose puro no existe: cuándo hay que desechar la composición asociada a esta vista. Por defecto se desecha cuando la vista se separa de la ventana, lo cual es incorrecto para fragmentos —cuya vista se separa y se vuelve a adjuntar en transacciones— y también para vistas recicladas. Atarla al ciclo de vida del árbol de vistas es la elección segura en fragmentos; hay variantes específicas pensadas para contenedores que reciclan sus hijos.
Para componentes reutilizables lo idiomático no es exponer una ComposeView cruda, sino heredar de AbstractComposeView y sobrescribir su contenido. Así tu componente se comporta como una vista más para quien lo consume, con sus atributos y su ciclo de vida, mientras por dentro es Compose.
flowchart LR subgraph C[Mundo Compose] A[Funcion componible] --> B[AndroidView hospeda una vista] end subgraph V[Mundo de Vistas] D[Jerarquia de views] --> E[ComposeView hospeda composicion] end B -.restricciones traducidas a MeasureSpec.-> D E -.necesita propietarios de ciclo de vida.-> A style B fill:#f9e2af,color:#11111b style E fill:#89b4fa,color:#11111b
Las costuras
Tres cosas atraviesan el puente peor de lo que uno espera, y las tres tienen solución conocida.
Propietarios de ciclo de vida
Una ComposeView necesita encontrar en su árbol los propietarios de ciclo de vida y de estado guardado. En una Activity o un Fragment ya están; en una ventana propia o en un servicio hay que instalarlos a mano.
El tema
Los temas no se heredan entre mundos. El tema de Compose no llega a una vista hospedada, y el tema XML no llega a la composición: cada frontera tiene que volver a aplicar el suyo.
Desplazamiento anidado
Los dos sistemas tienen protocolos de desplazamiento anidado distintos. Compose los conecta en la mayoría de los casos, pero anidar contenedores desplazables de ambos mundos sigue siendo la receta del conflicto.
Reciclaje
Meter una ComposeView en cada fila de un contenedor que recicla vistas es el patrón que más problemas de rendimiento y de estrategia de desecho ha generado.
La costura del ciclo de vida es la que produce fallos más desconcertantes, porque el síntoma —una excepción al establecer el contenido, o una composición que nunca se descarta— no menciona el ciclo de vida por ninguna parte. Si tu ComposeView vive fuera de una Activity o de un Fragment, tendrás que asociar explícitamente al árbol de vistas los propietarios de ciclo de vida, de estado guardado y de almacén de modelos antes de poder componer.
Hay una simetría que ayuda a recordarlo. En la dirección de Compose hacia las vistas, lo que no cruza automáticamente son los valores ambientales de la composición: el tema, la densidad, la dirección de escritura y todo lo que viaja implícitamente por el árbol. En la dirección contraria, lo que no cruza son los propietarios del árbol de vistas: ciclo de vida, estado guardado y almacén de modelos. Cada mundo transporta su contexto por un canal que el otro no lee, y el puente solo conecta la geometría y los eventos.
La del tema conviene interiorizarla pronto: cada punto de entrada a Compose debe envolver su contenido con el tema de la app. Si una pantalla tiene tres ComposeView distintas, las tres lo necesitan. Y si dentro de la composición hospedas una vista clásica, esa vista se pintará con el tema XML del contexto, no con los colores de Material que la rodean; el puente estético hay que construirlo a mano pasando colores como parámetros.
Colocar una ComposeView dentro de cada elemento de un contenedor que recicla vistas parece la vía de migración natural y suele salir cara: cada vista hospeda su propia composición, con su propio coste de creación, y la estrategia de desecho por defecto no encaja con el reciclado. Si la lista es el punto caliente, casi siempre compensa migrar el contenedor entero a una lista de Compose antes que sus elementos uno a uno.
Dónde poner la frontera
La conclusión práctica de todo lo anterior es que el puente funciona bien cuando es una frontera y funciona mal cuando es una malla. Cada cruce tiene coste fijo: una traducción de medida, un tema que reaplicar, un ciclo de vida que atar y un protocolo de entrada que reconciliar. Diez cruces cuestan diez veces eso, y además reparten la lógica entre dos modelos mentales distintos en el mismo fichero.
La estrategia que mejor envejece es migrar por unidades completas: una pantalla entera, un diálogo entero, un componente autónomo entero. Dentro de la unidad todo es de un solo mundo, y el cruce ocurre una sola vez y en un sitio identificable. La excepción legítima al principio es lo que no tiene equivalente razonable en Compose: mapas, superficies de vídeo, navegadores incrustados o vistas de dibujo muy especializadas. Ahí hospedar la vista clásica no es deuda técnica, es la decisión correcta y probablemente lo seguirá siendo.
Merece la pena señalar la asimetría entre las dos direcciones. Hospedar una vista dentro de Compose es un patrón estable y de larga vida, porque siempre habrá componentes nativos sin versión componible. Insertar Compose dentro de vistas es, casi siempre, un andamio de migración: útil, bien soportado, y destinado a desaparecer cuando la última pantalla heredada caiga. Tratarlo como lo que es ayuda a no diseñar arquitecturas permanentes alrededor de un puente temporal.
La tentación al mirar AndroidView es leerlo como un adaptador, una capa fina que convierte llamadas de un formato a otro. No lo es, y por eso duele donde duele. Lo que se está reconciliando son dos respuestas incompatibles a la pregunta de dónde vive la verdad de la interfaz. En el sistema de vistas, la verdad está en el objeto: la vista es el estado, tú la mutas y ella recuerda; su ciclo de vida lo gobierna el árbol al que está adjunta y su tamaño surge de una negociación que puede repetirse tantas veces como haga falta. En Compose, la verdad está en la descripción: el nodo es un residuo derivado que el runtime puede crear, actualizar o tirar cuando le convenga, la identidad la fija la posición del código y el tamaño se resuelve en una única pasada sin marcha atrás. El puente tiene que hacer que una cosa se comporte como la otra sin poder cambiar la naturaleza de ninguna, y todas las asperezas conocidas son manifestaciones exactas de ese desajuste. La lambda de creación existe porque la vista es un objeto con identidad que no puede recrearse en cada descripción. La lambda de actualización existe porque hay que traducir de vuelta el flujo declarativo a mutaciones imperativas, y es literalmente el punto donde una teoría se convierte en la otra. Las estrategias de desecho existen porque los dos modelos no coinciden en cuándo algo ha dejado de existir. Los problemas de medida existen porque un protocolo admite restricciones sin límite superior y el otro asume que siempre hay un número. Verlo así tiene una consecuencia inmediata en el diseño: no intentes hacer el puente invisible ni ubicuo. Hazlo explícito, hazlo grande, y ponlo donde puedas señalarlo con el dedo. Una frontera bien trazada convierte un choque de paradigmas en una línea de código; una frontera difusa lo convierte en una clase de errores que aparecen meses después y que nadie sabe atribuir.
- Hospeda una vista clásica con
AndroidViewdentro de una columna con desplazamiento y observa qué ocurre con su altura sin acotar el hueco. - Mueve la configuración dependiente de datos desde la lambda de creación a la de actualización y comprueba el cambio de comportamiento.
- Sustituye el contenido de un fragmento por una
ComposeViewy prueba las distintas estrategias de composición navegando adelante y atrás. - Comprueba qué ocurre cuando olvidas envolver el contenido con el tema de la app en una de las fronteras.
- Anida un contenedor desplazable clásico dentro de uno de Compose, documenta el conflicto y busca la manera de que solo uno reciba el gesto.