【Android】Glanceでウィジェット開発〜非同期処理編〜


本記事は以下の続編です

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


-----------

# ウィジェットで非同期処理を扱う

今回はユーザーがタップすると非同期的に画面を更新するウィジェットを作成します。

![async_glance.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/440643/10a1d37c-6b2e-9c43-e10c-104733d214c6.gif)



作成したアプリ・ウィジェットのソースコードはGitHubで公開しています

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

## Glanceの非同期的な更新処理


ウィジェットを非同期的に更新する関数が以下のように用意されているとします。（GlanceにおけるUIの作成方法・状態管理の実装は前編で説明済みのため割愛します）

```kotlin:CounterWidget.kt

class CounterWidget : GlanceAppWidget() {
    override suspend fun provideGlance(context: Context, id: GlanceId) {
        provideContent {
            val pref: Preferences = currentState()
            val count = pref[PREF_KEY_COUNT] ?: 0
            val isLoading = pref[PREF_KEY_IS_LOADING] ?: false
            WidgetTheme {
                CounterScreen(
                    isLoading = isLoading,
                    count = count,
                    // どうやってコルーチン起動する？ CoroutineScopeはどこ？
                    onIncrement = { /* TODO */ },
                    onDecrement = { /* TODO */ },
                )
            }
        }
    }

    suspend fun increment(context: Context, glanceId: GlanceId) {
        val pref: Preferences = getAppWidgetState(context, glanceId)
        val isLoading = pref[PREF_KEY_IS_LOADING] ?: false
        val current = pref[PREF_KEY_COUNT] ?: 0
        if (isLoading) return

        // ローディング中にUI更新
        updateAppWidgetState(context, glanceId) {
            it[PREF_KEY_IS_LOADING] = true
        }
        update(context, glanceId)

        // 非同期処理
        val next = min(current + 1, 99)
        delay(1000L)

        // 結果をUI更新
        updateAppWidgetState(context, glanceId) {
            it[PREF_KEY_COUNT] = next
            it[PREF_KEY_IS_LOADING] = false
        }
        update(context, glanceId)
    }

    suspend fun decrement(context: Context, glanceId: GlanceId) {
       // 省略
    }

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

# Workerで非同期処理


[状態管理編で少しだけ触れましたが、](https://qiita.com/Seo-4d696b75/items/235967ed0c4332683f6e#%E3%82%A6%E3%82%A3%E3%82%B8%E3%82%A7%E3%83%83%E3%83%88%E3%81%AE%E6%9B%B4%E6%96%B0%E5%87%A6%E7%90%86)ウィジェットを定期的に更新する `BroadcastReceiver.onReceiver()` では長時間のスレッドブロックはNGです。また普段の Jetpack Compose + ViewModel のように `viewModelScope`も使えないので、時間を要する処理は `Service`や`Worker`の利用が必要です。本記事では One-Shot な非同期処理を手軽に扱える`Worker`を採用します。

https://developer.android.com/topic/libraries/architecture/workmanager?hl=ja

## セットアップ

```gradle:build.gradle
dependencies {
   implementation "androidx.work:work-runtime-ktx:2.9.1"
}
```

## Workerの実装

便利なことに suspend 関数を実行できる `CoroutineWorker`が用意されているので、コルーチンで非同期処理をそのまま書けます。

https://developer.android.com/topic/libraries/architecture/workmanager/advanced/coroutineworker?hl=ja

`Intent`に引数を詰め込んで他の`Activity`や`Service`を起動するケースと似ていますが、

- 処理の成功・失敗に応じて`doWork()`は`ListenableWorker.Result`型を返す
- 引数は`WorkerParameters`型で渡される

とお作法が異なるので注意。


```kotlin:CountWorker.kt
class CountWorker constructor(
    private val context: Context,
    private val params: WorkerParameters,
) : CoroutineWorker(context, params) {
    override suspend fun doWork() = withContext(Dispatchers.IO) {
        try {
            val widget = CounterWidget()
            val appWidgetIds =
                params.inputData.getIntArray(KEY_APP_WIDGET_IDS) ?: throw IllegalArgumentException()
            val operation = when (params.inputData.getString(KEY_OPERATION)) {
                OPERATION_INCREMENT -> widget::increment
                OPERATION_DECREMENT -> widget::decrement
                else -> throw IllegalArgumentException()
            }
            val glanceAppWidgetManager = GlanceAppWidgetManager(context)

            appWidgetIds
                .map { appWidgetId -> glanceAppWidgetManager.getGlanceIdBy(appWidgetId) }
                .map { glanceId -> launch { operation(context, glanceId) } }
                .joinAll()

            Result.success()
        } catch (t: Throwable) {
            Result.failure()
        }
    }

    companion object {
        const val KEY_APP_WIDGET_IDS = "key_app_widget_ids"
        const val KEY_OPERATION = "key_operation"
        const val OPERATION_INCREMENT = "operation_increment"
        const val OPERATION_DECREMENT = "operation_decrement"
    }
}
```

:::note info
`GlanceId`のシリアライズ

複数のウィジェットを識別するため Glance では `GlanceId`という型を利用しますが、型定義の中身は空っぽなマーカーインターフェイスです。そのままではシリアライズできないため、`GlanceAppWidgetManager`で`Int`型に変換して引数に渡しています。

:::

## Workerの起動

`Intent`を投げて起動する`Activity`や`Service`とは異なり、`WorkManager`にリクエストを追加することで起動します。今回はユーザーイベントに対して即座かつ１回限りで実行されるため、`OneTimeWorkRequest`をリクエストする関数を外部に公開しておきます。

```kotlin:CountWorker.kt
    companion object {
        fun requestIncrement(
            context: Context,
            appWidgetIds: IntArray,
        ) {
            val request = OneTimeWorkRequestBuilder<CountWorker>()
                .setInputData(
                    workDataOf(
                        KEY_APP_WIDGET_IDS to appWidgetIds,
                        KEY_OPERATION to OPERATION_INCREMENT,
                    )
                )
                .build()
            WorkManager.getInstance(context).enqueue(request)
        }

        fun requestDecrement(
            context: Context,
            appWidgetIds: IntArray,
        ) {
            // 省略
        }
    }
