メドピア開発者ブログ

集合知により医療を再発明しようと邁進しているヘルステックカンパニーのエンジニアブログです。読者に有用な情報発信ができるよう心がけたいので応援のほどよろしくお願いします。

スギサポwalkへのJetPack Compose導入の取り組み

こんにちは。Androidエンジニアの伊藤です。 スギサポwalkという歩数計アプリの開発を担当しており今回は Jetpack Composeを導入したので、この取り組みについて書いていきます!

Jetpack Composeとは

Jetpack ComposeはAndroidの新しいUIツールキットです。 宣言的UIとも呼ばれ、状態をUIとして表示するという仕組みです。 AndroidでUI開発を簡素化して、直感的なAPIにより良い開発体験が得られます。

また、数多くのAndroidのプロダクトで利用されています。

なぜ導入しようと思ったのか

主に以下の点によるメリットが大きいと考え、導入しようと考えました。

  • 宣言的UIによりAndroidViewよりも状態管理がしやすくメンテナンス性も上がることが期待できる。
  • AndroidViewと比べると再描画の際の再計算コストが小さく、パフォーマンスの向上が期待できる。
  • Jetpack Composeのコンポーネントは使い回しがしやすく開発コストが少しでもさげられそう。
  • Preview機能によりUI開発体験が以前よりも飛躍しそう。
  • Googleからも推奨されアップデートも沢山行われている。
  • フルCompose実装が難しい場合ComposeとAndroidViewを組み合わせて使える。

どのように導入していったのか?

まずは、Jetpack Composeを入門するにあたり弊社Android、iOSエンジニアが集まってPathWayを受講しました。 その後、スギサポwalkでは導入にあたり、導入障壁となる問題の解決と導入方針を決めました。

問題の解決

導入にあたり、以下の問題がありました。

  • Kotlin , AGP(Android Gradle Plugin)のVersionが古い

当初はKotlinのVersionが1.4、AGPが3.6.3で構成されており、StableなComposeが導入できませんでした。 そのため、KotlinとAGPを最新に更新する必要が出てきました。

  • アーキテクチャがMVPで構成されている

Composeは状態管理手法として、UDFアーキテクチャパターンが推奨されていますが、 スギサポwalkのアーキテクチャはMVP(Model-View-Presenter)で構築されています。
MVPでもUDFパターンを扱う事はできそうですが、ViewとPresenterはFragmentなどと密接に繋がっていて、後々の実装で障壁なりそうでした。 そのため、Composeを導入する画面はPresenterからAndroid Architecture ComponentのViewModelに置き換えていく作業を加えていくことにしました。

プロダクトへの導入方針

  • 影響箇所の少ないデバッグ画面や新規機能を開発する場合は積極的にComposeでUIを作っていく。
  • リソースの関係上、全ての既存画面のリプレイスは問題がない限りは行わない。
  • 既存のStyleやThemeに沿ったボタンやテキスト、Appbarなどはコンポーネント化していく。

実際に導入した箇所の説明

スギサポwalkでは、昨年12月頃から連続ログインボーナスという機能を導入しました。 ダウンロード後7日間の間で日付に応じて、マイルが付与されるお得な機能になります! ダウンロードして使ってもらえると嬉しいです😄 play.google.com

apps.apple.com

連続ログインボーナス

連続ログインボーナスの画面は以下のような画面です!

連続ログインボーナス

画面構成について

画面の構成については以下のようなイメージです。 スギサポwalkは多種多様なモーダル画面が表示されています。
元々は、Activity → Fragment →XML(binding).
のような構成だったため、その構成に則ってXML部分をComposeに置き換えたような形になります。

コードでは以下になります。

   override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        super.onCreateView(inflater, container, savedInstanceState)
        return ComposeView(requireContext()).apply {
            setContent {
                setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
                AppCompatTheme {
                    LoginBonusViewScreen(
                        onCloseClick = {
                            // close event 
                        },
                        data = LoginBonusData()
                    )
                }
            }
        }
    }

今回実装した画面では状態とロジックが単純なため Composeに UIロジックとUI 要素を保有しています。

レイアウトについて

レイアウトについてざっくりとですが以下のようなイメージです。

Compose Node

