Jetpack Glance в 2026 году: как создать виджет для главного экрана, который никогда не врёт о ваших данных
Практическое руководство по виджетам Jetpack Glance на Android — состояние, обработка нажатий и ловушка квоты обновлений, из-за которой виджеты показывают устаревшие данные.
Виджет заслуживает своё место на главном экране только если он говорит правду. В тот момент, когда он показывает вчерашнее число или нажатие три секунды ничего не делает, пользователь перетаскивает его в папку и больше никогда не смотрит в его сторону. Планка выше, чем кажется: большая часть кода виджетов, которую я видел (включая мою первую попытку), честна сразу после установки и тихо ошибается уже через час.
В этом году я добавил виджет главного экрана в Hydrame — сегодняшнее потребление воды, одно нажатие для записи стакана, без необходимости открывать приложение. Вот архитектура на Jetpack Glance, которая держит его точным, и ловушка частоты обновлений, из-за которой возникает большинство отчётов о багах вида «мой виджет завис», если немного погуглить.
Почему Glance, а не RemoteViews
Старый API AppWidgetProvider + RemoteViews работает, но означает, что интерфейс приходится писать дважды — один раз в Compose для приложения, второй раз в RemoteViews на основе XML для виджета, да ещё и с гораздо более узким набором поддерживаемых представлений. Jetpack Glance устраняет это разделение: вы пишете виджет на DSL, похожем на Compose, а Glance сам компилирует его в RemoteViews во время рендеринга.
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>(),
)
}
}
}
}
}
Если вы привыкли к обычному Compose, бросаются в глаза две вещи: provideGlance выполняется в собственном жизненном цикле, чаще всего вне процесса вашего приложения, а состояние, которое он читает, приходит из собственного хранилища Preferences библиотеки Glance — не из ViewModel, не из StateFlow, который вы держите в памяти. Виджет должен уметь корректно отрисоваться «с холодного старта» — через несколько секунд после перезагрузки телефона, ещё до того, как ваше приложение вообще успело запуститься.
Состояние живёт в DataStore, а не напрямую в Room
Виджеты Glance не могут держать живой Flow из Dao Room так, как это делает Activity, — просто нет долгоживущего коллектора, который поддерживал бы его актуальность. Рабочий паттерн — небольшой Preferences DataStore на каждый виджет, в который записывают значение при каждом изменении исходных данных в Room и из которого читают значение в момент рендеринга Glance:
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)
}
Вызывайте syncWidgetState сразу после любой записи, меняющей итог за день, — записи стакана воды, редактирования записи, полуночного сброса. Именно этот вызов не даёт Room (источнику истины) и виджету (закешированному снимку) разойтись друг с другом. Относитесь к виджету как к модели чтения, которую вы обновляете при записи, а не как к живому представлению.
Ловушка квоты обновлений
Android ограничивает, как часто виджет может обновляться через AppWidgetManager — документация платформы указывает примерно на 30-минутный нижний предел для периодических обновлений, а на практике менеджеры батареи производителей делают ненадёжным даже этот предел. Если вы полагаетесь на updatePeriodMillis в XML-метаданных виджета и считаете вопрос закрытым, вы получите виджет, который верен один раз и устарел до конца дня.
Решение — перестать относиться к виджету как к чему-то, что опрашивает данные, и начать относиться к нему как к чему-то, куда вы проталкиваете обновление при каждом релевантном событии:
- Вызывайте
updateAll()прямо из пути записи (код выше), а не из фонового расписания. - Используйте
WorkManagerтолько для обновлений, которые действительно должны происходить без взаимодействия с приложением — например, полуночный сброс «итога за день», — и держите эту задачу редкой и бережной к батарее. - Не пытайтесь пробить 30-минутный предел более частым периодическим воркером. Вы не выиграете, а батарею потратите на результат, который ОС всё равно ограничит.
Как только виджет начинает обновляться только в ответ на реальные события, отчёты об устаревших данных почти исчезают, потому что больше не остаётся окна, в котором виджет «ждёт следующего опроса».
Обработчики нажатий выполняются в отдельном процессе
actionRunCallback<LogGlassAction>() не вызывает метод вашего класса виджета — он запускает ActionCallback, который Glance создаёт заново, без какой-либо гарантированной связи с тем состоянием, что ваше приложение сейчас держит в памяти:
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())
}
}
Каждая зависимость, нужная колбэку, — в данном случае база данных — должна разрешаться исключительно из Context, потому что нет ни Activity, ни ViewModel, а зачастую и никакой другой части приложения, работающей в этот момент. Пишите его так, будто процесс приложения только что был убит, — потому что на реальном устройстве он нередко именно так и есть.
Что я посоветовал бы тем, кто добавляет свой первый виджет
- Проектируйте виджет как модель чтения, а не как живое зеркало. Синхронизируйте его при записи; не заставляйте опрашивать данные.
- Предполагайте холодный старт при каждом рендеринге. Нет
ViewModel, нет закешированного синглтона, на который можно положиться, — каждый раз читайте из постоянного хранилища. - Уважайте нижний предел обновления, а не боритесь с ним. Виджет, получающий обновление при записи, остаётся точным без жёсткого периодического расписания.
- Тестируйте после перезагрузки и после force-stop, а не только после чистой установки. Именно там живёт большинство багов виджетов.
Виджет Hydrame работает уже несколько недель, и вывод из этого тот же, что и для всей фоновой работы на Android: платформа усложняет жизнь не просто так — она защищает батарею от кода, который считает, что может опрашивать данные когда захочет. Проектируйте с учётом этого ограничения, а не борясь с ним, и виджет останется честным без особых дополнительных усилий.
// По теме
Ещё из журнала
Миграции баз данных Room в 2026 году: как менять схему, не теряя ни одной строки
Практическое руководство по миграциям баз данных Room на Android — AutoMigration, написанные вручную объекты Migration и как протестировать миграцию до того, как её найдут ваши пользователи.
Как устроен Subly: календарная математика за датами продления подписок
Предсказать дату следующего списания по подписке кажется тривиальным — пока не столкнёшься с биллингом в конце месяца, високосными годами и переходом из пробного периода. Вот как Subly решает это прямо на устройстве.
Jetpack DataStore в 2026 году: переход с SharedPreferences без потери ни одной настройки
Практическое руководство по переходу с SharedPreferences на Jetpack DataStore на Android — асинхронные ловушки, путь миграции, сохраняющий существующие значения, и как это протестировать.