はてなブログAndroidアプリのウィジェットはJetpack Glanceで作られています

こんにちは!Web アプリケーションエンジニアの id:SlashNephy です。

Recomposing Hatena Blog for Android シリーズは、2025年にリニューアルされた はてなブログ Android アプリ の開発体験や技術詳細を解説する連載です。前回は id:yutailang0119 の「はてなブログAndroidアプリでのJetpack Navigation 3移行」でした。

連載の最終回となる今回は、ウィジェットを取り上げます。リニューアルしたブログアプリには、「購読中のブログ」「お知らせ」「アクセス解析」の3種類のウィジェットが実装されました。

  • 購読中のブログ: 購読中のブログの最新記事が並びます。
  • お知らせ: もらったスターやコメントなどを表示します。
  • アクセス解析: 自身のブログの統計情報と PV の推移グラフを表示します。

この記事では、Jetpack Glance を使った実装の過程で何に詰まり、どう解決したかを詳しくお話しします。 また、Gradle マルチモジュール戦略や Hilt を使った DI 設計、WorkManager によるバックグラウンド処理といった話題も登場します。ウィジェット開発だけでなく、Android アプリ開発全般で参考にしていただければと思います。

Jetpack Glance とは

従来のウィジェット開発では、RemoteViews が利用されていました。 RemoteViews は、別プロセスにビュー階層を渡して描画してもらうための仕組みです。 XML レイアウトと setTextViewText() のような命令的な操作で UI を組み立てる必要があり、使えるビューの種類も限られるため、通常の開発とは勝手の異なる領域でした。

Jetpack Glance は、RemoteViews によるウィジェット開発を Compose 風の宣言的 UI に置き換えるライブラリです。 もはやお馴染みになった Composable 関数のスタイルでウィジェットが書けます。 ただし、内部では RemoteViews へとコンパイルされるため*1、RemoteViews の制約から完全に逃れることはできません。ここが少々つまずきやすいポイントです。

ここに Glance で記述されたウィジェットのコードを示します。見た目こそ通常の Compose と似ていますが、いくつか重要な違いがあります。

例えば、同名の Composable があってもインポート元が違います。ほかにも、Modifier の代わりに GlanceModifier を使う点や clickable modifier に渡すコールバックがラムダ式ではなく Action 型になる点が挙げられます。

// インポート元は androidx.compose.material3.Scaffold ではない
import androidx.glance.appwidget.components.Scaffold

// 購読中ブログウィジェットの Content Composable
@Composable
fun SubscribingBlogWidgetContent(
    layout: SubscribingBlogWidget.Layout,
    state: SubscribingBlogWidgetState,
    onClickTitleBar: Action, // () -> Unit ではなく Action 型
    onClickReload: Action,
) {
    val context = LocalContext.current

    Scaffold(
        titleBar = {
            BlogAppWidgetTitleBar(
                title = context.getString(R.string.subscribing_blog_widget_title),
                onClickTitleBar = onClickTitleBar,
                onClickReload = onClickReload,
            )
        },
        // Modifier ではなく GlanceModifier
        modifier = GlanceModifier.fillMaxSize().padding(bottom = 12.dp),
        horizontalPadding = 0.dp,
    ) {
        when (state) {
            is SubscribingBlogWidgetState.LoginRequired ->
                FullScreenLoginRequired(
                    modifier = GlanceModifier.padding(horizontal = 12.dp),
                )
            is SubscribingBlogWidgetState.Loading ->
                FullScreenIndicator(
                    modifier = GlanceModifier.padding(horizontal = 12.dp),
                )
            is SubscribingBlogWidgetState.Content -> when {
                state.blogs.isEmpty() ->
                    ContentEmptyView()
                layout is SubscribingBlogWidget.Layout.SingleView ->
                    ContentSingleView(blog = state.blogs.first())
                layout is SubscribingBlogWidget.Layout.ListView ->
                    ContentListView(blogs = state.blogs)
            }
        }
    }
}

Gradle モジュール構成の変遷

最初の「購読中のブログ」ウィジェットは単一のモジュールで作りました。速度優先で、モジュールの分割は意図的に後回しにしました。

2つ目の「お知らせ」ウィジェットに着手すると、状態管理やレイアウト制御、テーマ管理が重複しはじめました。そこで、共通コードを :core に切り出し、ウィジェット (:widget) 間で共有できるようにしました。

あえて、個々のウィジェットを機能モジュール (:feature) としないことで、通常の Compose による画面の書き方を誤って持ち込むことを防止でき、保守性が良くなります。

