Jetpack Composeをフル活用してデカめの新規機能を作った

こんにちは、カンカクでAndroidエンジニアをやっている @haru067です。好きな麺は平打ち麺です。

プレスリリースにもありますが、カンカクではカフェ事業としては初となる、ラウンジ業態の店舗を2022年1月20日に開店しました🎉 これに伴い、プレオーダーや事前決済ができるモバイルアプリ『COFFEE App』にも、ラウンジの機能が追加されたのですが、Androidでは新規UIの全てをJetpack Compose(以降Compose)で記述しました。

そこで今回は、Compose導入の舞台裏について、話したいと思います。

Jetpack Composeとは

宣言的にUIを記述する、Androidの新しいUIツールキットです。*1
2021年7月には、安定版である1.0.0がリリースされました。 developer.android.com

技術選定

まず、Compose導入の経緯についてです。多くのアプリがJavaからKotlinから移行していったように、これまでのAndroidのView(以降View)からComposeへの移行は、遅かれ早かれ発生するものだと我々は考えていました。従って、焦点になるのは、Composeを「どのタイミングで」「どのように」導入するかでした。ラウンジ開発でのCompose導入を踏み切るにあたり考慮したのは、大きく分けて、次の3つでした。

  • 開発人数
  • 開発期間
  • 画面要素

開発人数

複数人のAndroidエンジニアを抱える組織では、足並みや認識を揃えるところから出発したり、学習コストについても考える必要があります。今回はAndroidエンジニア1人(わたしです)での開発であったため、学習コストを深く考慮せず、プロトタイピングとある程度平行する形で、機能開発を進めることができました。また、ComposeのCodelab等で、ある程度開発のイメージを掴んでいたのも、導入の後押しになりました。

開発期間

カンカクでの開発は店舗の開店日など、物理的な制約によって開発の期限が決まることも多く、ラウンジ開発も例外ではありませんでした。期限が決まっている開発で技術的な挑戦をすることは、不確定要素を増やすという点でネガティブでしたが、導入の仕方を工夫することでリスクを下げれば、許容できるものと判断しました。これについては後ほど話します。

画面要素

Composeを使用する上で、最も避けたいのはViewの世界とComposeの世界を相互に行き来するような状態でした。既存の機能に手を加えるケースではこのような問題に直面しがちですが、大半が新規の画面追加であるラウンジ開発はそういった懸念が無く、Composeを導入するには絶好の機会でした。また、ラウンジは複雑なUI要素が存在しないものの、画面数がシンプルに多い機能開発でした。そういった点でもラウンジとComposeは相性が良かったです。Composeのメリットである記述の短さは画面数(≒UI要素の量)の多い開発において絶大な効果を発揮しますし、懸念していた「Composeに今回必要な機能やUIコンポーネントが無いかもしれない」といった点もまあ、これぐらいのUIならいけるだろう、とある程度の自信を持って判断することができました。

f:id:kankak_inc:20220124151348p:plain
Figma上でのラウンジの画面

移行戦略

「今回」やると決めたあとは、それを「どのように」導入するか、の話です。ポイントは、Compose導入という技術的な挑戦をしつつ、開発期限を厳守する、という点です。ここで考えられる最悪のシナリオは、Composeの採用を途中で断念し、全てをViewに書き戻す作業が発生することでした。それをなるべく避けるために我々が採用したのは、Composeを使用しつつも、画面は従来どおりFragmentで区切って構成していく、という方針でした。(Compose as a Viewとでも呼びましょうか)

こうではなく
Fragment ┬ ComposeScreenA
         ├ ComposeScreenB
         └ ComposeScreenC

こうする
FragmentA ─ ComposeScreenA
FragmentB ─ ComposeScreenB
FragmentC ─ ComposeScreenC

個々のFragmentがComposeViewを持つことで、Fragmentの境界がComposeの境界となり、Composeで実現できない処理が出てきたとしても、それは該当するFragmentの範囲内での修正で済むはず、という考えです。

そしてFragmentで区切るもう一つのメリットは、既存のView資産を再利用しやすい、という点にあります。Fragmentが存在することで、画面遷移は従来と変わらずに実現できますし、ローディング(ProgressBar)やエラー表示(Snackbar)といった、元々アプリ全体で共通化していた処理も、既存のコードを流用して、手っ取り早く実装することができました。

