inSmartBank

B/43を運営する株式会社スマートバンクのメンバーによるブログです

B/43の残高ウィジェットをJetpack Glanceで作った話

こんにちは。スマートバンクで iOS / Android エンジニアをしている nakamuuu です。

先月公開したAndroid版B/43の最新バージョンでは、カードの残高を表示するウィジェットをご利用いただけるようになりました。アプリを最新のバージョンに更新の上、ぜひご活用ください!

この残高ウィジェットは Jetpack Glance を活用し、Android 12 以降で推奨される Dynamic color への対応などの構成にできる限り倣う形で実装を進めました。

developer.android.com

このエントリーではその Jetpack Glance でのモダンなウィジェット開発において得られた知見を、以下の目次に記したトピックに沿って紹介していきます。

Jetpack Glance

Jetpack Glance は Jetpack Compose ライクな宣言的なレイアウトの記述により、アプリウィジェットなどを構築できるツールキット群です。

記述したレイアウトは RemoteViews のViewツリーへ変換されるため、 Jetpack Compose 相当のすべての機能を利用できる訳ではありませんが、旧来の RemoteViews を直接用いる実装方法と比べると簡潔に実装を行えます。レイアウトXMLの記述のほか、RemoteViews 独自の View 操作用のインターフェースへの理解も不要となるのは嬉しいポイントです。

developer.android.com

また、レイアウトに限らず、データの更新フローに対する新たなアプローチが提供される(後述)など、Jetpack Glance にはウィジェットの実装における全般的な改善も含まれています。

Dynamic color への対応

B/43の残高ウィジェットは “Dynamic color” に対応しており、端末の壁紙やテーマに合わせて配色が変化します。Dynamic color を含む Material 3 のカラーシステムの前提については、以下の @_rockname さんのエントリーもぜひご参照ください。

blog.smartbank.co.jp

Jetpack Glance を用いたウィジェットの Dynamic color への対応は、 GlanceTheme を用いることで比較的簡単に行えます。 GlanceTheme の引数に独自のカラースキームを指定しなければ Dynamic color に倣ったスキームが適用されるため、 GlanceTheme.colors.primary などのプロパティからトーンにあった配色を使用することができます。

developer.android.com

class MyWidget : GlanceAppWidget() {
  override suspend fun provideGlance(context: Context, id: GlanceId) {
    provideContent {
      GlanceTheme { Content() }
    }
  }
  
  @Composable
  private fun Content() {
    Box(
      modifier = GlanceModifier.fillMaxSize()
        .background(GlanceTheme.colors.background)
      contentAlignment = Alignment.Center
    ) {
      Text(
        text = "Text",
        style = TextStyle(
          color = GlanceTheme.colors.primary,
          fontSize = 24.sp
        )
      )
    }
  }
}

Jetpack Glance 1.0.0 時点で、 GlanceTheme (ColorProviders) では Material 3 の カラースキームに倣う25色のプロパティが提供されています。Google製のアプリなどのウィジェットの配色を観察しつつ、GlanceTheme のプロパティから適切な配色を割り当てていくのが良いでしょう。

参考として、以下に B/43 の残高ウィジェットにおける配色を示します。(※ ウィジェットの背景色については後述)

💭 Off Topic : B/43 と Material 3

9月に開催された DroidKaigi 2023 では Android版 B/43 の開発に携わっていただいている @yanzm さんから「Material 3 やめました」のタイトルで発表がありました。

この発表では B/43 での事例を元に、 Material 3 の Color をはじめとするデザイントークンを利用せず、サービス独自のデザインシステムの中で Material 3 のコンポーネント群のみを活用する手法について述べられています。

speakerdeck.com

B/43のアプリ内においては Material 3 のコンポーネント群のみの利用に留まっていますが、一方でウィジェットでは以下の理由で Dynamic color への準拠が望ましいと考えています。

  • ホーム画面上ではデバイスを通したデザインの一貫性を担保する意義が高い
    • 独自のスタイルでウィジェットを提供した場合、Themed App Icon や Dynamic color に対応済みのウィジェットと並んだ際に違和感を覚えさせる
    • 各アプリでの Themed App Icon の対応が(筆者の観測範囲において)進んでいることから、ウィジェットに関しても Dynamic color への対応が一定進むことが期待できる
  • 実装面においてもアプリ本体へ影響しない形で Dynamic color への対応を進められる

ウィジェットの背景

Dynamic color に対応したウィジェットの実装を進めていく中で、大きく悩まされたポイントが “ウィジェットの背景色” です。Google製のアプリなどでは Material 3 における “Secodary Container” の配色よりも彩度の少し低い色が使われています。

該当する配色の定義を探しました *1 が、結論として Material Components 1.10.0 / Jetpack Glance 1.0.0 時点では MaterialTheme / GlanceTheme にこの配色に相当するプロパティは存在していません。

