wandres.dev
WIDGETS CON GLANCE · fuera de la app

Glance: escribir widgets con la sintaxis de Compose

Glance ofrece una gramática que se parece a Compose hasta el punto de confundirse con ella, pero su compositor no emite nodos de interfaz sino un árbol que después se traduce a RemoteViews, y esa diferencia de destino determina exactamente qué se puede escribir y qué no. Esta lección explica la arquitectura de la traducción, recorre el catálogo real de componibles disponibles con sus contenedores, su modificador propio y su tipografía limitada, y enumera con precisión lo que no existe —dibujo libre, layouts propios, animaciones, gestos, efectos secundarios de composición— explicando en cada caso qué propiedad de la frontera entre procesos lo hace imposible en lugar de simplemente pendiente de implementar.

⏱ 19 min

La promesa de Glance es seductora y peligrosa a partes iguales: escribes funciones anotadas como componibles, anidas columnas y filas, encadenas modificadores y obtienes un widget. La sintaxis es tan familiar que el cerebro asume continuidad y empieza a escribir Compose de verdad, con estados recordados, efectos lanzados y modificadores de dibujo, hasta que el compilador se niega o, peor, hasta que compila y el resultado no hace nada. La causa es que Glance comparte con Compose el compilador y el motor de composición, pero no el destino: donde Compose emite nodos que un canvas pintará en tu proceso, Glance emite un árbol intermedio que un traductor convierte en la descripción serializable que estudiaste en la lección anterior. Todo lo que sobrevive a esa traducción existe; todo lo que no cabe en ella, no. Aprender Glance es, sobre todo, aprender esa frontera de traducción con la misma precisión con la que aprendiste la frontera entre procesos.

🎯 Al terminar esta lección sabrás
  • Explicar la cadena que va del componible al árbol intermedio y de este a RemoteViews.
  • Declarar un GlanceAppWidget con su receptor y su punto de entrada suspendido.
  • Manejar el catálogo real de componibles, contenedores y GlanceModifier.
  • Reconocer qué construcciones de Compose no existen en Glance y por qué no pueden existir.

De la función componible a la descripción serializada

La estructura mínima consta de dos clases. Una hereda de GlanceAppWidget y expone un método suspendido donde se llama a provideContent con el árbol a mostrar. La otra hereda de GlanceAppWidgetReceiver y no hace más que apuntar a la primera; sigue siendo el receptor de emisiones del que ya sabes todo, y se declara en el manifiesto exactamente igual.

class ContadorWidget : GlanceAppWidget() {

    override suspend fun provideGlance(context: Context, id: GlanceId) {
        val pendientes = repositorio.contarPendientes()
        provideContent {
            GlanceTheme {
                Column(
                    modifier = GlanceModifier
                        .fillMaxSize()
                        .background(GlanceTheme.colors.widgetBackground)
                        .padding(12.dp),
                ) {
                    Text(text = "Pendientes", style = TextStyle(fontSize = 12.sp))
                    Text(text = pendientes.toString())
                }
            }
        }
    }
}

class ContadorReceiver : GlanceAppWidgetReceiver() {
    override val glanceAppWidget: GlanceAppWidget = ContadorWidget()
}

Que provideGlance sea una función suspendida no es un detalle estético: es el sitio previsto para leer del repositorio, de la base de datos o de las preferencias antes de componer, y resuelve de raíz el viejo problema del proveedor clásico, que solo disponía de unos segundos en el hilo principal. La composición que abre provideContent no termina al emitir el primer árbol; mantiene abierta una sesión mientras el anfitrión considera el widget activo, de modo que un cambio de estado observado dentro puede producir una nueva traducción sin que nadie vuelva a llamar al receptor.

flowchart LR
A[Funcion componible de Glance] --> B[Compositor de Compose runtime]
B --> C[Arbol de nodos de emision]
C --> D[Traductor a RemoteViews]
D --> E[Objeto serializado]
E --> F[AppWidgetManager]
F --> G[Lanzador anfitrion infla y pinta]

Conviene resistirse a leer ese diagrama como una simple capa de conveniencia. Entre el primer paso y el último hay un cambio de naturaleza: a la izquierda existe un árbol vivo, con memoria de las ejecuciones anteriores y capacidad de invalidar selectivamente lo que cambió; a la derecha existe un paquete inerte que describe una interfaz completa y que no recuerda nada. La recomposición fina que tanto trabajo cuesta conseguir dentro de la aplicación se pierde por completo al cruzar, porque el anfitrión no puede recibir un parche: solo entiende fotografías enteras.

El eslabón decisivo es el traductor. Recorre el árbol y, para cada nodo, elige un recurso de diseño real de entre un conjunto generado de antemano por la librería y le aplica operaciones. Ese conjunto es finito y está compilado dentro del artefacto: por eso el número de niveles de anidamiento tiene tope, por eso ciertas combinaciones de modificadores se ignoran en silencio y por eso no existe manera alguna de introducir un componible que el traductor no conozca.