Composeをどう書くか

次に考えたのは、「何かあったとき、すぐにComposeからViewに戻せるような状態とは何か?」という問いでした。ComposeやViewが依存しうる要素、代表的にはFragmentやViewModelとの相互作用をどう設計するか?という話です。これに関しては次の2つのルールを定めました。

ある画面Hogeが存在し、それに対応するHogeFragmentがあったとする。このとき

  1. HogeFragmentに唯一のstatefulなcomposableである`Screen`を書き、それ以外にcomposableを定義しない
  2. それ以外のcomposableはstatelessで、FragmentやViewModelに一切依存してはいけない
     (Fragment/ViewModel agnosticである)

コードで表すと次のような感じです。

// HogeFramgent.kt

class HogeFragment : Fragment() {
  override fun onCreateView(
    inflater: LayoutInflater,
    container: ViewGroup?,
    savedInstanceState: Bundle?
  ): View {
    val composeView = ComposeView(requireContext()).apply {
      setContent { Screen(viewModel) }
    }
    
    ...

    return composeView
  }

  ...

  @Composable
  private fun Screen(viewModel: HogeViewModel) {
    val uiState by viewModel.uiState.collectAsState()

    HogeScreen(
      uiState = uiState,
      onBackPressed = { popBackStack() },
      onClickA = viewModel::onClickA,
      onClickB = viewModel::onClickB,
      ...
    )
  }
}
// HogeScreen.kt

@Composable
fun HogeScreen(
  uiModel: HogeUiState,
  onBackPressed: () -> Unit,
  onClickA: () -> Unit,
  onClickB: () -> Unit,
) {
  ...
  SomeContents(...)
  ...
}

@Composable
private fun SomeContents(
  foo: String,
  bar: Int,
  event: () -> Unit,
) {
  ...
}

composable中でstateが必要になった場合、state hoistingにより、ルートである Screen が子のstateを持つようにし、子のcomposableに必要な値は全て引数として渡される、という方針です。こうすることで、FragmentやViewModelに依存する処理を1箇所にまとめることができますし、それ以外のcomposableをstatelessにすることができます。statelessであるということは、後述のプレビューやUIテストを記述する上で非常に役に立ちました。

Composeを使ってみて

ここからはComposeを使用してみての感想タイムです。まずは良かった点について。

開発効率

今回の開発で最も多かった画面パターンの一つが「スクロールが必要で、コンテンツはネットワークからの取得により動的に定まるが、ページング等は必要としない画面」でした。つまりRecyclerViewでは扱うには大げさで、ScrollViewで扱うには不十分な画面です。ComposeではLazyColumnやScroll modifierによって簡潔にスクロールするUIを記述できるので、大きく助けられました。「動的に定まる」の部分に関しても、コードで記述する、というComposeのスタイルが上手くマッチして、高速に開発をすることができました。

動作確認

previewが重いけど便利です。特にナビゲーション階層の深いところに存在する画面や、特定の状態でしか出現しないUIなど、再現に手間がかかるものほど、IDE上でのレイアウト確認に助けられました。次のスクリーンショットは、Spinnerの状態別のレイアウト確認の例です。

f:id:kankak_inc:20220124153029p:plain
IDE上でのSpinnerの状態別UIプレビュー

ラウンジ開発で特に助かった瞬間は利用履歴の画面でした。この画面は予約中、利用中、利用後、予約キャンセル、といった状態によってUIが微妙に変わり、これを細かい変更の度に全ての画面で確認するのは大きな手間でしたが、previewによりまとめて確認することができました。

f:id:kankak_inc:20220124153758p:plain
状態別の利用履歴画面一覧

あとはUIテストが非常にやりやすくなりました。これはComposeで助かったというよりも、宣言的でstatelessに作った結果、testabilityが高くなった、という感じでした。

f:id:kankak_inc:20220124163725p:plain

リッチな表現

