Jetpack Glance en 2026: cómo construir un widget de pantalla de inicio que nunca miente sobre tus datos
Una guía práctica de los widgets de Jetpack Glance en Android: estado, acciones de toque, y la trampa de la cuota de actualización que deja datos obsoletos.
Un widget solo se gana su sitio en la pantalla de inicio si dice la verdad. En el momento en que muestra el número de ayer, o un toque no hace nada durante tres segundos, el usuario lo arrastra a una carpeta y no vuelve a mirarlo. Es un listón más alto de lo que parece: la mayoría del código de widgets que he visto (incluido mi primer intento) es honesto justo después de la instalación y silenciosamente incorrecto una hora más tarde.
Este año añadí un widget de pantalla de inicio a Hydrame: el consumo de agua de hoy, un toque para registrar un vaso, sin necesidad de abrir la app. Esta es la arquitectura de Jetpack Glance que lo mantiene preciso, y la trampa de la frecuencia de actualización que causa la mayoría de los reportes de “mi widget se quedó atascado” que se encuentran al buscar un poco.
Por qué Glance en lugar de RemoteViews
La antigua API AppWidgetProvider + RemoteViews funciona, pero implica escribir la interfaz dos veces: una en Compose para la app, otra en RemoteViews basado en XML para el widget, con un conjunto mucho más reducido de vistas soportadas. Jetpack Glance elimina esa división: se escribe el widget en un DSL similar a Compose, y Glance lo compila a RemoteViews en el momento del renderizado.
class HydrationWidget : GlanceAppWidget() {
override suspend fun provideGlance(context: Context, id: GlanceId) {
provideContent {
val prefs = currentState<Preferences>()
val consumedMl = prefs[intPreferencesKey("consumed_ml")] ?: 0
val goalMl = prefs[intPreferencesKey("goal_ml")] ?: 2000
GlanceTheme {
Column(modifier = GlanceModifier.padding(12.dp)) {
Text("$consumedMl / $goalMl ml", style = TextStyle(fontSize = 18.sp))
Button(
text = "+ 250 ml",
onClick = actionRunCallback<LogGlassAction>(),
)
}
}
}
}
}
Dos cosas llaman la atención si estás acostumbrado al Compose normal: provideGlance se ejecuta en su propio ciclo de vida, la mayor parte del tiempo fuera del proceso de tu app, y el estado que lee proviene del almacén Preferences propio de Glance, no de un ViewModel ni de un StateFlow que mantengas en memoria. El widget tiene que poder renderizarse correctamente en frío, segundos después de que el teléfono se reinició y antes incluso de que tu app haya llegado a ejecutarse.
El estado vive en un DataStore, no directamente en Room
Los widgets de Glance no pueden mantener un Flow en vivo de un Dao de Room como lo haría una Activity: no hay un colector de larga duración que lo mantenga actualizado. El patrón que funciona es un pequeño Preferences DataStore por widget, escrito cada vez que cambian los datos subyacentes de Room, y leído cuando Glance renderiza:
suspend fun syncWidgetState(context: Context, dao: HydrationDao) {
val today = dao.totalForToday()
val manager = GlanceAppWidgetManager(context)
val ids = manager.getGlanceIds(HydrationWidget::class.java)
ids.forEach { id ->
updateAppWidgetState(context, id) { prefs ->
prefs[intPreferencesKey("consumed_ml")] = today
}
}
HydrationWidget().updateAll(context)
}
Llama a syncWidgetState justo después de cualquier escritura que cambie el total del día: registrar un vaso, editar una entrada, el reinicio de medianoche. Esa única llamada es lo que evita que Room (la fuente de verdad) y el widget (una instantánea en caché) se desincronicen. Trata el widget como un modelo de lectura que refrescas al escribir, no como una vista en vivo.
La trampa de la cuota de actualización
Android limita la frecuencia con la que un widget puede actualizarse a través de AppWidgetManager: la documentación de la plataforma describe un piso de aproximadamente 30 minutos para las actualizaciones periódicas, y en la práctica los gestores de batería de los fabricantes hacen que ni siquiera ese piso sea fiable. Si confías en updatePeriodMillis en los metadatos XML del widget y das el tema por resuelto, obtendrás un widget correcto una vez y obsoleto el resto del día.
La solución es dejar de tratar el widget como algo que sondea, y empezar a tratarlo como algo a lo que le envías una actualización en cada evento relevante:
- Dispara
updateAll()directamente desde el camino de escritura (el código de arriba), no desde una programación en segundo plano. - Usa
WorkManagersolo para actualizaciones que realmente deban ocurrir sin interacción con la app —como el reinicio a medianoche del “total del día”— y mantén ese trabajo poco frecuente y respetuoso con la batería. - No intentes vencer el piso de 30 minutos con un worker periódico más ajustado. No lo conseguirás, y gastarás batería en un resultado que el sistema limitará de todas formas.
Una vez que el widget solo se actualiza en respuesta a eventos reales, los reportes de datos obsoletos desaparecen en gran medida, porque ya no existe una ventana en la que el widget esté “esperando su próximo sondeo”.
Las acciones de toque se ejecutan en un proceso aparte
actionRunCallback<LogGlassAction>() no llama a un método de tu clase de widget: dispara un ActionCallback que Glance instancia de cero, sin ninguna conexión garantizada con el estado que tu app pueda tener actualmente en memoria:
class LogGlassAction : ActionCallback {
override suspend fun onAction(
context: Context,
glanceId: GlanceId,
parameters: ActionParameters,
) {
val db = AppDatabase.get(context)
db.hydrationDao().logGlass(amountMl = 250)
syncWidgetState(context, db.hydrationDao())
}
}
Cada dependencia que el callback necesita —la base de datos, en este caso— debe poder resolverse únicamente a partir del Context, porque no hay una Activity, ni un ViewModel, y a menudo ninguna otra parte de tu app en ejecución. Escríbelo como si el proceso de la app acabara de ser eliminado, porque en un dispositivo real, con frecuencia lo ha sido.
Lo que le diría a cualquiera que añade su primer widget
- Diseña el widget como un modelo de lectura, no como un espejo en vivo. Sincronízalo al escribir; no lo hagas sondear.
- Asume un arranque en frío en cada renderizado. No hay
ViewModelni singleton en caché en el que confiar: lee del almacenamiento persistente cada vez. - Respeta el piso de actualización en lugar de combatirlo. Un widget que recibe la actualización al escribir se mantiene preciso sin necesitar una programación periódica ajustada.
- Prueba después de un reinicio y de un force-stop, no solo tras una instalación limpia. Ahí es donde viven la mayoría de los bugs de widgets.
El widget de Hydrame lleva unas semanas en producción, y la lección que se queda es la misma que aparece en todo el trabajo en segundo plano de Android: la plataforma no complica las cosas sin motivo, protege la batería de código que asume que puede sondear cuando le apetezca. Diseña en torno a esa suposición en lugar de pelear contra ella, y el widget se mantendrá honesto sin mucho esfuerzo adicional.
// Lecturas relacionadas
Más del diario
Migraciones de bases de datos Room en 2026: desplegar cambios de esquema sin perder una sola fila
Una guía práctica sobre las migraciones de bases de datos Room en Android — AutoMigration, objetos Migration escritos a mano, y cómo probar una migración antes de que tus usuarios encuentren el error.
Construyendo Subly: las matemáticas de calendario detrás de las fechas de renovación de suscripciones
Predecir la próxima fecha de cobro de una suscripción parece trivial hasta que te topas con la facturación a fin de mes, los años bisiestos y las conversiones de prueba. Así es como Subly lo resuelve, en el dispositivo.
Jetpack DataStore en 2026: migrar desde SharedPreferences sin perder un solo ajuste
Una guía práctica para pasar de SharedPreferences a Jetpack DataStore en Android — las trampas asíncronas, la ruta de migración que preserva los valores existentes y cómo probarla.