Issue Tracker に報告したところ “適切な配色を統合するための作業が進行中” と返信を得ています。対応が完了するまでは、以下のように背景色用の独自のカラーリソースを定義することで適切な配色に合わせることができます。

<!-- res/values-v31/colors.xml -->
<color name="workaround_bg">@android:color/system_accent2_50</color>

<!-- res/values-night-v31/colors.xml -->
<color name="workaround_bg">@android:color/system_accent2_800</color>
GlanceModifier.background(R.color.workaround_bg)

また、Android 12 以降で推奨される構成に倣うため、ウィジェットの背景においては以下の2点も意識すべきポイントでしょう。

  • appWidgetBackground Modifierを使用し、ランチャーからのアプリ起動時のアニメーションがスムーズに行われるようにする
  • cornerRadius Modifierを使用し、ウィジェットの角丸を端末で推奨される半径にする
Box(
  modifier = GlanceModifier.background(R.color.workaround_bg)
    .appWidgetBackground()
    // `cornerRadius` のModifier は Android 12 以上でのみ動作する
    // Android 12 未満で同等の見栄えとするには背景用の Drawable リソースの作成などが必要
    .cornerRadius(android.R.dimen.system_app_widget_background_radius)
) { ... }

レスポンシブなレイアウト

Android 12 以降ではウィジェットが配置されたサイズに応じて、柔軟にコンテンツを出し分けることが可能です。しかし、旧来の RemoteViews を直接用いる方法では、表示パターンごとにレイアウトXMLを用意するなど煩雑な実装が必要となっていました。

developer.android.com

Jetpack Glance ではレイアウトの分岐点の宣言を含む SizeMode.Responsive を設定することでレスポンシブなウィジェットを実装できます。XMLのようにレイアウトの記述が分断することがないので、宣言的UIにおけるViewの再利用性のメリットも享受しやすいことでしょう。

class MyWidget : GlanceAppWidget() {
  override val sizeMode: SizeMode = SizeMode.Responsive(
    setOf(
      DpSize(0.dp, 0.dp),
      DpSize(160.dp, 0.dp),
      DpSize(0.dp, 160.dp),
      DpSize(160.dp, 160.dp),
    )
  )

  override suspend fun provideGlance(context: Context, id: GlanceId) {
    provideContent {
      val size = LocalSize.current
      ...
    }
  }
}

B/43の残高ウィジェットでは以下の図に示す5パターンのレイアウトを定義しています。それぞれ残高のフォントサイズや更新日時の表示有無などに差がありますが、レイアウトのパーツを極力再利用できる構成にしています。

また、 “Large” のパターンでは入金・出金などのアプリ内の機能への導線を右端に表示していますが、導線の表示有無以外は “Medium” と全く同じ構成であり、レイアウトの実装も使い回しています。

ウィジェットが実際に配置されるサイズは端末の画面サイズやランチャーアプリなどによりさまざまです。スマートフォンの横向きの画面では極端に横長なレイアウトで表示されることもあり、この状態でレイアウトが破綻するサードパーティ製のウィジェットもいくつか目にしています。

Figmaなどのデザインツール上でのシミュレーションや Glance Experimental Tools (AppWidget Preview) を用いた動作確認によって、品質を担保できるとよいでしょう。

複雑なレイアウト上の制約を正確にデザイナーさんに伝えることも難しいため、B/43では実装者がレイアウトの分岐点を含むデザインの草案をFigma上で作成し、コミュニケーションを適宜取りつつデザインを定めていきました。

データの更新フロー

ウィジェットの外観に関する話から少し離れ、表示するデータの更新フローについても触れておこうと思います。

Jetpack Glanceでは配置したウィジェットがそれぞれ Preferences DataStore の永続ストレージと紐づき、状態を保持することができます。この振る舞いは PreferencesGlanceStateDefinition 以外の GlanceStateDefinition を用いることでカスタマイズすることも可能です。

B/43の残高ウィジェットではウィジェット単位の永続ストレージに以下のような情報を保存しています。

/**
 * ① ウィジェットの配置時に構成するデータ
 *
 * 残高を表示するカードの識別子と種類の区別(マイ / ペア / ジュニア)を含む。
 */
data class BalanceWidgetConfiguration(
  val accountId: AccountId,
  val accountType: AccountType
)
/**
 * ② ウィジェットの配置後も更新されうる動的なデータ
 *
 * カードの残高とウィジェットの更新日時を含む。
 */
data class BalanceWidgetData(
  val balance: Int,
  val updatedDate: Date
)

データの更新については “WorkManagerを用いた定期更新” “アプリ内で取得した残高の反映” の2つのフローが実装されています。定期更新ではAPIリクエストと Preferences の保存処理を担う簡単な Worker を用意し、 GlanceAppWidgetReceiver の onEnabled で定期的な実行をスケジュールしています。

