【Android】Glanceでウィジェット開発〜状態管理編〜

本記事は以下の導入編の続きです

https://qiita.com/Seo-4d696b75/items/3cb024a71307e1d7d3c8


1. [導入編](https://qiita.com/Seo-4d696b75/items/3cb024a71307e1d7d3c8)
2. **状態管理編 ← HERE**
3. [非同期処理編](https://qiita.com/Seo-4d696b75/items/6c0d46b589d2b0b97868)

-----------



# Glanceウィジェットの状態管理

ウィジェットは通常のアプリとは異なる仕組みで描画・状態管理されるため、Jetpack Compose + ViewModelとはだいぶ要領が変わります。

https://developer.android.com/jetpack/compose/glance/glance-app-widget?hl=en

最初に Glance での状態管理の基本を確認してから以下２種類のケースを紹介します

- 定期的に更新する
- ユーザー操作をトリガーに更新する



本記事で作成するアプリ・ウィジェットの全ソースコードをGitHubで公開しています

https://github.com/Seo-4d696b75/glance-widget-demo/tree/count-stateful


## 状態の参照

Glance では簡単に [Preferences DataStore](https://developer.android.com/topic/libraries/architecture/datastore?hl=ja) を利用して状態を保持＆参照できます。

```kotlin:CounterWidget.kt
class CounterWidget : GlanceAppWidget() {
    override suspend fun provideGlance(context: Context, id: GlanceId) {
        provideContent {
            val pref: Preferences = currentState()
            val state = pref[PREF_KEY_COUNT] ?: 0
            WidgetTheme {
                CounterScreen(
                    count = state,
                )
            }
        }
    }

    companion object {
        private val PREF_KEY_COUNT = intPreferencesKey("pref_key_count")
    }
}
```

:::note warn
カスタム型の状態を扱う

`GlanceAppWidget.stateDefinition`をoverrideすることで `Preferences`以外の状態も扱えます。ただしウィジェットの状態を正しく保持するには永続化が必須ですので、デフォルトの`Preferences`型を使うのが無難です。

```kotlin:GlanceAppWidget.kt
    // デフォルトで GlanceStateDefinition<Preferences> が設定されており、
    // ディスクへの永続化処理が実装済み
    open val stateDefinition: GlanceStateDefinition<*>? = PreferencesGlanceStateDefinition

```
:::


## 状態の更新

状態を更新してウィジェットに反映する関数を用意します。

1. `updateAppWidgetState`で状態を更新
2. `update`を呼び出して描画させる

```kotlin:CounterWidget.kt
    suspend fun increment(context: Context, glanceId: GlanceId) {
        updateAppWidgetState(context, glanceId) {
            val current = it[PREF_KEY_COUNT] ?: 0
            val next = min(current + 1, 99)
            it[PREF_KEY_COUNT] = next
        }
        update(context, glanceId)
     }
```

# 定期的にウィジェットを更新する

時間経過で自動的に更新する方法です

| 追加直後 | 時間経過後 |
|----|----|
|<img width="300" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/440643/55d574cd-9763-46ad-767f-2167c0d6b465.png">|<img width="300" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/440643/5204d70d-14c6-bd76-6d4f-3236d70da755.png"> |



## 更新頻度の指定
AppWidgetProviderInfo メタデータの`updatePeriodMillis`に更新間隔をミリ秒単位で指定します。するとAndroidManifestで登録した`GlanceAppWidgetReceiver`の`onUpdate()` が定期的に呼び出されます。

```diff:res/xml/appwidget_info.xml
<?xml version="1.0" encoding="utf-8"?>
  <appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
    android:description="@string/widget_description"
    android:initialLayout="@layout/glance_default_loading_layout"
    android:minWidth="110dp"
    android:minHeight="40dp"
    android:previewImage="@null"
    android:resizeMode="none"
-   android:updatePeriodMillis="0"
+   android:updatePeriodMillis="1800000"
    android:widgetCategory="home_screen" />
```


:::note info
最小の更新間隔

[APIリファレンスによれば、](https://developer.android.com/reference/android/appwidget/AppWidgetProviderInfo#updatePeriodMillis
)30分に1回を超える頻度を指定しても`onUpdate`が呼ばれない場合があります。


> Note: Updates requested with updatePeriodMillis will not be delivered more than once every 30 minutes.

:::


## ウィジェットの更新処理


実際の定期更新では`GlanceAppWidgetReceiver.onUpdate()`が呼ばれるので、あらかじめ用意した状態更新＆再描画のメソッドを呼び出します。

```kotlin
class CounterWidgetReceiver : GlanceAppWidgetReceiver() {

    // 適当なコルーチン起動方法（仮実装）
    private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)

    override val glanceAppWidget: GlanceAppWidget = CounterWidget()

    override fun onUpdate(
        context: Context,
        appWidgetManager: AppWidgetManager,
        appWidgetIds: IntArray
    ) {
        super.onUpdate(context, appWidgetManager, appWidgetIds)
        coroutineScope.launch {
            val manager = GlanceAppWidgetManager(context)
            appWidgetIds.map { appWidgetId ->
                val glanceId = manager.getGlanceIdBy(appWidgetId)
                launch {
                    counterWidget.increment(context, glanceId)
                }
            }.joinAll()
        }
    }
}
```


:::note info
`GlanceId`

複数のウィジェットを識別するため通常は`Int`型のidを利用します。ただし Glance では `GlanceId`という別の型を利用するため`GlanceAppWidgetManager`による変換が必要です。

:::


:::note warn
安全なコルーチンの起動

`onUpdate`は`BroadcastReceiver.onReceive`から呼び出されます
- `BroadcastReceiver`の生存期間は原則`onReceive`の呼び出し中のみ
- `onReceive`はMainスレッドで処理される

したがって`onUpdate`での長時間のブロックはNGです。サンプルコードではコルーチンを起動してMainセーフですが、`BroadcastReceiver`のライフサイクルを無視している点に注意してください。

一般的な対応方法として `goAsync()`が挙げられますが、`super.onUpdate()`の内部実装で既に呼び出しているため2度目以降はクラッシュします... 安全を期すなら`Service`や`Worker`に処理を投げるのが無難です。

[参考： Android developer ブロードキャストの概要](https://developer.android.com/guide/components/broadcasts#effects-process-state)
:::

# ユーザー操作でウィジェットを更新する

次にタップで操作可能なカウンターウィジェットにします

![tap_glance.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/440643/dfdd4153-a89b-fb88-fe2d-4404d9534d8f.gif)


## コールバックの登録

https://developer.android.com/jetpack/compose/glance/user-interaction?hl=en

Jetpack Compose 同様にクリックイベントは `GlanceModifier`で簡単に指定できます。あとはイベントをコールバック関数で受取り、状態の更新＆再描画を走らせるだけです。

```kotlin
@OptIn(ExperimentalGlanceApi::class)
@Composable
fun ExampleIconButton(
    onIncrement: () -> Unit,
    modifier: GlanceModifier = GlanceModifier,
) {
    Image(
        provider = ImageProvider(R.drawable.ic_arrow_up),
        contentDescription = glanceString(R.string.widget_increment_label),
        colorFilter = ColorFilter.tint(GlanceTheme.colors.onPrimary),
        modifier = modifier.clickable(
            key = "increment",
            block = onIncrement,
        ),
    )
}    
```