```

## GlanceとWorkerの接続

用意したWorker起動用の関数はコルーチン不要で呼び出せます。

```diff:CounterWidget.kt
 class CounterWidget : GlanceAppWidget() {
    override suspend fun provideGlance(context: Context, id: GlanceId) {
+       // GlanceId を直接渡せないのでIntに変換
+       val appWidgetId = GlanceAppWidgetManager(context).getAppWidgetId(id)
        provideContent {
            val pref: Preferences = currentState()
            val state = pref[PREF_KEY_COUNT] ?: 0
            val isLoading = pref[PREF_KEY_IS_LOADING] ?: false
            WidgetTheme {
                CounterScreen(
                    isLoading = isLoading,
                    count = state,
-                   onIncrement = { /* TODO */ },
-                   onDecrement = { /* TODO */ },
+                   onIncrement = {
+                       CountWorker.requestIncrement(context, intArrayOf(appWidgetId))
+                   },
+                   onDecrement = {
+                       CountWorker.requestDecrement(context, intArrayOf(appWidgetId))
+                   },
                )
            }
        }
    }
```    

`BroadcastReceiver`経由の定期更新からも呼び出せます

```diff:CounterWidgetReceiver.kt
class CounterWidgetReceiver : GlanceAppWidgetReceiver() {

    override val glanceAppWidget: GlanceAppWidget
        get() = CounterWidget()

    override fun onUpdate(
        context: Context,
        appWidgetManager: AppWidgetManager,
        appWidgetIds: IntArray
    ) {
        super.onUpdate(context, appWidgetManager, appWidgetIds)
+       CountWorker.requestIncrement(context, appWidgetIds)
    }
}
```