:app
├─ :core:widget (状態管理・レイアウト制御等)
├─ :core:widgetworker (Worker のインターフェイス)
├─ :core:theme
│
├─ :widget:subscribingblog
├─ :widget:notice
└─ :widget:accesslog

まず1つ目を作り、2つ目で共通化の必要性が見えてから切り出す、という段取りのおかげで的外れな抽象化を回避できました。

Glance での状態管理

ウィジェットはアプリのプロセスとは分離されています。そのため、状態はアプリ本体から独立したストアに永続化するのが好ましいです。Glance には GlanceStateDefinition を実装することで、DataStore を介して状態を読み書きできるようにするユーティリティが用意されています。

// ウィジェットの状態を表す基底型
interface BlogAppWidgetState

// 状態 S: BlogAppWidgetState を DataStore に永続化する定義
class BlogAppWidgetStateDefinition<S : BlogAppWidgetState>(
    private val defaultValueFactory: () -> S,
    // kotlinx.serialization のシリアライザ
    private val kSerializer: KSerializer<S>,
) : GlanceStateDefinition<S> {
    override fun getLocation(context: Context, fileKey: String): File {
        return context.dataStoreFile(/* ... */)
    }

    override suspend fun getDataStore(context: Context, fileKey: String): DataStore<S> {
        return DataStoreFactory.create(
            // S を DataStore 形式にシリアル化する androidx.datastore.core.Serializer
            serializer = BlogAppWidgetStateSerializer(defaultValueFactory, kSerializer),
            produceFile = { getLocation(context, fileKey) },
        )
    }
}

ウィジェットの状態は sealed interface を使うことで、UI 側で出し分ける際に when 式で網羅性チェックが効くようになります。 例えば、「購読中のブログ」ウィジェットでは次のようになっています。画面に表示すべき状態と1対1で対応しているので理解しやすいです。

@Serializable
sealed interface SubscribingBlogWidgetState : BlogAppWidgetState {
    // ログイン前
    @Serializable
    data object LoginRequired : SubscribingBlogWidgetState

    // 読み込み中
    @Serializable
    data object Loading : SubscribingBlogWidgetState

    // 読み込み完了後
    @Serializable
    data class Content(val blogs: List<SubscribingBlog>) : SubscribingBlogWidgetState
}

ウィジェットの状態の具象型ができたので、「購読中のブログ」ウィジェットの GlanceStateDefinition はこうなります。

// 「購読中のブログ」ウィジェットの GlanceStateDefinition
val SubscribingBlogWidgetStateDefinition = BlogAppWidgetStateDefinition(
    defaultValueFactory = {
        // ウィジェット追加直後の初期状態
        SubscribingBlogWidgetState.Loading
    },
    kSerializer = SubscribingBlogWidgetState.serializer(),
)

バックグラウンド更新

ウィジェットに表示される状態は定期的に更新したいです。そこで、非同期的にデータを更新するために WorkManager の CoroutineWorker を使用しています。

「購読中のブログ」ウィジェットの CoroutineWorker 実装は次のようなものです。 GlanceId ごと (ホーム画面に置かれたウィジェットのインスタンスごと) に、並行してデータを取得し状態を更新します。

@HiltWorker
class SubscribingBlogWidgetWorker @AssistedInject constructor(
    @Assisted appContext: Context,
    @Assisted params: WorkerParameters,
    private val blogRepository: BlogRepository,
) : CoroutineWorker(appContext, params) {
    override suspend fun doWork(): Result {
        val manager = GlanceAppWidgetManager(applicationContext)
        val widget = SubscribingBlogWidget()
        val glanceIds = manager.getGlanceIds(widget::class.java)

        if (glanceIds.isNotEmpty()) {
            coroutineScope {
                glanceIds.forEach { glanceId ->
                    launch {
                        val previousState = getAppWidgetState(
                            applicationContext,
                            SubscribingBlogWidgetStateDefinition,
                            glanceId,
                        )
                        // 購読中のブログを取得して SubscribingBlogWidgetState を生成
                        val nextState = next(previousState)
                        updateAppWidgetState(
                            applicationContext,
                            SubscribingBlogWidgetStateDefinition,
                            glanceId,
                        ) { nextState }
                        widget.update(applicationContext, glanceId)
                    }
                }
            }
        }
        return Result.success()
    }
}

なお、Hilt で CoroutineWorker に DI するにはコツがいります。@AssistedInjectandroidx.hilt:hilt-work@HiltWorker を使うことで、Repository などの標準外のパラメータを注入できます。androidx.hilt:hilt-compiler を忘れると注入に失敗するのでご注意ください。

ところで、Worker の実行をトリガーするスケジューラは、BlogAppWidgetWorkerScheduler インターフェイスに切り出しておき、どこからでも呼び出せるようにしました。