El catálogo que sí existe

Los contenedores son tres y se llaman como sus equivalentes de Compose: Column, Row y Box, con alineaciones análogas. Para contenido largo hay LazyColumn y, en versiones recientes de la plataforma, LazyVerticalGrid; ambos se apoyan por debajo en el servicio de fábrica de filas del sistema, con lo que sus elementos se piden bajo demanda y conviene darles identificadores estables mediante itemId para que el desplazamiento no salte.

La tipografía merece una advertencia propia. Text acepta un TextStyle con tamaño, peso, color, alineación y decoración, y ahí se acaba: no hay familias tipográficas arbitrarias, ni interlineado fino, ni espaciado entre letras, ni desbordamiento con elipsis configurable más allá del número máximo de líneas. Un diseño que dependa de una fuente corporativa concreta o de un ajuste óptico delicado no puede reproducirse aquí, y conviene descubrirlo antes de prometérselo a nadie y no durante la revisión de diseño.

Entre las hojas están Text, Image, Button, Spacer, los indicadores de progreso circular y lineal, y los controles con estado CheckBox, Switch y RadioButton. El artefacto de material añade Scaffold, TitleBar y la paleta dinámica. El modificador se llama GlanceModifier y es un tipo distinto, no un alias: soporta tamaño, relleno, fondo, esquinas redondeadas, visibilidad y la acción de clic, y nada más.

🧱

Tres contenedores

Column, Row y Box cubren todo el diseño. No hay flujo, ni restricciones, ni desplazamiento libre.

📜

Listas perezosas

LazyColumn existe y funciona, pero cada elemento cruza la frontera por separado. Dale itemId estable o el desplazamiento se comportará de forma errática.

🎛️

Controles con estado

CheckBox y Switch tienen apariencia nativa y, en las versiones modernas, cambio visual inmediato en el anfitrión sin esperar a tu proceso.

🎨

Modificador propio

GlanceModifier no es Modifier. Comparte la forma encadenada y casi nada del vocabulario, porque solo puede expresar lo que la descripción sabe transportar.

Lo que no existe, y la razón exacta

Conviene separar lo ausente por falta de tiempo de lo ausente por imposibilidad, porque solo lo segundo debe condicionar el diseño. Casi todo lo relevante pertenece a la segunda categoría.

No hay dibujo libre. Canvas, drawBehind y cualquier acceso a un lienzo requieren ejecutar código tuyo durante la fase de pintado, y el pintado ocurre en el anfitrión. La única salida es rasterizar en tu proceso y enviar el resultado como imagen, con el límite de la transacción vigilando.

No hay layouts propios. Escribir un Layout significa proporcionar una función de medida que se ejecuta durante la fase de medición, otra vez en un proceso que no ejecutará tu código. Tampoco hay medidas intrínsecas ni BoxWithConstraints; el tamaño disponible se conoce por otra vía que estudiarás en la última lección del nivel.

No hay animación. La descripción es una fotografía completa: para animar habría que enviar sesenta fotografías por segundo a través de Binder, lo que ni el presupuesto de energía ni el ancho de banda de IPC admiten. Las transiciones que se ven en algunos widgets son animaciones del sistema aplicadas al cambio de contenido, no tuyas.

No hay medición condicional dentro de la composición. Lo que en Compose resolverías consultando las restricciones entrantes aquí exige conocer el tamaño por otra vía, y esa vía impone declarar de antemano el conjunto de tamaños posibles en lugar de reaccionar a cualquiera.

No hay gestos. pointerInput, arrastres, pulsaciones largas y desplazamientos personalizados exigen un flujo continuo de eventos hacia tu proceso, y lo único que atraviesa la frontera es el disparo de un intent pendiente. Existe el clic y, en las versiones modernas, la conmutación de un control con estado. Nada más.

⚠️
Los efectos secundarios de composición son la trampa más cara

LaunchedEffect, DisposableEffect y rememberCoroutineScope no forman parte del vocabulario útil aquí, y remember conserva valor únicamente mientras la sesión de composición siga viva, cosa que no controlas: el anfitrión puede considerar el widget inactivo en cualquier momento y la próxima composición empezará de cero. Todo lo que deba persistir entre composiciones va al estado del widget, que es el asunto de la lección siguiente. Escribir un contador con remember produce un widget que funciona durante tu sesión de pruebas y se reinicia solo cuando el usuario bloquea la pantalla.

Compartir código sin compartir componibles

