本文へスキップ
すべての記事

2026年のJetpack Glance:データについて決して嘘をつかないホーム画面ウィジェットの作り方

Jetpack Glanceウィジェットの実践ガイド — 状態管理、タップアクション、そしてウィジェットに古いデータを表示させてしまう更新クォータの落とし穴について。

MFKAPPS 1 分で読めます

ウィジェットがホーム画面に居場所を確保できるのは、真実を語っている場合だけだ。昨日の数字を表示した瞬間、あるいはタップしても3秒間何も起きなかった瞬間、ユーザーはそれをフォルダに放り込み、二度と見なくなる。これは見た目以上に高いハードルだ——私がこれまで見てきたウィジェットのコード(自分の最初の実装も含めて)の多くは、インストール直後は正しく、1時間後には静かに間違っている。

今年、Hydrameにホーム画面ウィジェットを追加した。今日の水分摂取量を表示し、タップ一つでコップ1杯分を記録でき、アプリを開く必要がない。ここでは、それを正確に保つJetpack Glanceのアーキテクチャと、少し検索すれば見つかる「ウィジェットが固まった」というバグ報告の大半の原因となる更新頻度の落とし穴について説明する。

RemoteViewsではなくGlanceを選ぶ理由

従来のAppWidgetProvider + RemoteViews APIは動作するが、UIを二重に書く必要がある——アプリ用にComposeで一度、ウィジェット用にXMLベースのRemoteViewsでもう一度、しかもサポートされるビューの種類ははるかに少ない。Jetpack Glanceはこの分断をなくす。ウィジェットをCompose風のDSLで書くだけで、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に慣れていると、2つの点が目を引くはずだ。provideGlanceは独自のライフサイクルで動作し、多くの場合アプリのプロセスの外で実行される。そして、それが読み込む状態はGlance独自のPreferencesストアから来る——ViewModelでもなく、メモリ上に保持しているStateFlowでもない。ウィジェットは、端末が再起動して数秒後、アプリがまだ一度も実行されていない状態でも、コールドスタートから正しく描画できなければならない。

状態はRoomに直接ではなく、DataStoreに置く

GlanceウィジェットはActivityのようにRoomのDaoからライブのFlowを保持することはできない——それを最新に保つ長寿命のコレクターが存在しないからだ。うまくいくパターンは、ウィジェットごとに小さな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は、今日の合計値を変える書き込みの直後——コップ1杯の記録、エントリの編集、深夜のリセットなど——に必ず呼び出す。この一つの呼び出しこそが、Room(正の情報源)とウィジェット(キャッシュされたスナップショット)がずれていくのを防いでいる。ウィジェットはライブビューではなく、書き込み時に更新する読み取りモデルとして扱うべきだ。

更新クォータの落とし穴

AndroidはAppWidgetManager経由でウィジェットを更新できる頻度を制限している——プラットフォームのドキュメントでは、定期更新についておおむね30分という下限が示されており、実際にはメーカーのバッテリーマネージャーがその下限すら不確かなものにしてしまう。ウィジェットのXMLメタデータにあるupdatePeriodMillisだけに頼って「これで完了」と考えると、一度だけ正しく、その日の残りはずっと古いままのウィジェットができあがる。

解決策は、ウィジェットをポーリングするものとして扱うのをやめ、関連するイベントが起きるたびにプッシュする対象として扱うことだ。

  • updateAll()は、バックグラウンドのスケジュールからではなく、書き込み処理(上記のコード)から直接呼び出す。
  • WorkManagerは、アプリとの対話なしに本当に発生させる必要がある更新——たとえば「今日の合計」の深夜リセットなど——にだけ使い、その処理は頻度を低く、バッテリーに優しく保つ。
  • 30分の下限をより短い周期のワーカーで打ち破ろうとしない。勝てないし、どのみちOSが制限する結果のためにバッテリーを消耗するだけだ。

ウィジェットが実際のイベントに応じてのみ更新されるようになると、「次のポーリングを待っている」という時間帯そのものがなくなるため、古いデータに関する報告はほぼ消える。

タップアクションは別プロセスで実行される

actionRunCallback<LogGlassAction>()はウィジェットクラスのメソッドを呼び出すわけではない。Glanceが新たにインスタンス化するActionCallbackを起動するのであり、アプリが現在メモリ上に保持しているどんな状態とも保証された接続はない。

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だけから解決できなければならない。なぜなら、そこにはActivityViewModelもなく、多くの場合アプリの他の部分すら動作していないからだ。実機ではアプリのプロセスが直前に強制終了されていることが珍しくないので、そうであるかのように書くべきだ。

初めてウィジェットを追加する人に伝えたいこと

  1. ウィジェットはライブミラーではなく読み取りモデルとして設計する。 書き込み時に同期し、ポーリングさせない。
  2. すべてのレンダリングでコールドスタートを想定する。 頼れるViewModelもキャッシュされたシングルトンもない——毎回、永続ストレージから読み込む。
  3. 更新の下限と戦うのではなく、それを尊重する。 書き込み時にプッシュされるウィジェットは、厳密な定期スケジュールを必要とせずに正確さを保てる。
  4. クリーンインストール後だけでなく、再起動後やforce-stop後にもテストする。 ウィジェットのバグの大半はそこに潜んでいる。

Hydrameのウィジェットは数週間前から稼働しているが、残る教訓はAndroidのバックグラウンド処理全般で見られるものと同じだ——プラットフォームは理由もなく面倒にしているわけではない。好きなときにポーリングできると仮定するコードから、バッテリーを守っているのだ。それと戦うのではなく、その前提を踏まえて設計すれば、ウィジェットは大きな追加の手間なく正直であり続ける。