💭

プロダクトにJetpack Composeを導入した時に考えたことや決めたこと、使ったテクニック

2025/02/16に公開

前提

  • Jetpack Compose + Coil + DaggerHilt + AACのViewModelを用いて、ViewModelにuiStateを持たせてstateの管理をしながら開発する想定

  • いろんな資料を参考に、自プロダクト向けの解釈の上、必要であればアレンジ等はしている

  • これがベストプラクティスのつもりはないが、この方針はトレンドから外れておらず、保守性の上で問題は起きていない...です現状

  • この記事の指す”View”はComposable関数のこと

Viewを一定の粒度で分割する

分割しないと巨大なViewファイルが出来上がって保守性を保てないが、分割方針がないとどう分けたらいいかもわからない

DroidKaigi2023のissue#84とアプリ本体を参考に、Viewの分割ルールを制定した

https://github.com/DroidKaigi/conference-app-2023/issues/84

Screen/Content, Section, Componentの3レベルで分割する

Screen/Content

  • 画面全体に対応するView
  • ScreenとContentは一つのファイル内に記述する
  • ScreenはViewModelの分離をするだけ、かつContentを呼び出すだけ
    • ViewModelの分離はプレビューを動作させるために必要(後述)
  • ContentはSectionとComponentを呼び出し可能

Section

  • Screen/Contentの一つ下の粒度のView
  • 大抵、画面幅をフルに使うようなViewパーツがSectionに対応する
  • SectionはSectionとComponentの呼び出しが可能

Component

  • 一番小さいView
  • Sectionよりも小さいものはComponentとして扱う

厳密な粒度を求めない

画面の構造は人によって認知差がある部分で、緻密に分割することが本質ではない
認知差を埋めることが難しい分割ルールを守ろうとして悩んで手が止まってしまうのは、体験もよくないし進捗も出せない
運用しながらチームで感覚を洗練させたり統合していくほうが良いという判断で、厳密に粒度を揃えようとしない方針にした

プレビューを活用しながら開発する

基本的に、Viewに対して必ずプレビュー関数を作る

プレビューできない状態でUI開発はとてもじゃないが無理

プレビュー可能であることはプレビュー関数からの呼び出しが可能で、つまり疎結合かつテスタビリティが保たれているということなので、内部品質を維持するためにも作成したViewに対して必ずプレビューを作るルールにしている
(とても小さいViewであるときは任意にしている)

VRT導入の選択肢も格段に取りやすくなる

ViewModelへの依存を切り離す

ComposableがViewModelに依存すると、プレビューできなくなる
(プレビュー実行環境ではViewModelの依存を解決できない)

これを解消するためにScreen/Contentのレイヤーを活用する

ScreenでhiltViewModel()でviewModelを初期化する
ScreenでuiStateを取り出して、Cotentに渡す

これでViewModelへの依存が切り離されてプレビューが機能するようになる

プレビューもしくはVRT環境であることを判別する

プレビュー環境でComposable関数が実行されていることは

val isPreview = LocalInspectionMode.current

で判別できる

ちなみに、roborazziでVRTを走らせる場合は

Build.FINGERPRINT.equals(”robolectric”)

で判別できる

自プロダクトでは

fun isRunningOnTest(): Boolean {
  val isPreview = LocalInspectionMode.current
  val isRunningOnRobolectric = Build.FINGERPRINT.equals(”robolectric”)
  return isPreview || isRunningOnRobolectric
}

で環境の識別ができるようにしている

VRTではなくUIテスト時にもこのフラグが立つよね多分…
UIテストは基本書いていないので検証不足です🙏

CoilのImageLoaderを切り替える

自プロダクトは、コンテンツの画像読み込み時に認証が必要な都合上、CoilのokHttpClientを差し替えている

しかし、プレビュー環境ではdagger hiltによるDIが動かないため、okHttpClientの注入ができない。このままImage付きのViewをプレビューしようとすると、ImageLoaderを正しく初期化できずプレビューが壊れる

これを解決するために、CompositionLocalと先ほど紹介したisRunningOnTest関数を活用しつつ、プレビュー環境ではデフォルトのImageLoaderを参照するようにしている

fun getImageLoader() : ImageLoader {
    return if (isRunningOnTest) {
        LocalContext.current.imageLoader
    } else {
        CustomImageLoader.current
    }
}

利用したいときは

SubcomposeAsyncImage(
    imageLoader = getImageLoader()
)

のように呼び出す

CircularProgressIndicatorの表示制御

roborazziでスクリーンショットテストを回す場合だが、CircularProgressIndicatorが含まれるビューのスクリーンショットテストをしようとすると、くるくる回るアニメーションが繰り返されるせいでViewがアイドル状態にならず、スクリーンショットテストが終わらなくなる

色々と対処方法はありそうだが、自プロダクトでは前述したVRT環境であることを識別する方法を使って、CircularProgressIndicator自体を呼び出さないようにした

終わりに

Jetpack Composeを用いたプロダクト開発ができるようになって、開発効率が上がったし、xmlを跨がないので認知負荷も抑えられるためストレスも少ないし、モダンなスキルを獲得しながら仕事ができるため自分含めてメンバーも喜んでいるし、効力感の醸成につながっているなと感じます

何より、RecyclerViewを脱却できたことでリスト実装コストが格段に低下した点が素晴らしいと感じています。パフォーマンス等で何か問題が出ないかなとビビっていたのですが、自プロダクトではそういった問題も現状起きていないです

Jetpack Compose導入を考えていたり、実装で詰まっている人の参考になったら幸いです🙏

Discussion