こんにちは。 モバイルエンジニアの臼井(@usuiat)です。
私はサイボウズでGaroonモバイルのAndroidアプリの開発をしています。 Garoonモバイルは2024年10月に公開したv1.11でウィジェットに対応し、ユーザーはホーム画面で自分の今日の予定を確認できるようになりました。 (この記事はAndroid版の開発について紹介していますが、iOS版も同じタイミングでウィジェットに対応しました。)
この記事では、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
が再実行されて、ウィジェットの表示が更新されます。
例えばActivity
のonPause
に次のようなコードを書くと、アプリがバックグラウンドに移行するタイミングでウィジェットを更新できます。
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
に値が流れて、再コンポーズが実行されて表示が更新される
- DataStoreの値が新しくなることによって
- コンポーザブル関数が停止中(最後の表示更新から45秒以上経過)
updateAll
の呼び出しによってprovideGlance
が再実行されて、表示が更新される
これでコンポーザブル関数が動作中かどうかにかかわらずウィジェットの表示が更新されます。
おわりに
この記事では、Garoonモバイルのウィジェット開発で私たちが苦労した「45秒問題」とその解決方法を紹介しました。 実は私たちがぶつかった45秒問題はもう一つあるのですが、そちらはまた別の機会に紹介できればと思います。
Glance関連のノウハウはまだ広く知れ渡っていないものが多い気がします。 この記事が、これからウィジェットの開発を始める人の参考になれば幸いです。