Jetpack Glance en 2026 : construire un widget d'écran d'accueil qui ne ment jamais sur vos données
Un guide pratique des widgets Jetpack Glance sur Android — état, actions de clic, et le piège du quota de mise à jour qui affiche des données périmées.
Un widget ne mérite sa place sur l’écran d’accueil que s’il dit la vérité. Dès qu’il affiche le chiffre d’hier, ou qu’un appui ne fait rien pendant trois secondes, l’utilisateur le glisse dans un dossier et ne le regarde plus jamais. La barre est plus haute qu’il n’y paraît — la plupart du code de widget que j’ai vu (y compris ma première tentative) est honnête juste après l’installation et discrètement faux une heure plus tard.
J’ai ajouté un widget d’écran d’accueil à Hydrame cette année : la consommation d’eau du jour, un appui pour ajouter un verre, pas besoin d’ouvrir l’application. Voici l’architecture Jetpack Glance qui la garde exacte, et le piège de la fréquence de mise à jour à l’origine de la plupart des rapports de bug « mon widget est bloqué » que l’on trouve en cherchant un peu.
Pourquoi Glance plutôt que RemoteViews
L’ancienne API AppWidgetProvider + RemoteViews fonctionne, mais elle oblige à écrire l’interface deux fois — une fois en Compose pour l’application, une fois en RemoteViews basé sur XML pour le widget, avec un ensemble de vues supportées bien plus restreint. Jetpack Glance supprime cette séparation : on écrit le widget dans un DSL proche de Compose, et Glance le compile en RemoteViews au moment du rendu.
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>(),
)
}
}
}
}
}
Deux choses surprennent quand on a l’habitude du Compose classique : provideGlance s’exécute dans son propre cycle de vie, la plupart du temps en dehors du processus de l’application, et l’état qu’il lit provient du magasin Preferences propre à Glance — pas d’un ViewModel, pas d’un StateFlow conservé en mémoire. Le widget doit pouvoir s’afficher correctement à froid, quelques secondes après le redémarrage du téléphone et avant même que l’application n’ait tourné.
L’état vit dans un DataStore, pas directement dans Room
Les widgets Glance ne peuvent pas conserver un Flow en direct issu d’un Dao Room comme le ferait une Activity — il n’y a pas de collecteur de longue durée pour le garder à jour. Le modèle qui fonctionne est un petit Preferences DataStore par widget, écrit à chaque changement des données Room sous-jacentes, et lu au moment où Glance effectue le rendu :
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)
}
Appelez syncWidgetState juste après toute écriture qui change le total du jour — l’ajout d’un verre, la modification d’une entrée, la remise à zéro de minuit. Cet appel unique est ce qui empêche Room (la source de vérité) et le widget (un instantané mis en cache) de diverger. Traitez le widget comme un modèle de lecture que vous rafraîchissez à chaque écriture, pas comme une vue en direct.
Le piège du quota de mise à jour
Android limite la fréquence à laquelle un widget peut être mis à jour via AppWidgetManager — la documentation de la plateforme mentionne un plancher d’environ 30 minutes pour les mises à jour périodiques, et en pratique les gestionnaires de batterie des fabricants rendent même ce plancher peu fiable. Si vous vous reposez sur updatePeriodMillis dans les métadonnées XML du widget en pensant le problème réglé, vous obtiendrez un widget exact une fois puis périmé pour le reste de la journée.
La solution consiste à cesser de traiter le widget comme quelque chose qui interroge périodiquement, et à le traiter comme quelque chose à qui l’on pousse une mise à jour à chaque événement pertinent :
- Déclenchez
updateAll()directement depuis le chemin d’écriture (le code ci-dessus), pas depuis une planification en arrière-plan. - N’utilisez
WorkManagerque pour les mises à jour qui doivent vraiment se produire sans interaction avec l’application — la remise à zéro du « total du jour » à minuit, par exemple — et gardez cette tâche peu fréquente et économe en batterie. - Ne combattez pas le plancher de 30 minutes avec un worker périodique plus serré. Vous ne gagnerez pas, et vous consommerez de la batterie pour un résultat que le système limitera de toute façon.
Une fois que le widget ne se met à jour qu’en réponse à des événements réels, les rapports de données périmées disparaissent presque entièrement, car il n’existe plus de fenêtre où le widget « attend son prochain sondage ».
Les actions de clic s’exécutent dans un processus séparé
actionRunCallback<LogGlassAction>() n’appelle pas une méthode de votre classe de widget — il déclenche un ActionCallback que Glance instancie à neuf, sans lien garanti avec un quelconque état que votre application conserve actuellement en mémoire :
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())
}
}
Chaque dépendance dont le callback a besoin — la base de données, dans ce cas — doit pouvoir se résoudre uniquement à partir du Context, car il n’y a ni Activity, ni ViewModel, et souvent aucune autre partie de l’application en cours d’exécution. Écrivez-le comme si le processus de l’application venait d’être tué, car sur un appareil réel, c’est fréquemment le cas.
Ce que je conseillerais pour un premier widget
- Concevez le widget comme un modèle de lecture, pas un miroir en direct. Synchronisez-le à l’écriture ; ne le faites pas interroger.
- Supposez un démarrage à froid à chaque rendu. Pas de
ViewModel, pas de singleton en cache sur lequel compter — lisez depuis le stockage persistant à chaque fois. - Respectez le plancher de mise à jour au lieu de le combattre. Un widget qui reçoit une poussée à l’écriture reste exact sans avoir besoin d’une planification périodique serrée.
- Testez après un redémarrage et après un force-stop, pas seulement après une installation propre. C’est là que vivent la plupart des bugs de widget.
Le widget de Hydrame est en ligne depuis quelques semaines, et la leçon qui reste vaut pour tout le travail en arrière-plan sur Android : la plateforme ne complique pas les choses sans raison — elle protège la batterie d’un code qui suppose pouvoir sonder quand il le souhaite. Concevez en tenant compte de cette contrainte plutôt qu’en la combattant, et le widget reste honnête sans effort supplémentaire notable.
// À lire aussi
D’autres notes du journal
Les migrations de base de données Room en 2026 : faire évoluer un schéma sans perdre une seule ligne
Un guide pratique des migrations de base de données Room sur Android — AutoMigration, objets Migration écrits à la main, et comment tester une migration avant que vos utilisateurs ne trouvent le bug.
Construire Subly : les calculs de calendrier derrière les dates de renouvellement d'abonnement
Prédire la prochaine date de prélèvement d'un abonnement semble trivial jusqu'à ce qu'on tombe sur la facturation en fin de mois, les années bissextiles et les conversions d'essai. Voici comment Subly y arrive, sur l'appareil.
Jetpack DataStore en 2026 : migrer depuis SharedPreferences sans perdre un seul réglage
Un guide pratique pour passer de SharedPreferences à Jetpack DataStore sur Android — les pièges asynchrones, le chemin de migration qui préserve les valeurs existantes, et comment le tester.