De todo lo anterior se sigue la regla práctica que evita mantener dos aplicaciones: lo que se comparte entre la pantalla y el widget no son componibles, son modelos. Una función componible de Compose y una de Glance nunca podrán ser la misma, porque emiten a destinos distintos, pero la clase que decide qué tres elementos mostrar, en qué orden y con qué texto formateado sí puede ser exactamente la misma y además puede probarse sin dispositivo.

// modulo compartido, sin dependencias de interfaz
data class ResumenTareas(
    val titulo: String,
    val pendientes: Int,
    val siguientes: List<String>,
)

fun construirResumen(tareas: List<Tarea>, limite: Int): ResumenTareas =
    ResumenTareas(
        titulo = if (tareas.isEmpty()) "Todo hecho" else "Pendientes",
        pendientes = tareas.count { !it.completada },
        siguientes = tareas.filter { !it.completada }.take(limite).map { it.titulo },
    )

La superficie de la aplicación traduce ese modelo con componibles de Compose y la del escritorio lo traduce con componibles de Glance, cada una con la riqueza que su destino permita. El widget mostrará tres elementos donde la pantalla muestra cincuenta, y usará texto donde la pantalla usa un gráfico, pero ambas estarán de acuerdo sobre los hechos porque ambas parten del mismo cálculo.

💡
Se prueban por separado, y eso es una ventaja

El modelo compartido se prueba en la máquina virtual con pruebas ordinarias y sin emulador. La traducción a Glance se prueba con las utilidades de prueba unitaria de la librería, que ejecutan la composición fuera de un dispositivo y permiten afirmar sobre el árbol emitido sin colocar nada en ningún escritorio. Que las tres capas se prueben con herramientas distintas no es fricción: es la señal de que están correctamente separadas.

Glance demuestra que Compose no es una biblioteca de interfaz, sino un compilador de arboles con destinos intercambiables

Hay una idea de fondo en Glance que va mucho más allá de los widgets y que conviene extraer, porque cambia la forma de entender todo lo que has aprendido sobre Compose. Cuando descubriste las funciones componibles, era natural leerlas como una forma nueva de construir vistas de Android: azúcar sintáctico sobre lo que antes se hacía con XML y objetos de vista. Glance prueba que esa lectura era incorrecta. El compilador de Compose no sabe nada de vistas, ni de canvas, ni de Android. Lo que sabe hacer es tomar un lenguaje de descripción declarativo, mantener una memoria posicional de las ejecuciones anteriores y calcular con precisión quirúrgica qué subconjunto del árbol ha dejado de ser válido cuando cambia un dato. Eso es un motor de reconciliación de árboles, y es completamente independiente de qué sea un nodo del árbol. En Compose de interfaz, un nodo es un elemento del diseño que acabará midiéndose y pintándose. En Glance, un nodo es una instrucción que acabará convirtiéndose en una operación remota. En otros destinos que ya existen, un nodo es una escena tridimensional, un documento, un fragmento de terminal o una fila de una hoja de cálculo. El mismo compilador, la misma memoria posicional, la misma noción de recomposición, destinos distintos. Reconocer esto tiene una consecuencia práctica inmediata y una consecuencia intelectual más lenta. La práctica es que deja de sorprenderte que Glance carezca de dibujo, de gestos y de animación: no le falta nada de Compose, porque de Compose solo tomó el motor; lo que le falta pertenece al destino de interfaz, que es otra cosa. La intelectual es que empiezas a evaluar cualquier tecnología declarativa preguntando primero cuál es su destino y qué operaciones admite ese destino, en lugar de preguntar qué componentes trae. Y de ahí sale la regla de arquitectura que hace que un proyecto con widget no se convierta en dos proyectos: la lógica que decide qué mostrar —qué elementos, en qué orden, con qué formato, bajo qué condiciones— pertenece a una capa sin destino, expresada en tipos propios que ni conocen Compose ni conocen Glance. Cada superficie se limita entonces a traducir ese modelo a su vocabulario. Los equipos que no separan así terminan con dos descripciones divergentes del mismo dominio, y la del widget siempre es la que se queda atrás, porque nadie la mira mientras funcione.

⚔️ Encuentra la frontera de traducción a golpes
  1. Escribe un widget con Column, Text e Image y localiza en el registro cuántas traducciones se producen al colocarlo y al redimensionarlo.
  2. Intenta usar Modifier en lugar de GlanceModifier y estudia el error; después busca en la documentación tres modificadores habituales que no tengan equivalente.
  3. Añade un remember con un contador que crezca en cada clic, deja el widget quieto media hora con la pantalla apagada y comprueba qué queda del valor.
  4. Sustituye un dibujo vectorial complejo por una imagen rasterizada en tu proceso y compara el tamaño del paquete resultante con el límite de la transacción.
  5. Extrae la lógica de qué mostrar a una clase sin dependencias de interfaz y comprueba que puedes probarla en la máquina virtual sin dispositivo.