アニメーションが扱いやすくなりました。Viewのアニメーションでは、xmlで表現できない部分をコードで記述したりして、混沌の原因になることも多かったのですが、Composeでは、そもそもxmlを使用しませんし、APIも簡潔で扱いやすくなっていると感じました。 また、Accompanistには、ローディング中に仮のUIを表示するPlaceholderという機能があります。Modifierの仕組みを使って実現しているのが面白く、View時代には実装しづらかった部分も楽に実装することができました。

f:id:kankak_inc:20220124154131p:plain
左:読み込み後、右:読み込み中

困った点

逆に、困った点は次の3つです。

  • Kotlinバージョンのロックイン
  • アプリサイズの増大
  • 成熟度合い

1つ目は開発途中にKotlin 1.6がリリースされて、アップデートを試みました。しかしComposeがKotlinのバージョンを指定するため、Composeが対応するまでKotlinのバージョンが更新できない、という事態に陥りました。2つ目は、ViewとComposeが共存する上で避けられない問題です。ComposeだけでなくAccompanistやCoil composeなど、周辺ライブラリも色々と導入した結果、Compose導入前後で数MBのサイズ増加が確認できました。最後は時間とともに解決していく問題だとは思うのですが、一部のAPIがExperimentalであったり、日本語入力の扱いなどで怪しい挙動が何点か見つかりました。

課題

最後に、課題として残っている部分を挙げておきます。

コード規約

composableな関数は名前がPascalCaseであったり、通常の関数とは異なる扱いをしますが、named argumentをつけるか否かは書く人によってブレそうだな、と感じました。今回は保守的に「composable functionの呼び出しではnamed argumentを必須とする」としましたが、単一の引数しか持たない場合や、modifierは例外的にnamed argument無しを許容してもいいかもしれません。

// 悩ましい

Text("こうするか")
Text(text = "それともこうするか")

共通コンポーネントの設計

Composeでコードを書いていると、アプリ全体で共通して使いたいUIが必ず出てくると思うのですが、それらのAPIをどう書くべきか、記述の柔軟性と簡潔性のトレードオフをどうすべきか、ということに関しては上手く答えが出ませんでした。例えばComposeのButtonはSlot APIにより柔軟性の高い記述を実現しています。

@Composable
fun Button(
  ...
  content: @Composable RowScope.() -> Unit
)

// テキストボタンとして使う場合
Button(...) {
  Text("Button")
}

ライブラリの設計としては良い方針に見えますが、アプリ内では決まったパターンのボタンしか使わないので、ラウンジ開発では、AppButtonというアプリ内の共通ボタンを定義しました。

@Composable
fun AppButton(
  text: String,
  ...
) {
  Button(...) {
    Text(
      text = text,
    )
  }
}

// テキストボタンとして使う場合
AppButton("Button")

記述が簡潔になり、一見すると良さそうに見えますが、例外的なパターンというのはやはり出てくるものです。 大体は上記の方針で上手く行ったものの、「ある画面では例外的に太字にしたい」、「別の画面では文字数が多いので少し文字を小さくしたい」といったパターンが出てきてしまい、徐々に破綻してくる、ということがButtonの例に限らず何度かありました。このあたりはAtomic Designやデザインシステムの話とも絡めて、上手く構造化していく必要があるのかもしれません。

おしまい

というわけで、Composeを使用したラウンジ開発の振り返りでした。実際に動いている様子を見たい方はぜひ、COFFEE Appをダウンロードしてみて下さい! また、カンカクではラウンジ以外にも「Building the next Ordinary. 新しいライフスタイルを作る」大きなプロジェクトが既に走り出しており、一緒に作り上げていく仲間を募集しています。(Androidエンジニアが足りていません・・・!)雑談ベースで話がしたいけど応募はちょっと・・・という方はTwitter等でご気軽に連絡いただければと思います!

おまけ

Composeでの画像読み込みにはCoil Composeを使うことが多いと思うのですが、 COFFEE Appでは元々Coilを使用していたため、Compose採用時にも特に違和感なく導入することができました。 大元のコードベースを書いた、最近はPdMもやられている しほちゃんの先見性にこの場を借りて感謝します🙏

*1:厳密にはマルチプラットフォームも存在します https://www.jetbrains.com/ja-jp/lp/compose-mpp/