Перейти к содержимому
Все записи

Jetpack Glance в 2026 году: как создать виджет для главного экрана, который никогда не врёт о ваших данных

Практическое руководство по виджетам Jetpack Glance на Android — состояние, обработка нажатий и ловушка квоты обновлений, из-за которой виджеты показывают устаревшие данные.

MFKAPPS 5 мин чтения

Виджет заслуживает своё место на главном экране только если он говорит правду. В тот момент, когда он показывает вчерашнее число или нажатие три секунды ничего не делает, пользователь перетаскивает его в папку и больше никогда не смотрит в его сторону. Планка выше, чем кажется: большая часть кода виджетов, которую я видел (включая мою первую попытку), честна сразу после установки и тихо ошибается уже через час.

В этом году я добавил виджет главного экрана в 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, а зачастую и никакой другой части приложения, работающей в этот момент. Пишите его так, будто процесс приложения только что был убит, — потому что на реальном устройстве он нередко именно так и есть.

Что я посоветовал бы тем, кто добавляет свой первый виджет

  1. Проектируйте виджет как модель чтения, а не как живое зеркало. Синхронизируйте его при записи; не заставляйте опрашивать данные.
  2. Предполагайте холодный старт при каждом рендеринге. Нет ViewModel, нет закешированного синглтона, на который можно положиться, — каждый раз читайте из постоянного хранилища.
  3. Уважайте нижний предел обновления, а не боритесь с ним. Виджет, получающий обновление при записи, остаётся точным без жёсткого периодического расписания.
  4. Тестируйте после перезагрузки и после force-stop, а не только после чистой установки. Именно там живёт большинство багов виджетов.

Виджет Hydrame работает уже несколько недель, и вывод из этого тот же, что и для всей фоновой работы на Android: платформа усложняет жизнь не просто так — она защищает батарею от кода, который считает, что может опрашивать данные когда захочет. Проектируйте с учётом этого ограничения, а не борясь с ним, и виджет останется честным без особых дополнительных усилий.

// По теме

Ещё из журнала

MFKAPPS 4 мин чтения

Миграции баз данных Room в 2026 году: как менять схему, не теряя ни одной строки

Практическое руководство по миграциям баз данных Room на Android — AutoMigration, написанные вручную объекты Migration и как протестировать миграцию до того, как её найдут ваши пользователи.

#android #engineering #room
MFKAPPS 4 мин чтения

Как устроен Subly: календарная математика за датами продления подписок

Предсказать дату следующего списания по подписке кажется тривиальным — пока не столкнёшься с биллингом в конце месяца, високосными годами и переходом из пробного периода. Вот как Subly решает это прямо на устройстве.

#android #engineering #subscriptions
MFKAPPS 4 мин чтения

Jetpack DataStore в 2026 году: переход с SharedPreferences без потери ни одной настройки

Практическое руководство по переходу с SharedPreferences на Jetpack DataStore на Android — асинхронные ловушки, путь миграции, сохраняющий существующие значения, и как это протестировать.

#android #engineering #kotlin