Qué es una función componible
La anotación que cambia la naturaleza de una función: por qué describe la interfaz en lugar de construirla, por qué devuelve Unit y emite en su lugar, qué reescribe el plugin del compilador con el Composer y las claves de posición, y qué contrato adquiere tu código al aceptar esa firma.
Una función componible parece una función corriente con una anotación encima, y esa apariencia inocente es el origen de casi todos los malentendidos iniciales. No fabrica objetos, no te entrega la interfaz que acabas de escribir y no puedes guardarla en una variable para pintarla más tarde. Lo que hace es bastante más extraño: al ejecutarse deja un rastro en una estructura de datos que el motor mantiene por debajo, y de ese rastro —no del valor de retorno— nace la pantalla. Entender qué es ese rastro, y qué le hizo el compilador a tu función para que fuera posible, es exactamente la frontera entre escribir Compose y entender Compose.
- Distinguir describir la interfaz de construirla, y por qué esa diferencia reordena todo el diseño.
- Explicar por qué una función componible devuelve
Unity qué emite en su lugar. - Leer la reescritura que aplica el plugin del compilador y el papel del
Composer. - Enunciar el contrato que firma tu función: idempotencia, ausencia de efectos y orden ajeno.
Describir en vez de construir
En el modelo de vistas clásico, escribir interfaz es fabricar y mutar objetos. Inflas un TextView, guardas la referencia, y cada vez que los datos cambian llamas a setText. El objeto es la fuente de verdad visible y tu trabajo consiste en mantenerlo sincronizado a mano con los datos, sabiendo que cualquier camino que olvides deja la pantalla mintiendo. Ese trabajo de sincronización crece de forma cuadrática con el número de estados posibles, y es donde vive la mayoría de los defectos de interfaz de la última década.
Una función componible invierte el planteamiento. No construye ni conserva nada: describe cómo debe verse la interfaz para unos datos concretos. Cuando los datos cambian, no actualizas la pantalla, sino que Compose vuelve a ejecutar la descripción y calcula por su cuenta la diferencia mínima que hay que aplicar sobre lo que ya existe. La sincronización deja de ser tarea tuya y pasa a ser una propiedad del sistema.
@Composable
fun TarjetaUsuario(nombre: String, conectado: Boolean) {
Column {
Text(text = nombre)
if (conectado) {
Text(text = "En linea")
}
}
}
Fíjate en lo que ese if significa aquí. No es un fragmento de código que decide si crear una vista una vez: es parte de la descripción. Si conectado pasa a ser falso en la siguiente ejecución, la segunda línea sencillamente no se describe, y Compose retira el nodo correspondiente porque ya no forma parte de lo descrito. No hay setVisibility, no hay rama de limpieza, no hay estado intermedio que puedas olvidar. El flujo de control del lenguaje se convierte en el mecanismo de composición de la interfaz, y por eso los bucles, las condiciones y las funciones de Kotlin bastan para expresar jerarquías que en XML exigían inflado dinámico y adaptadores.
De ahí se derivan tres consecuencias que se notan enseguida al escribir. La primera es que desaparecen las referencias a widgets: no hay nada que buscar por identificador ni que guardar en un campo, porque no existe un objeto tuyo al que agarrarte. La segunda es que el número de estados que tu código debe contemplar deja de crecer, porque solo describes el estado actual y nunca las transiciones entre estados. Y la tercera, menos obvia, es que la reutilización cambia de herramienta: donde antes se heredaba de una vista para especializarla, ahora basta con extraer una función y llamarla, porque componer funciones es más barato y más flexible que componer clases.
Qué hace el compilador con tu función
La anotación @Composable no es documentación ni un simple marcador para generar código auxiliar: cambia el tipo de la función y su convención de llamada. Una función componible solo puede invocarse desde otro contexto componible, igual que una función suspend solo puede llamarse desde otra suspend. Es un sistema de efectos, con la misma lógica de contagio: la anotación marca la necesidad de un contexto de ejecución especial, y ese contexto es el que el compilador inyecta.
// Lo que escribes
@Composable
fun Saludo(nombre: String) {
Text(text = "Hola $nombre")
}
// Lo que genera el plugin, drasticamente simplificado
fun Saludo(nombre: String, composer: Composer, changed: Int) {
composer.startRestartGroup(0x7f3a91c2) // clave derivada del sitio de llamada
// si el parametro no cambio, se salta el cuerpo entero
Text(text = "Hola $nombre", composer, 0)
composer.endRestartGroup()?.updateScope { c, _ ->
Saludo(nombre, c, changed or 1) // como reejecutarse mas tarde
}
}
Tres piezas merecen atención. La primera es el parámetro Composer implícito: es el hilo conductor que todas las funciones componibles se van pasando, y el objeto a través del cual se emite. La segunda es la máscara changed, un mapa de bits que indica qué parámetros llegan modificados desde la llamada anterior; sobre ella se apoya la posibilidad de saltarse el cuerpo. La tercera es la clave numérica constante, calculada a partir de la posición del sitio de llamada en el fichero fuente: es la identidad de esa invocación, y no depende de los datos.
Esa última idea es la más contraintuitiva y la más importante. En Compose, la identidad de un fragmento de interfaz no la da un identificador que tú asignas ni el contenido que muestra, sino el lugar del código desde el que se llamó. Dos llamadas a la misma función desde dos líneas distintas son dos elementos distintos y permanentes; la misma llamada ejecutada mil veces es siempre el mismo elemento, aunque cambien todos sus argumentos.
El caso donde la posición no basta es el bucle: si recorres una colección, todas las iteraciones comparten el mismo sitio de llamada y por tanto la misma clave, así que el runtime las distingue por el orden en que aparecen. Mientras la colección solo crece por el final funciona; en cuanto insertas o reordenas, la correspondencia se descoloca y el trabajo memorizado migra al elemento equivocado. Para eso existe la función key, que envuelve un tramo de composición y le da una identidad explícita derivada de tus datos, no de la línea del fichero.
@Composable
fun ListaArticulos(articulos: List<Articulo>) {
Column {
articulos.forEach { articulo ->
key(articulo.id) { // identidad de dominio, no de posicion
FilaArticulo(articulo)
}
}
}
}
El árbol invisible
Ahora se puede responder a la pregunta que todo el mundo hace al principio: si describe la interfaz, ¿por qué devuelve Unit? Porque el resultado no viaja por el valor de retorno, sino por el Composer. Al ejecutarse, la función abre y cierra grupos y emite nodos en una estructura llamada tabla de ranuras: un búfer lineal, con hueco móvil, donde cada grupo ocupa un tramo contiguo y guarda tanto la clave de posición como los valores recordados en ese punto. Recorrerla es recorrer la ejecución de tu código convertida en datos.
flowchart TD A[Funcion anotada con Composable] --> B[El plugin inserta Composer y claves de posicion] B --> C[Al ejecutarse abre grupos y emite en la tabla de ranuras] C --> D[El runtime calcula la diferencia con la version anterior] D --> E[Un Applier materializa el arbol de nodos de layout] style A fill:#a6e3a1,color:#11111b style E fill:#89b4fa,color:#11111b
Si la función devolviera un objeto de interfaz, cada ejecución produciría una jerarquía nueva y habría que compararla entera contra la anterior, que es el modelo del árbol virtual de la web. Compose evita ese coste porque no hay árbol intermedio que comparar: la ejecución escribe directamente sobre la tabla existente, sabe en qué ranura está gracias a las claves y solo anota lo que difiere. Devolver Unit no es una limitación de la que haya que disculparse: es la condición que permite que la memorización sea posicional y que la actualización sea incremental por construcción.
Que la clave salga del sitio de llamada significa que Compose puede recordar cosas por posición en el código sin que tú le des ningún identificador. Ese es el mecanismo que hay debajo de todo lo que en Compose empieza por recordar: no es magia ni reflexión, es un índice en un búfer lineal calculado en tiempo de compilación.
Conviene además separar dos capas que suelen confundirse. El runtime de Compose no sabe nada de Android: gestiona grupos, memorización y cambios sobre un árbol genérico. Quien traduce esas emisiones en nodos concretos es un Applier, y el de la interfaz de Android produce nodos de layout. Por eso el mismo runtime puede pilotar gráficos vectoriales, widgets del escritorio con Glance o, en principio, cualquier árbol mutable. Tu función no habla con vistas: habla con un runtime de composición que resulta estar conectado a ellas.
El contrato que firmas
Aceptar esa firma implica aceptar unas reglas. No son estilo ni buenas maneras: son las precondiciones que el runtime da por ciertas para poder saltarse, reordenar, abandonar o repetir tu función sin avisarte.
Sin efectos secundarios
El cuerpo no debe escribir en variables globales, tocar disco ni lanzar peticiones. Puede ejecutarse muchas más veces de las que imaginas y en momentos que no controlas.
Idempotente
Para los mismos argumentos debe producir la misma descripción. Si el resultado depende de un reloj o de un contador oculto, la interfaz deja de ser una función de sus datos.
Rápida
Se ejecuta dentro del presupuesto de un fotograma. Cualquier trabajo costoso pertenece a otra capa, no al cuerpo de la descripción.
De orden ajeno
El runtime decide en qué orden ejecuta a los hermanos, y puede hacerlo en paralelo. Escribir código que dependa de ese orden es apostar contra la implementación.
Conviene además notar de dónde sale el contagio de la anotación. Como @Composable forma parte del tipo, una lambda que quieras invocar desde dentro de una composición tiene que estar anotada también, y por eso los componentes que reciben contenido declaran su parámetro como una función componible. Ese detalle de tipos es el que permite el patrón de ranuras: en lugar de configurar un componente con veinte parámetros de aspecto, le pasas el contenido que debe emitir y él decide dónde colocarlo. Toda la biblioteca de Material está construida sobre esa idea, y es la razón de que se pueda extender sin heredar de nada.
Hay un corolario práctico que ahorra muchas discusiones de revisión de código: una función componible que devuelve algo distinto de Unit no está describiendo interfaz, está calculando un valor, y casi siempre debería ser una función normal. Y una función que emite interfaz no debería además devolver datos al que la llamó, porque mezcla dos naturalezas que el modelo mantiene deliberadamente separadas.
La convención oficial es exacta y no es cosmética: las funciones que emiten interfaz se nombran en PascalCase como sustantivos, no como verbos, y devuelven Unit. Las que devuelven un valor se nombran en camelCase. Al leer una llamada sabes de inmediato si aquello pinta algo o calcula algo, sin abrir la definición.
Cuesta ver el alcance real de @Composable mientras se la interpreta como una etiqueta que activa un generador de interfaz. No lo es. Es un cambio de tipo que introduce un contexto de ejecución, exactamente igual que suspend introduce el contexto de una continuación. Ambas anotaciones colorean la función y contagian ese color hacia arriba, y ambas lo hacen por la misma razón: el cuerpo necesita hablar con una maquinaria que solo existe si alguien la está pilotando. Una función suspend no gestiona hilos, describe una computación que alguien reanudará; una función componible no gestiona vistas, describe una emisión que alguien materializará. En cuanto se ve así, las prohibiciones dejan de parecer arbitrarias y se convierten en consecuencias. No puede tener efectos secundarios porque el runtime se reserva el derecho a ejecutarla, abandonarla a medias y repetirla; no puede devolver interfaz porque su salida no es un valor sino una escritura sobre una tabla indexada por posición; no puede depender del orden entre hermanos porque ese orden es una decisión del planificador de composición, no de tu código. Y aquí está el giro que casi nadie anticipa: el compilador de Compose no es un generador de código, es la implementación de una convención de llamada distinta dentro del mismo lenguaje. Kotlin sigue siendo Kotlin, pero dentro de ese universo una llamada a función significa otra cosa —significa este trozo de árbol, en este sitio, con estos datos—, y la memorización, la identidad y la actualización incremental caen solas de esa redefinición. Quien programa Compose sin haber interiorizado esto acaba peleándose con la recomposición como si fuera un fallo del framework; quien lo interioriza deja de escribir código que fabrica pantallas y empieza a escribir funciones puras cuyo efecto observable es una interfaz.
- Escribe una función componible mínima y activa los informes del compilador de Compose en Gradle para inspeccionar el fichero de clases generado.
- Descompila el bytecode desde Android Studio y localiza el parámetro
Composery la máscara de cambios en la firma real. - Añade una segunda llamada a la misma función desde otra línea y comprueba que las claves de grupo generadas son distintas.
- Intenta declarar una función componible que devuelva un
Stringy razona por escrito por qué el diseño lo desaconseja aunque compile. - Introduce a propósito un efecto secundario en el cuerpo, como incrementar un contador global, y observa cuántas veces se ejecuta al girar el dispositivo.