Garoonモバイルでウィジェット開発中に直面したGlanceの45秒問題

こんにちは。 モバイルエンジニアの臼井(@usuiat)です。

私はサイボウズでGaroonモバイルのAndroidアプリの開発をしています。 Garoonモバイルは2024年10月に公開したv1.11でウィジェットに対応し、ユーザーはホーム画面で自分の今日の予定を確認できるようになりました。 (この記事はAndroid版の開発について紹介していますが、iOS版も同じタイミングでウィジェットに対応しました。)

Garoonモバイルのウィジェット
Garoonモバイルのウィジェット

この記事では、Garoonモバイルチームが初めてウィジェットを開発する際に直面した「45秒問題」と解決方法を紹介します。

Glanceによるウィジェット開発

今回のウィジェット開発では、Composeのウィジェット版であるGlanceを採用することにしました。 Garoonモバイルは全ての画面をComposeで開発しており、日々の開発でComposeの快適さを実感しています。 そのためウィジェットの開発にもComposeを利用するというのは自然な選択でした。

Glanceを利用したウィジェットの作成方法は、「Glance でアプリ ウィジェットを作成する - developer.android.com」に簡潔にまとまっています。 UIを作るにはGlanceAppWidgetを継承したクラスを作成し、provideGlanceをオーバーライドしてprovideContentを呼び出します。 provideContentのラムダはコンポーザブル関数なので、ここにComposeのコードを記述できます。

更新時刻表示の例

Garoonモバイルのウィジェットには、ウィジェットの更新時刻を表示しています。 もっともシンプルに更新時刻を表示するコードは以下のようになります。 provideGlanceで現在時刻を取得し、provideContentで表示しています。

class SampleWidget() : GlanceAppWidget() {
    override suspend fun provideGlance(context: Context, id: GlanceId) {
        val updateTime = Clock.System.now().toString()
        provideContent {
            Column(
                modifier = GlanceModifier.fillMaxSize().background(Color.White),
                horizontalAlignment = Alignment.CenterHorizontally
            ) {
                Text("Update time: $updateTime")
            }
        }
    }
}

更新時刻を表示するウィジェット
更新時刻を表示するウィジェット

ウィジェットの更新要件

Garoonモバイルで新しく作成したウィジェットは、今日この後の自分の予定をリスト形式で確認できるウィジェットです。 このウィジェットの要件として、以下のタイミングで表示を更新する必要がありました。

  • (要件A)定期更新
    • バックグラウンドで定期的に表示を更新する
  • (要件B)アプリから更新
    • アプリ内で予定を変更した時にウィジェットにも反映させるため、アプリからウィジェットを更新する

実際にはもう一つ、ウィジェットに配置した更新ボタンを押した時にも表示を更新する要件があるのですが、この記事では扱わないので割愛します。

定期更新

要件Aの定期更新は、特に追加の実装は必要ありません。 メタデータに定義したupdatePeriodMillisの値に従い、定期的にprovideGlanceが呼ばれてウィジェットが更新されます。 (ただし、updatePeriodMillisの値はあくまで目安で、デバイスの状態によって時間が延びます。)

アプリから更新

要件Bのアプリ内からのウィジェット更新は、GlanceAppWidget.updateAllを利用します。 updateAllを呼び出すとprovideGlanceが再実行されて、ウィジェットの表示が更新されます。 例えばActivityonPauseに次のようなコードを書くと、アプリがバックグラウンドに移行するタイミングでウィジェットを更新できます。

override fun onPause() {
    super.onPause()
    lifecycleScope.launch {
        SampleWidget().updateAll(applicationContext)
    }
}

45秒問題

最低限の実装はこれでできたことになります。 しかし、ここで私たちが「45秒問題」と呼んでいたGlanceの仕様による動作の問題が発覚しました。

ウィジェット更新から45秒後に動作が変わる

provideContentに実装したコンポーザブル関数は、45秒間動作を続けます。 この間は通常のコンポーザブル関数と同様にStateの変化をトリガーとして再コンポーズが動きます。 再コンポーズが実行されると、そこからさらに45秒間は動作を続けます。

そして問題となるのが、コンポーザブル関数が動作している間はupdateAllが動作しないことです。 つまり、ウィジェットの表示を更新してから45秒以内にupdateAllを呼び出しても表示は更新されません。

DataStoreを利用した解決方法

そこで、コンポーザブル関数が動作している間は再コンポーズにより表示を更新し、コンポーザブル関数が動作を停止している場合はprovideGlanceが再実行されることで表示が更新されるように実装を変更します。

private val Context.dataStore by preferencesDataStore("sampleWidgetDataStore")
private val updateTimeKey = stringPreferencesKey("updateTime")

class SampleWidget() : GlanceAppWidget() {
    override suspend fun provideGlance(context: Context, id: GlanceId) {
        val store = context.dataStore
        store.edit { it[updateTimeKey] = Clock.System.now().toString() }
        val first = store.data.first()

        provideContent {
            val data by store.data.collectAsState(first)
            val updateTime = data[updateTimeKey] ?: ""
            Column(...) {
                Text("Update time: $updateTime")
            }
        }
    }

    suspend fun updateNow(context: Context) {
        context.dataStore.edit { it[updateTimeKey] = Clock.System.now().toString() }
        updateAll(context)
    }
}
override fun onPause() {
    super.onPause()
    lifecycleScope.launch {
        SampleWidget().updateNow(applicationContext)
    }
}

このコードではprovideGlanceで取得した時刻をそのまま表示せずに、一旦DataStoreに保存しています。 provideContentではDataStoreからStateを取得して時刻を取り出して表示しています。 またupdateNowという関数を定義し、DataStoreの時刻を更新するとともにupdateAllを呼び出しています。

コンポーザブル関数の動作状態と表示更新処理の関係は以下のようになります。

  • コンポーザブル関数が動作中(最後の表示更新から45秒以内)
    • DataStoreの値が新しくなることによってcollectAsStateに値が流れて、再コンポーズが実行されて表示が更新される
  • コンポーザブル関数が停止中(最後の表示更新から45秒以上経過)
    • updateAllの呼び出しによってprovideGlanceが再実行されて、表示が更新される

これでコンポーザブル関数が動作中かどうかにかかわらずウィジェットの表示が更新されます。

おわりに

この記事では、Garoonモバイルのウィジェット開発で私たちが苦労した「45秒問題」とその解決方法を紹介しました。 実は私たちがぶつかった45秒問題はもう一つあるのですが、そちらはまた別の機会に紹介できればと思います。

Glance関連のノウハウはまだ広く知れ渡っていないものが多い気がします。 この記事が、これからウィジェットの開発を始める人の参考になれば幸いです。