MainActivityのJetpack Compose化が上手くいった話

はじめに

こんにちは!今年の1月からクラスター社でソフトウェアエンジニアとして働いているryomaです。
クラスター社のAndroidアプリ開発チームでは、2021年からコツコツとJetpack Composeへの移行を進めており、先日ついに全画面の移行が完了しました。
その中でも今回は、最後の難関として残っていた「MainActivityのJetpack Compose移行」にスコープを絞って、上手く機能した移行の進め方(+実装上苦労したポイント)を紹介します。
アプリトップなど、UI要素の多い画面のJetpack Compose移行の進め方で悩んでる方の参考になれば幸いです。

背景

具体的な話に入る前に、Compose移行対象としてMainActivityが最後まで残っていた理由とこのタイミングで移行するに至った背景を簡単に紹介します。

まず、移行対象として残っていた理由ですが、大きく以下の要因が挙げられます。

  • MainActivity(モバイルアプリのトップ画面)は、長らくUIの更新の入らない状況が続いていた
  • Bottom NavigationやDrawer Navigationなど、単体でのJetpack Compose移行が難しいUI要素が多数含まれていたので、移行実施に際してはこれらも含めて一度に大量の要素を移行する必要があった

上記のような状況を踏まえつつも、このタイミングでのCompose移行を実施することにした理由として、次のような理由が挙げられます。

  • 写真フィードスペース機能の開発が本格的に始まり、直近でアプリトップに大きな更新が入る予定がある
  • 継続的なアプリのメンテナンスという観点から、XML Layoutの画面が残っていると開発のボトルネックになってしまう

特に後者に関しては、MainActivity以外の画面でJetpack Composeへの移行が済んでいたこともあり、「MainActivityのコードに修正を加える = 普段と異なる記述のコードベースに触れる必要があるので心理的負荷が上がる」という状況を是正する観点も含んでいます。

移行前のMainActivityの設計(as-is)

UIの開発方針: XML Layout & View Binding、inflateはFragment毎に実行する
ViewModelの構成: MainActivty用と、Fragment毎にViewModelを用意
MainActivity内での画面遷移: androidx-navigationを利用

移行前のMainActivityの構成は上記のようなBottom Navigation + 複数Fragmentのよくある構成になっていました。
Bottom NavigationやDrawer Navigationといった画面全体で利用するUIに関してはMainActivityで直接inflateする構成です。

移行後のMainActivityの設計(to-be)

UIの開発方針: 全てJetpack Composeで実装し、FragmentはXxxScreenに置き換え
ViewModelの構成: MainActivty用と、Screen毎にViewModelを用意
MainActivity内での画面遷移: androidx-navigation-composeを利用

移行後は、個々のBottom Navigation Itemに対応する画面として子要素「XxxScreen」を新たに定義し、それらを親であるMainScreenで束ねつつ、navigation composeで切り替えるような構成に変更しました。
あわせて、Bottom NavigationやDrawer NavigationもすべてJetpack Composeベースのものに置き換えて、MainScreenで管理するように変更しました。

代案として、UI要素のみJetpack Composeに移行しつつ、Fragmentを利用したNavigation Stackを継続利用するアプローチも検討しましたが、「MainActivityの持続的な開発可能性」という観点では複雑さの懸念が残るため、navigation composeに移行しきってしまい、ノウハウを蓄積する方向に舵を切ることにしました。

具体的な移行の進め方

前述のas-isの状態からto-beの状態に移行するにあたって、どのように移行作業を進行していったのかを紹介します。

詳細な移行対象の洗い出し

移行を本格的に進めていくにあたり、まずはUIコンポーネントレベルで移行する必要のある要素をすべてリストアップする作業を行いました。
具体的には、アプリトップにおけるpickup表示など、運用者が動的に表示コンテンツを差し替えられる仕組みなども踏まえつつ、新しいMainActivityに移行する必要のあるコンポーネントとそうでないコンポーネントを精査する、といった作業です。
また、XML Layoutで利用していたデータ定義をJetpack Composeで扱いやすい形に変換するためのコンバーターを用意するか否かなど、UI要素に直接的に関わらないタスクの洗い出しなどもこのタイミングである程度実施し、全体の作業量の見通しを立てるようにしました。

移行作業の依存関係を整理

移行作業の全容が見えたタイミングで、個々の移行作業の依存関係を整理しました。
次に何をしたら良いのか迷子にならないので、「あとは手を動かすだけ」という状況が作れるのはもちろんのこと、特定のタスクが個別で切り出せるかどうかの判断も容易にできるので、「この部分だけ他のメンバーにヘルプでお願いしたい」というケースでも非常に効果的でした。

FeatureFlagで移行前後の状態を切り替えられるように制御