interface BlogAppWidgetWorkerScheduler {
    fun enqueuePeriodicWork()
    fun enqueueOneTimeWork()
    fun cancelAllWorks()
}

ウィジェットの設置後には enqueuePeriodicWork() を実行して、定期的な実行を予約します。

一方、アプリ内で Pull to Refresh してからホーム画面に戻ったとき、ウィジェットに古い情報が表示されないようにしたいです。そこで、アプリ内で対応する操作をしたときには enqueueOneTimeWork() を実行し、状態を最新化するようにしました。

そして、ウィジェットの撤去後には cancelAllWorks() を実行して、実行中および予約済みの Work をキャンセルします。

ちなみにスケジューラの実装はこういう感じです。Work が実行されて困るタイミングを避けるよう制約を追加しています。

@Singleton
class SubscribingBlogWidgetWorkerScheduler @Inject constructor(
    @ApplicationContext private val context: Context,
) : BlogAppWidgetWorkerScheduler {
    private companion object {
        private const val PERIODIC_WORKER_NAME = "SubscribingBlogWidgetPeriodicWorker"
        private const val ONE_TIME_WORKER_NAME = "SubscribingBlogWidgetOneTimeWorker"
        private val interval = 1.hours.toJavaDuration()
    }

    private val workerConstraints: Constraints
        get() = Constraints.Builder()
            // インターネット接続がある かつ バッテリー残量に余裕があるときに起動する
            .setRequiredNetworkType(NetworkType.CONNECTED)
            .setRequiresBatteryNotLow(true)
            .build()

    override fun enqueuePeriodicWork() {
        val request = PeriodicWorkRequestBuilder<SubscribingBlogWidgetWorker>(interval)
            .setConstraints(workerConstraints)
            .build()

        WorkManager.getInstance(context)
            .enqueueUniquePeriodicWork(
                uniqueWorkName = PERIODIC_WORKER_NAME,
                existingPeriodicWorkPolicy = ExistingPeriodicWorkPolicy.CANCEL_AND_REENQUEUE,
                request = request,
            )
    }

    override fun enqueueOneTimeWork() {
        val request = OneTimeWorkRequestBuilder<SubscribingBlogWidgetWorker>()
            .setConstraints(workerConstraints)
            .build()

        WorkManager.getInstance(context)
            .enqueueUniqueWork(
                uniqueWorkName = ONE_TIME_WORKER_NAME,
                existingWorkPolicy = ExistingWorkPolicy.KEEP,
                request = request,
            )
    }

    override fun cancelAllWorks() {
        WorkManager.getInstance(context).cancelUniqueWork(PERIODIC_WORKER_NAME)
        WorkManager.getInstance(context).cancelUniqueWork(ONE_TIME_WORKER_NAME)
    }
}

レスポンシブ レイアウト

ウィジェットはユーザーが自由にリサイズできるので、サイズに応じてレイアウトを出し分けたいです。Glance では SizeMode.Responsive() でブレークポイントを指定します。

class SubscribingBlogWidget : GlanceAppWidget() {
    sealed interface Layout {
        val size: DpSize

        // 小さいとき: 1件だけ表示
        data object SingleView : Layout {
            override val size = DpSize(width = 150.dp, height = 90.dp)
        }

        // リスト表示 (横に広い / 縦に長い の2パターン)
        sealed interface ListView : Layout {
            data object Wider : ListView {
                override val size = DpSize(width = 240.dp, height = 90.dp)
            }
            data object Higher : ListView {
                override val size = DpSize(width = 150.dp, height = 270.dp)
            }
        }
    }

    override val sizeMode = SizeMode.Responsive(
        sizes = setOf(
            Layout.SingleView.size,
            Layout.ListView.Wider.size,
            Layout.ListView.Higher.size,
        ),
    )

    override val stateDefinition get() = SubscribingBlogWidgetStateDefinition

    override suspend fun provideGlance(context: Context, id: GlanceId) {
        provideContent {
            // ...
        }
    }
}

ブレークポイントの値は Google ニュース のウィジェットを参考にしました。広く使われているウィジェットに合わせておくのが適切だろうと判断しました。

「購読中のブログ」ウィジェットの1件表示とリスト表示

「お知らせ」ウィジェットのアイコン表示の切り替わり

Typography

Glance には GlanceTheme が提供されています。しかし、Compose の MaterialTheme と異なり Typography がないため、必要な TextStyle を自前で用意しています。

object BlogGlanceTextStyle {
    val Overline
        @Composable
        get() = TextDefaults.defaultTextStyle.copy(
            color = GlanceTheme.colors.onSurfaceVariant,
            fontSize = 12.sp,
            fontWeight = FontWeight.Medium,
        )