@HiltWorker
class BalanceWidgetWorker constructor(
  @Assisted private val context: Context,
  @Assisted workParams: WorkerParameters,
  apiClient: ApiClient,
) : CoroutineWorker(context, workParams) {
  override suspend fun doWork(): Result {
    // すべてのカードの残高を含む情報をAPIから取得し、ウィジェットに反映する
    val accounts = apiClient.getAccounts()
    accounts.forEach { account -> update(account.id, account.balance) }
    return Result.success()
  }

  private fun update(accountId: AccountId, balance: Int) {
    // ホーム画面上に配置されているすべてのウィジェットを探索する
    GlanceAppWidgetManager(context)
      .getGlanceIds(BalanceWidget::class.java)
      .forEach { glanceId ->
        updateAppWidgetState(context, glanceId) { preferences ->
          // 該当するカードが選択されていれば残高と更新日時を更新する
          // `Preferences#configuration` / `data` は上述のオブジェクトの取得・保存処理を集約した拡張プロパティ
          if (account.id == preferences.configuration.accountId) {
            preferences.data = BalanceWidgetData(
              balance = account.balance, 
              updatedAt = Date()
            )
          }
        }
        BalanceWidget().update(context, glanceId)
      }
  }
}
class BalanceWidgetReceiver : GlanceAppWidgetReceiver() {
  override val glanceAppWidget: GlanceAppWidget = BalanceWidget()

  override fun onEnabled(context: Context) {
    super.onEnabled(context)

    WorkManager.getInstance(context)
      .enqueueUniquePeriodicWork(
        "...", ExistingPeriodicWorkPolicy.UPDATE,
        PeriodicWorkRequestBuilder<BalanceWidgetWorker>(30, TimeUnit.MINUTES).build()
      )
  }
}
class BalanceWidget: GlanceAppWidget() {
  override suspend fun provideGlance(context: Context, id: GlanceId) {
    provideContent {
      val configuration = currentState<Preferences>().configuration
      val data = currentState<Preferences>().data
      // TODO: 保存されたデータを用いてレイアウトを表示
    }
  }
}

“アプリ内で取得した残高の反映” も含めたB/43の残高ウィジェットの更新フローの概観は以下の図のとおりです。

📝 GlanceAppWidget 内に更新ロジックを記述する方法

Jetpack Glance では 1.0.0-beta01 以降、 GlanceAppWidget#provideGlance の suspend 関数内にAPIリクエストなどの時間のかかる更新ロジックを記述できるようになりました。ウィジェット単位で独立したシンプルな更新ロジックを持つ場合、構成用のXMLの android:updatePeriodMillis を適切に設定することで定期更新用の実装も GlanceAppWidget 内に集約できます。

<!-- res/xml/balance_widget.xml -->
<appwidget-provider
  ...
  android:updatePeriodMillis="1800000"
  ... />
class BalanceWidget: GlanceAppWidget() {
  private val apiClient = ApiClient()

  override suspend fun provideGlance(context: Context, id: GlanceId) {
    // 時間のかかる読み込み処理を `provideGlance` 内に記述できる
    val accounts = withContext(Dispatchers.IO) { apiClient.getAccounts() }
    provideContent {
      // TODO: 取得したデータを用いてレイアウトを表示
    }
  }
}

上述の通り、B/43ではアプリ内の操作によりカードの残高に変動が生じた場合にウィジェットへ反映するフローも実装しています。この場合、 provideGlance の呼び出し時に取得処理を必ず行う構成では不必要なAPIリクエストを発火してしまう(あるいはAPIリクエストを発火させないための何らかの分岐が必要になる)ため、定期取得用の Worker を自前で用意する実装方法に留めています。

まとめ

このエントリーでは Jetpack Glance を用いたB/43の残高ウィジェットの実装について紹介しました。旧来の RemoteViews を直接用いる方法でのウィジェットを実装した経験もありますが、比較してもスムーズに実装を進められたと感じています。RemoteViews 独自のインターフェースへの理解が不要となることに加え、ウィジェット単位での永続ストレージを自前で構成せずに済むことも嬉しいポイントでした。

Jetpack Glance でのウィジェット開発や Dynamic color への対応事例はまだまだ少ないと思われるので、これから実装を始める方へ少しでも参考になれば幸いです。

参考資料


スマートバンクでは一緒に B/43 を作り上げていくメンバーを募集しています!カジュアル面談も受け付けていますので、お気軽にご応募ください 🙌 smartbank.co.jp

*1:Android Open Source Projectのドキュメント上で該当する配色が "accentSurface" として小さく言及されていること以外、情報は得られていません。 https://source.android.com/docs/core/display/material

We create the new normal of easy budgeting, easy banking, and easy living.
In this blog, engineers, product managers, designers, business development, legal, CS, and other members will share their insights.