クラスター社では新機能の開発にFeatureFlagを利用しているので、今回のJetpack Composeへの移行に関してもFeatureFlagを利用して移行前後の状態をいつでも切り替えられるようにしていました。
具体的には以下のような制御をMainActivityに対して実装し、Flagでの切り替えを実現しています。

  • FeatureFlagが無効化されている時は、今まで通りMainActivity内でXMLベースのLayoutリソースをinflateし、androidx-navigation前提のnavigation graphを適用する
  • FeatureFlagが有効化されている場合は、MainScreen ComposableをsetContentし、XMLベースのLayoutリソースには関与しない

このように切り替えを容易にしておくことで、アプリトップUIの完全移行という比較的大きな変更が入るケースでも、リリースや切り戻しがローコストで実現できるメリットがあります。
一方で、移行期間中に既存のXML Layoutで組まれた画面を修正してリリースしなければいけない状況が発生すると、FeatureFlagの有効化された状態とそうでない状態のUI実装両方対して変更を適用する必要があるので、移行期間を可能な限り短くしたり、先々のリリース予定を見越した上で移行を実施するなど、一定のケアは必要になります。

進め方のポイントまとめ

ここまで紹介してきたJetpack Compose移行の進め方におけるポイントを簡潔にまとめます。

  • 移行作業の全貌を正確に把握し、個々の移行作業の依存関係を明確にしておく
  • FeatureFlag等を利用して、移行前後の状態を簡単に切り替えられる状況を作る
  • 今後のAppの改修予定を踏まえつつ、移行期間が最短になるようにスケジューリングする

ここまで準備してしまえばあとは手を動かすだけです!絶対にXML Layoutを粉砕してやるんだ、という強い気持ちを持って最後まで駆け抜けてください。

苦労した箇所(おまけ)

基本的にはサクサク進んだJetpack Compose移行ですが、一箇所だけ実装上かなり悩まされたポイントがあったので、おまけとして紹介します。

HorizontalPagerの切り替えとドロワー開閉のジェスチャー操作がコンフリクトする

MainScreen Composableでドロワーを管理しつつ、配下のScreenでHorizontalPagerを利用した際に発生した事象で、そのままだとジェスチャーによるドロワー展開ができない状態になってしまいました。
何も対策をしない状態だと、ドロワーの開閉ジェスチャーよりも より下層に存在するHorizontalPagerのジェスチャーが優先して消費されてしまうためです。 この問題に関しては、NestedScrollConnectionを利用することでなんとか回避することができました。
具体的には以下のコードを追加することで、一番左端のタブページが表示されている状況からさらに水平方向左から右のスクロールを検出するとドロワーが展開するようにしています。

fun openDrawerGestureNestedHorizontalScroll(
    openThreshold: Float,
    pagerState: PagerState,
    coroutineScope: CoroutineScope,
    invokableState: MutableState<Boolean>,
    onDetectDrawerOpenGesture: () -> Unit,
): NestedScrollConnection = object : NestedScrollConnection {

    private var leftSideScrollValue: Float = 0f
    private var invokable by invokableState
    private val throttleIntervalMs = 1000L

    override fun onPostScroll(consumed: Offset, available: Offset, source: NestedScrollSource): Offset {
        if (source != NestedScrollSource.Drag) {
            // Drag操作以外は消費しない
            return clearAndNoConsume()
        }
        if (available.x <= 0f) {
            // 右スクロールが発生したら消費しない
            return clearAndNoConsume()
        }
        if (pagerState.currentPage > 0) {
            // 左端のページに到達してなかったら消費しない
            return clearAndNoConsume()
        }
        if (pagerState.currentPageOffsetFraction > 0f) {
            // offset値としても左端に到達してなかったら消費しない
            return clearAndNoConsume()
        }

        return consumeWithCallback(available)
    }

    /**
        * Drawer判定を開く用の変数の値をクリアしつつ、スクロール値を消費しなかったものとしてOffsetを返却する
        */
    private fun clearAndNoConsume(): Offset {
        leftSideScrollValue = 0f
        return Offset.Zero
    }

    /**
        * Drawerを開く判定に使用して、availableを全消費したものとしてOffsetを返却します
        */
    private fun consumeWithCallback(available: Offset): Offset {
        leftSideScrollValue += available.x
        if (leftSideScrollValue >= openThreshold && invokable) {
            invokable = false
            coroutineScope.launch {
                delay(throttleIntervalMs)
                invokable = true
            }
            // ここでleftSideScrollValueをリセットしているので
            // 前段にスロットリング処理を挟んで[onDetectDrawerOpenGesture]が重複実行されるのを防止
            leftSideScrollValue = 0f
            onDetectDrawerOpenGesture()
        }
        return available
    }

おわりに

今回はMainActivityにスコープを絞りつつ、Jetpack Composeへの移行の進め方を紹介させていただきました。

クラスター社のAndroidアプリ開発チームでは、新機能の開発と同時にJetpack Composeへの移行のような「持続的な開発可能性」にも重きをおいた取り組みを行っています。
この記事を読んで少しでも興味が沸いた方は、ぜひ下記リンクからご応募ください。

recruit.cluster.mu