Dialogがあり、Boxがあります。 その下にマイルの情報、連続ログインの情報やCloseButtonを表示するColumnStarAnimationを表示するLayerがあり重ねています。 マイルの情報、連続ログインの情報を表示するColumnの下には各種コンテンツを構成する要素が並んでいます。

工夫した点

工夫した点はStarのAnimationです。 StarのAnimationはAndroidViewの方で実装されていたObjectAnimatorを利用したImageViewだったのですが、 ComposeのAnimationでは再現が難しく、AndroidViewの実装をComposeに組み込むことで再現しました。

@Composable
fun StarAnimation(modifier: Modifier = Modifier) {
    val ticks = remember { mutableStateOf(0) }
    val isStart = remember { mutableStateOf(false) }
    ObserveLifecycleEvent { event ->
        when (event) {
            Lifecycle.Event.ON_RESUME -> {
                isStart.value = true
            }
            Lifecycle.Event.ON_PAUSE -> {
                isStart.value = false
            }
            else -> Unit
        }
    }

    LaunchedEffect(isStart.value) {
        while (isStart.value) {
            delay(ANIMATION_DELAY)
            ticks.value += 1
        }
    }
    AndroidView(
        modifier = modifier.fillMaxSize(),
        factory = { context ->
            StarAnimationView(context).apply {
                val params = FrameLayout.LayoutParams(
                    FrameLayout.LayoutParams.WRAP_CONTENT,
                    FrameLayout.LayoutParams.WRAP_CONTENT
                )
                params.gravity = Gravity.CENTER
                layoutParams = params
            }
        },
        update = { view ->
            if (ticks.value > 0) {
                view.startStarAnimation()
            }
        }
    )
}

内容としては、0.25秒ごとにFrameLayoutに対して、AnimationするStarのImageViewを追加するというものでした。(Viewがインフレートされた後にAnimationを開始)

AnimationするImageViewの内容とライフサイクルは以下です。

  • Animation時間は2.5秒
  • 画面の中心から上下ランダムにTranslationX & Yの値を変化させていく。
  • Scale & Alpha値を徐々に大きくしていく。
  • Animation終了時に親のViewからRemoveする。

以下にStarAnimationViewはクラスを作成しました。

class StarAnimationView : FrameLayout {

    constructor(context: Context) : super(context) {
        init(context, null, 0)
    }
    // Animation ViewをFrameLayoutにaddしanimationを開始、終了したら、removeする
    fun startStarAnimation() {
      val star = addStarView(context, this)
      val winAnim = starAnim(context, star)
      winAnim.doOnEnd { this.removeView(star) }
      winAnim.start()
    }

改善点

  • Lottie Animationで実装すれば、ロジック的にはもう少し簡素化できた。
  • メモリーやCPU使用率には大きな問題は無かったが、AndroidViewが高頻度でrecomposesされ 内部でもViewがインフレートされるため、フルComposeにすればパフォーマンスの向上が期待できそう。

実際に導入してよかったこと

  • 開発体験が向上した。
  • UIのUnitTestができるようになった。
  • Previewのインタラクティブモードが使いやすい。
  • LazyColumnやLazyRowによりRecyclerViewの時よりもコードが簡潔になった。

今後の課題

  • Composable設計ルールの制定
    Atomicデザインに則りパーツ単位でUIデザインを設計していく。
  • 脱MVP
    新規の機能や影響範囲からCompose化していく予定のため、徐々に脱MVP化してViewModel+UDFのような形にしていきます。
  • 脱Rx
    現状のRest APIとの通信結果はRxのSingleなどでデータフローを作っているが、ComposeはCoroutine Flowなどと相性が良いため、置き換えていきたい。
  • VRT(Visual Regression Testing)の導入を検討
    頻繁にUIが変わる画面など、プロダクトの方向性により導入を検討していく。

いかがだったでしょうか。 まだまだ、弊社では他プロダクトでもJetpackComposeの導入を実行中です。 新たな知見など出てきたら、また発信していきます!


是非読者になってください


メドピアでは一緒に働く仲間を募集しています。 ご応募をお待ちしております!

■募集ポジションはこちら

https://medpeer.co.jp/recruit/entry/

■開発環境はこちら

https://medpeer.co.jp/recruit/workplace/development.html