    val Headline
        @Composable
        get() = TextDefaults.defaultTextStyle.copy(
            color = GlanceTheme.colors.onSurface,
            fontSize = 14.sp,
        )

    val Supporting
        @Composable
        get() = TextDefaults.defaultTextStyle.copy(
            color = GlanceTheme.colors.onSurfaceVariant,
            fontSize = 12.sp,
        )
}

子要素数の制約

3種類のウィジェットの中で最も作り込んだのは、「アクセス解析」ウィジェットです。

「アクセス解析」ウィジェットはブログの PV (ページビュー) の推移を棒グラフで表示します。Bitmap を使わず、Glance の Composable だけでグラフを組み上げています。

BoxcornerRadius modifier を付与したものを縦棒とし、直近2週間の毎日のアクセス数を相対的に比較できるようにしています。一見すると縦棒を並べるだけのように見えますが、ここにも乗り越えるべき RemoteViews の制約がありました。

工夫が必要だったのは、Glance の Row に含められる子要素は10個まで*2という制約がある点です。直近2週間分の縦棒は14個あるため、そのまま並べるとこの制約に違反します。

そこで、内部的には Box を10個ずつのチャンクに分割し、absolutePadding で水平方向にオフセットすることでうまく回避しています。Glance のプリミティブだけでグラフを描くにはこうしたワークアラウンドが必要でした。

Configuration Activity

また、「アクセス解析」ウィジェットには Configuration Activity があり、表示するブログを選べるようになっています。設置時や長押しでの設定変更時に Activity が起動します。

@AndroidEntryPoint
class AccessLogWidgetConfigurationActivity : ComponentActivity() {
    @Inject lateinit var workerScheduler: AccessLogWidgetWorkerScheduler

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        val appWidgetId = getAppWidgetId()
        if (appWidgetId == AppWidgetManager.INVALID_APPWIDGET_ID) {
            finish()
            return
        }

        // 意図せず Activity が終了した場合にウィジェットの追加をキャンセルするため、あらかじめ RESULT_CANCELED を設定しておく
        val result = Intent().putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId)
        setResult(RESULT_CANCELED, result)

        setContent {
            MaterialTheme {
                AccessLogWidgetConfigurationScreen(
                    initialSelectedBlogId = getCurrentSelectedBlogId(appWidgetId),
                    onConfirm = { blogId ->
                        updateGlanceState(appWidgetId, blogId)
                        workerScheduler.enqueueOneTimeWork()
                        setResult(RESULT_OK, result)
                        finish()
                    },
                    onDismiss = { finish() },
                )
            }
        }
    }
}

設定値はウィジェットのインスタンスごとに保存されるので、複数のブログのグラフを同時にホーム画面に並べられます。

ところで、android:widgetFeatures にはいくつか任意の属性があります。reconfigurable は設置後にも設定画面を開き直せること、configuration_optional は設置時の設定画面をスキップできることをそれぞれ表明できます。

<?xml version="1.0" encoding="utf-8"?>
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
    ...
    android:widgetFeatures="reconfigurable|configuration_optional" />

おわりに

はてなブログ Android アプリのウィジェットは、こうして Jetpack Glance で作られています。

Jetpack Compose に慣れていれば Jetpack Glance の導入は容易です。 宣言的 UI や sealed interface による状態モデリング、WorkManager によるバックグラウンド処理など、パターンが固まってからは、共通基盤に乗せるだけで結構楽ができました。

とはいえ、Compose との差異や RemoteViews の抽象化レイヤーに起因する制約などに向き合う必要があります。Glance を採用する際は、リファレンスだけでなく RemoteViews 側の仕様も把握しておくと、設計段階でどこまで作り込むかの判断がしやすくなるはずです。

はてなブログ Android アプリのウィジェットをぜひお試しください!

play.google.com

さて Recomposing Hatena Blog for Android シリーズは今回が最終回です。連載では、リニューアルに携わったメンバーが開発体験から個々の機能の技術詳細まで、思い入れのある箇所を紹介してきました。

数ヶ月にわたりお付き合いいただき、ありがとうございました。これからもアプリの改善を続けてまいります!

*1:執筆時点では Jetpack Glance のバックエンドは RemoteViews のみです。Google I/O 2026 では Android 17 からウィジェットが単一の Compose ベースの開発モデルへ移行すると発表されており、新しい RemoteCompose バックエンド の実装が Glance 1.3.0 系のアルファ版で進められています。

*2:Row の API リファレンス に「Note for App Widgets: Row supports up to 10 child elements. Any additional elements will be truncated from the output.」と明記されています。