はじめに こんにちは、ブランドソリューション開発本部FAANS部の田中です。ショップスタッフの販売サポートツール「FAANS」のAndroidアプリを担当しています。本記事では、FAANSアプリでFragmentベースのナビゲーションからNavigation Composeへの移行を、機能開発と並行してモジュール単位で進めた取り組みを紹介します。 約半年で、8つの機能モジュールのうち5つの移行が完了しました。ゴールは、 画面遷移の管理をActivityに1つ置いたNavHost(Navigation Composeで画面遷移を管理するComposable)へ集約する ことです。ただし機能開発とリリースを止めるわけにはいかないため、Fragmentを残したまま モジュール単位で移行を積み上げる 形を取っています。以下、この進め方と移行の中で見えてきた課題、今後の見通しを書きます。 目次 はじめに 目次 背景・課題 マルチモジュール構成と画面遷移 UIのCompose化と二重管理 移行対象の規模 移行方針 ゴールと段階 共存させるための選択肢 Navigation 3ではなくNavigation Composeを選んだ理由 1. モジュール単位の移行 1-1. 採用した構造 未移行のFragmentへの橋渡し 1-2. 進め方の整備 1-3. 移行前の遷移の記録 1-4. 画面の移行 ダイアログの移行 遷移をイベントで通知しているViewModelの移行前対応 Fragmentのライフサイクルとの対応 1-5. PRの分け方 2. 親子関係にあるモジュールの統合 2-1. NavGraphBuilder拡張としてのグラフ分離 2-2. 一時的なモジュール間依存の扱い 3. Activity直下の単一NavHostへの統合 3-1. ルート側の現状 3-2. 統合の見通し 移行の中で見えてきた課題 課題1:戻るボタンの連打で画面がホワイトアウトする 課題2:写真ピッカーから戻った直後の遷移が発火しない 課題3:未移行の画面を挟んでもバックスタックは失われなかった 課題4:ボトムシートを開き直しても前の内容が表示される 課題5:画面遷移テストを書かずにどう担保するか 移行の進捗と現時点の効果 最後に 背景・課題 マルチモジュール構成と画面遷移 FAANSアプリは、モジュールごとにナビゲーショングラフを持つマルチモジュール構成です。各モジュールのグラフをルートのグラフから <include> で取り込み、遷移先はすべてFragmentとして定義しています。モジュールは機能単位で分けることを意図していますが、歴史的な経緯でモジュールの境界と機能の境界が一致していない箇所もあり、それ自体が技術的負債の1つになっています。それでも、ナビゲーショングラフと画面遷移のまとまりとしてはモジュールが単位として機能しているため、今回の移行は このモジュールを単位として 進める方針を取りました。 ナビゲーショングラフ(XML)は次のように定義しています。ルートのグラフが各モジュールのグラフを <include> で取り込み、モジュールのグラフには画面の <fragment> と遷移の <action> が並びます。 <!-- ルートのグラフ: 各モジュールのグラフを取り込む --> <navigation android : id = "@+id/nav_root" app : startDestination = "@id/SplashFragment" > <fragment android : id = "@+id/SplashFragment" android : name = "...SplashFragment" /> <include app : graph = "@navigation/module_a" /> <include app : graph = "@navigation/module_b" /> </navigation> <!-- モジュールAのグラフ: 画面ごとに fragment と action を定義する --> <navigation android : id = "@+id/nav_module_a" app : startDestination = "@id/SettingsFragment" > <fragment android : id = "@+id/SettingsFragment" android : name = "...SettingsFragment" > <action android : id = "@+id/action_Settings_to_Account" app : destination = "@id/AccountFragment" /> </fragment> <fragment android : id = "@+id/AccountFragment" android : name = "...AccountFragment" > <action android : id = "@+id/action_Account_to_ChangeEmail" app : destination = "@id/ChangeEmailFragment" /> </fragment> <fragment android : id = "@+id/ChangeEmailFragment" android : name = "...ChangeEmailFragment" /> </navigation> UIのCompose化と二重管理 XMLのレイアウトをJetpack Composeに書き換える作業を続け、機能モジュールの画面はすべてCompose化が完了しました。残っていたのが画面遷移の層です。画面遷移がFragmentベースのままなので、Compose化した画面も遷移先として登録する必要がありました。そのために「 ComposeView を setContent するだけのFragment」を1画面ごとに用意していました。あわせて、ナビゲーショングラフに <fragment> と <action> も書き続ける必要がありました。 // 移行前: 中身はComposeなのに、遷移のためだけに残っているFragment @AndroidEntryPoint class ExampleFragment : Fragment() { private val viewModel: ExampleViewModel by viewModels() private val args: ExampleFragmentArgs by navArgs() // 引数はXMLの <argument> から生成されたクラスで受け取る override fun onCreateView(...): View = ComposeView(requireContext()).apply { setContent { FaansTheme { ExampleRoute( id = args.id, viewModel = viewModel, onNavigateBack = { findNavController().popBackStack() }, ) } } } } 画面の実装はComposeに移っているのに、遷移まわりは3か所に分かれたままです。遷移先の定義はナビゲーショングラフに、遷移の実行はFragmentの findNavController() に書きます。画面間で渡す引数は、XMLの <argument> から生成されるSafe Argsのクラスで受け取ります。Composeの画面を1つ足すたびにFragmentとXMLの両方を触る「 二重管理 」が積み上がっていました。 移行対象の規模 項目 移行前 ナビゲーショングラフ(XML) 12ファイル(機能モジュール分9、アプリ共通3) 遷移先( <fragment> ) 90 遷移先( <dialog> ) 50 画面のCompose化をやり切り、ようやく画面遷移の層に手を付けられる状態になりました。公式の移行ガイドも、Navigation Composeへ切り替える前提として「 遷移先がすべてComposableであること 」を挙げています。 You can migrate to Navigation Compose once you're able to replace all of your Fragments with corresponding screen composables . Screen composables can contain a mix of Compose and View content, but all navigation destinations must be composables to enable Navigation Compose migration. Until then, you should continue using Fragment-based Navigation component in your interop View and Compose codebase. — Migrate Jetpack Navigation to Navigation Compose — Android Developers 要約:すべての遷移先がComposableになるまでは、Fragmentベースのナビゲーションを使い続ける必要がある。 一方で、アプリ全体を一度に切り替える選択肢はありませんでした。 機能開発と並行して少しずつ、しかし後戻りなく進める方法 が必要でした。 移行方針 ゴールと段階 Navigation Composeでは、 NavHost という1つのComposableが画面の切り替えとバックスタックを管理します。公式の基本構成では、これをActivityに1つ置きます。 ゴールは、Activity直下に1つのCompose NavHostを置き、XMLのナビゲーショングラフと NavHostFragment をなくすことです。 そこまでを一気には進められないので、3段階に分けています。 モジュール単位の移行 :起点のFragmentを残し、その内部にNavHostを置いて、モジュール内の画面をComposeへ移す(コードは1-1) 親子関係にあるモジュールの統合 :特定の1つのモジュールからしか遷移されないモジュール(この記事では親子関係と呼びます)の移行が完了したら、そのグラフを親側のNavHostへ組み込む(コードは2-1) Activity直下の単一NavHostへの統合 :すべてのモジュールが揃った段階で、Activityに1つだけ置いたNavHostへ一括で組み込む(コードは3-2) 以下、この3段階を順に説明します。コードは設定モジュールを通しの例にして、同じコードがこの3段階でどう変わるかを追える形にしています。3は次のフェーズとして計画している段階で、実際の移行作業は本記事のスコープ外のため、現状と見通しだけを書きます。 共存させるための選択肢 FragmentベースのナビゲーションとNavigation Composeを共存させる方法は、公式の相互運用APIを含めていくつかあります。 方法 概要 向いている場面 ナビゲーショングラフ(XML)に composable を置く Navigation 2.8.0 の navigation-fragment-compose 。 ComposableNavHostFragment を使うと、グラフに <composable> を書ける 既存のグラフ構造を保ったまま、画面単位でComposeに置き換えていきたい場合 ComposeのNavHostの中にFragmentを置く Fragment 1.8.0 の fragment-compose 。 AndroidFragment でCompose階層の中にFragmentを配置でき、状態の保存・復元にも対応している NavHostを先にCompose側へ移し、残ったFragment画面を後から置き換えたい場合 起点のFragmentを残し、その内部にNavHostを置く 外側はFragmentベースのナビゲーションのまま。モジュール内の画面だけを composable<T> へ移していく モジュール単位で移行を完結させたい場合(今回採用) 私たちは「 1つのモジュールを最後までやり切ってから次へ進む 」進め方を徹底していたため、3つ目を選びました。モジュールの内側は「遷移先がすべてComposable」という公式の前提を満たし、外側は既存のFragmentベースのナビゲーションがそのまま動きます。画面単位で少しずつ混ぜたい場合は1つ目、NavHostを先に移したい場合は2つ目が選択肢になります。 Navigation 3ではなくNavigation Composeを選んだ理由 執筆時点では Navigation 3 が安定版として公開されています。それなら最初からNavigation 3へ移行すればよいのでは、と思われるかもしれません。しかし Navigation 3はCompose専用で、Fragmentを遷移先にできません 。そのためFragmentが残る段階ではまずNavigation Composeへ移行し、Navigation 3への移行はその後の段階としました。型安全ルートを採用しているので、 公式の移行ガイド が前提とする形はそのまま満たします。 1. モジュール単位の移行 1-1. 採用した構造 1つのモジュールに注目すると、移行後のグラフはComposeでこう書けます。遷移先は @Serializable なクラスで表し、 NavHost に composable<T> として登録します。 // 遷移先の定義: XMLの <fragment> の代わり sealed interface SettingsDestination { @Serializable data object Settings : SettingsDestination @Serializable data object Account : SettingsDestination @Serializable data class ChangeEmail( val currentEmail: String ) : SettingsDestination } // モジュールのNavHost: XMLの <action> の代わりにラムダで遷移する @Composable fun SettingsNavHost( navController: NavHostController, onClickBackButton: () -> Unit , ) { NavHost(navController, startDestination = SettingsDestination.Settings) { composable<SettingsDestination.Settings> { SettingsRoute( onClickBackButton = onClickBackButton, onClickAccount = { navController.navigate(SettingsDestination.Account) }, ) } composable<SettingsDestination.Account> { AccountRoute(onClickChangeEmail = { email -> navController.navigate(SettingsDestination.ChangeEmail(email)) }) } composable<SettingsDestination.ChangeEmail> { backStackEntry -> val args = backStackEntry.toRoute<SettingsDestination.ChangeEmail>() ChangeEmailRoute(currentEmail = args.currentEmail) } } } このNavHostをどこに置くかが共存の要点です。モジュールAのグラフ(XML)に属していたFragmentのうち、 起点の1つだけを残し、その中にNavHostを置きます 。モジュール内の画面はすべてNavHostの中の composable<T> になります。 公式ガイドの手順ではNavHostをActivityに置きますが、外側のグラフ(XML)を残す必要があったため、モジュールの起点となるFragmentにNavHostを置きました。 1モジュール=1NavHost という単位で、背景で挙げた「遷移先がすべてComposable」という前提をモジュールの内側で満たします。 起点のFragmentの責務は「NavHostを置く」ことと「外側との接続」だけになります。 @AndroidEntryPoint class SettingsFragment : Fragment() { override fun onCreateView(...): View = ComposeView(requireContext()).apply { setViewCompositionStrategy( ViewCompositionStrategy.DisposeOnLifecycleDestroyed(viewLifecycleOwner) ) setContent { FaansTheme { val navController = rememberNavController() SettingsNavHost( navController = navController, onClickBackButton = { findNavController().popBackStack() }, ) } } } } NavHost はそのまま使わず、画面遷移アニメーションを None に統一した薄いラッパーを共通モジュールに置いて、各モジュールはこれを使います。Fragment時代の遷移と見た目の差を出さないためです。 なお、上の SettingsNavHost の例では簡略化のため NavHost を直接呼んでいますが、実際にはこのラッパー( FaansNavHost )を使っています。 @Composable fun FaansNavHost( navController: NavHostController, startDestination: Any , modifier: Modifier = Modifier, builder: NavGraphBuilder.() -> Unit , ) { NavHost( navController = navController, startDestination = startDestination, modifier = modifier, enterTransition = { EnterTransition.None }, exitTransition = { ExitTransition.None }, popEnterTransition = { EnterTransition.None }, popExitTransition = { ExitTransition.None }, builder = builder, ) } 未移行のFragmentへの橋渡し 共存期には「モジュール内では移行済みだが、遷移先がまだFragment」という組み合わせが必ず出ます。 内側のNavHostからいったん抜け、外側の findNavController() で遷移させます 。次の移行対象をTODOで明示しておくと、後から探す手間が省けます。 onNavigateToLegacyScreen = { args -> // TODO LegacyFragment をScreen化したら compose の navController で遷移する findNavController().navigate(R.id.LegacyFragment, args) } 1-2. 進め方の整備 最初のモジュールには、業務の中心ではないものの一定の利用がある、設定画面のモジュールを選びました。利用が少なすぎると問題が表に出ず、多すぎると問題が出たときの影響が大きいためです。画面数が少なく、遷移が一直線であることも理由の1つです。 このモジュールは私が1人で移行し、判断に迷うところはチームに相談しながら進めました。移行後は、 移行済みと未移行のモジュールが混在した状態のまま通常のリリースに載せ 、問題が出ないことを確かめました。 そのうえで手順と注意点をClaude Codeのスキル(手順書を登録し、呼び出して実行させる仕組み)に落とし込み、2モジュール目以降はチームで分担しています。スキルは移行手順の実行だけでなく、その前後の段取りも自動化していて、使いながら手直しを続けています。 分担して進めるために、次のものを整えました。 進捗を一覧できる管理ページ :どのモジュールがどこまで進んでいるかを1ページで見える化。作業チケット(課題管理はJira)にモジュール名のラベルを付け、このページからモジュールごとに絞り込める モジュールごとの集約ブランチ :各画面のPRを集約ブランチに積み、モジュール全体の動作確認が終わってから開発のメインブランチへ取り込む。機能開発側のリリースと衝突しにくくなる 骨組みのPRを先に入れる :起点のFragmentにNavHostを導入するPRを最初に入れておくと、子画面の移行はそれぞれ独立したPRになり、複数人で並行して進められる 進捗の管理ページはこのような形です。 1-3. 移行前の遷移の記録 画面遷移のテストは書かない判断をしました 。Navigation Composeにはテスト用のAPIが用意されていますが、それでも書かないと決めるまでの経緯は課題5で書きます。とはいえ、移行前と同じ遷移ができることの確認は必要です。そこで途中から Maestro で画面遷移のシナリオを作り、移行の前後で同じシナリオを流すという軽い確認を試しています。 Maestroは、UI操作のシナリオ(Maestroではフローと呼びます)をYAMLで書くと、実機で同じ操作を再生するツールです。テストコードを書くより手軽で、移行のたびに繰り返せる点が今回の用途に向いていました。 シナリオのYAMLは、 mobile-mcp 経由でClaude Codeに実機の画面を操作させながら生成しています。 AIに任せるのは作成までで、できあがったシナリオの実行はMaestro CLIだけで行います 。実行のたびにAIの判断が入らないので、同じ操作を同じ結果で繰り返せます。 シナリオで確認しているのは、画面を開いて表示を確認し、起点に戻るという 単純な画面遷移だけ です。削除や投稿のようにデータを変更する操作や、複数の入力を伴うフローは含めていません。そこは従来どおりQAと手動確認で担保しています。 シナリオは画面ごとに「起点画面で始まり、起点画面に帰着する」ように書き、それを runFlow で束ねた1本を用意します。単体でも束ねても実行でき、ステップを二重に持ちません。 実行すると、左のログが1ステップずつ進み、右の端末で同じ操作が再生されます。 Maestroは画面上の要素を、表示テキストかIDで指定して操作します。Viewの画面なら android:id がそのままIDになりますが、Composeの要素にはIDに当たるものがありません。そこで操作したい要素に Modifier.testTag("...") を付けます。さらに、アプリ全体を包むテーマのComposable(すべての画面が必ず通る、いちばん外側のComposable)で testTagsAsResourceId = true を設定します。これで testTag の文字列がAndroidの resource-id として外から見えるようになり、Maestroの id: で指定できます。 // アプリ全体を包むテーマのComposableで一度だけ設定する(配下のすべての画面に効く) Modifier.semantics { testTagsAsResourceId = true } // 操作したい要素に testTag を付ける Button(modifier = Modifier.testTag( "settings_change_password_button" ), ...) # Maestro のシナリオからは id で指定できる - tapOn : id : "settings_change_password_button" Android Compose: Use Modifier.semantics { testTagsAsResourceId = true } to ensure your test tags are discoverable as IDs. — Core Selectors — Maestro Documentation 要約:Composeでは testTagsAsResourceId を有効にすることで、 testTag をIDセレクタとして使える。 maestro record で取得した動画をPRに添付し、レビュアーが遷移の維持を確認できるようにしています。CIには組み込まず、移行PRごとにローカルで実行しています。書いたシナリオは移行後も残るので、画面の追加や共通コンポーネントの差し替え、ライブラリ更新のときにも同じシナリオで回帰確認ができます。 1-4. 画面の移行 検証用のシナリオが揃ったら、いよいよ移行に取りかかります。進める順番は次のとおりです。 起点のFragmentに空のNavHostを導入し、起点画面だけを composable<T> で配線する骨組みのPRを入れる 子画面を1つずつ移行する。 Fragmentを削除し、画面のComposableを composable<T> としてNavHostに登録する 不要になった定義をナビゲーショングラフから削除する。その画面の <fragment> と、そこへ向かう <action> が消える <!-- 移行前: ナビゲーショングラフに書いていた定義。移行後はこの2つを削除する --> <fragment android : id = "@+id/ChangeEmailFragment" android : name = "...ChangeEmailFragment" /> <action android : id = "@+id/action_Account_to_ChangeEmail" app : destination = "@id/ChangeEmailFragment" /> // 移行後: NavHost側に composable<T> を登録し、遷移はラムダで渡す composable<SettingsDestination.ChangeEmail> { backStackEntry -> val args = backStackEntry.toRoute<SettingsDestination.ChangeEmail>() ChangeEmailRoute( currentEmail = args.currentEmail, onNavigateBack = dropUnlessResumed { navController.navigateUp() }, ) } FAANSでは画面のComposableを、ViewModelを受け取って状態を購読する XxxRoute と、状態とコールバックだけを受け取る純粋なUIの XxxScreen に分けています。 Fragmentが担っていたViewModelの生成・引数の受け取り・遷移コールバックの提供は、NavHost側の composable<T> ブロックと Route へ移ります。 // 画面側: ViewModelを受け取って状態を購読し、Screenに渡す @Composable fun ChangeEmailRoute( currentEmail: String , onNavigateBack: () -> Unit , viewModel: ChangeEmailViewModel = hiltViewModel(), ) { val state by viewModel.state.collectAsStateWithLifecycle() ChangeEmailScreen( state = state, onAction = viewModel :: dispatchAction, onNavigateBack = onNavigateBack, ) } ViewModel : by viewModels() の代わりに hiltViewModel() を使う。遷移先( NavBackStackEntry )にスコープされるので、画面を抜ければ破棄される 引数 : Bundle やSafe Argsの代わりに backStackEntry.toRoute<T>() で型付きの引数を受け取る 遷移 : findNavController() の代わりに、NavHost側で navController を使うラムダを渡す ダイアログの移行 ナビゲーショングラフには遷移先の <dialog> が50ありました。移行後は種類ごとに扱いが変わります。 確認ダイアログ( AlertDialog ) :共通のComposeダイアログに置き換える。親画面の状態( showDeleteConfirmDialog のようなフラグ)で表示を切り替え、遷移先としては登録しない BottomSheetDialogFragment : ModalBottomSheet に置き換える。確認ダイアログと同じく、親画面の状態で表示を切り替える 全画面のDialogFragment :通常の画面と同じく composable<T> として登録する 引数を受け取り、独立した遷移先として扱う必要があるもの : dialog<T> でNavHostに登録する 遷移先として登録しないダイアログは、ナビゲーショングラフの <dialog> とそこへ向かう <action> が消えます。 // 親画面の状態でダイアログを出し分ける。遷移先としては登録しない if (state.showDeleteConfirmDialog) { FaansAlertDialog( message = stringResource(R.string.delete_account_confirm), onConfirm = { onAction(AccountAction.OnClickDeleteConfirm) }, onDismiss = { onAction(AccountAction.OnDismissDeleteConfirmDialog) }, ) } 遷移をイベントで通知しているViewModelの移行前対応 FAANSの新しい画面は、UI状態を StateFlow に集約する形です。しかし古い画面には、画面遷移やダイアログ表示を LiveData<Event> のような1回限りのイベントで通知しているViewModelが残っていました。 公式のアーキテクチャガイド は、ViewModelで発生するイベントを UI状態の更新として表現する ことを推奨しています。こうした画面は、遷移の要否を状態( shouldNavigateBack のようなフラグ)として持つ形に変換してから移行します。画面側では状態をキーにした LaunchedEffect で遷移を呼び、呼んだことをViewModelに戻して状態をリセットします。 LaunchedEffect(state.shouldNavigateBack) { if (state.shouldNavigateBack) { onNavigateBack() onAction(DetailAction.ConsumeNavigateBack) } } この対応は挙動の変更を含むので、移行PRとは分けて「移行前対応」としてレビューしています。 Fragmentのライフサイクルとの対応 Fragmentを削除すると、 onViewCreated や onResume で行っていた初期化やログ送信の置き場所がなくなります。処理の種類ごとに、Fragmentでの書き方とComposeでの書き方を並べると次のようになります。 画面が表示されたときの初期化 // Fragment override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super .onViewCreated(view, savedInstanceState) viewModel.dispatchAction(ChangeEmailAction.Initialize) } // Compose: Compositionに入ったときに1回実行される LaunchedEffect( Unit ) { onAction(ChangeEmailAction.Initialize) } 前面に戻るたびのデータ再取得と、画面表示のログ送信 // Fragment override fun onResume() { super .onResume() viewModel.dispatchAction(ChangeEmailAction.Refresh) analytics.logScreenView(SCREEN_NAME) } // Compose: ON_RESUME ごとに実行される。後始末が要るものは LifecycleResumeEffect、 // ログのように投げるだけのものは LifecycleEventEffect LifecycleResumeEffect( Unit ) { onAction(ChangeEmailAction.Refresh) onPauseOrDispose { } } LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { analytics.logScreenView(SCREEN_NAME) } LifecycleResumeEffect と LifecycleEventEffect は 別のAPI です。どちらも ON_RESUME でブロックが動きますが、前者は onPauseOrDispose とペアで「再開で始めて中断で止める」処理を扱い、後者はイベントのたびに処理を実行するだけです。 Fragmentのライフサイクルとの対応表は公式ドキュメントには載っていないため、 Composeのライフサイクル対応APIの説明 をもとに自分たちで整理し、移行前に決めておきました。 Fragmentで書いていたこと Composeでの置き換え 補足 onViewCreated での初期化・購読 LaunchedEffect(Unit) Compositionに入るたびに実行。NavHost内で戻ってきたときも再実行される onResume でのデータ再取得 LifecycleResumeEffect ON_RESUME に結び付き、 onPauseOrDispose で後始末 onResume での画面表示ログ送信 LifecycleEventEffect(ON_RESUME) 公式ドキュメントがログや分析などのワンショットイベント向けとしているAPI 一度だけ行う初期化 ViewModelの init Composableのライフサイクルに依存させない 1-5. PRの分け方 PRは役割で分けています。 Fragmentの削除、 composable<T> への登録、不要になったナビゲーショングラフの定義の削除だけを「移行PR」に入れます 。ViewModelをUI状態の形に寄せる作業は「移行前対応」、残ったレイアウトXMLや不要importの整理は「移行後対応」として別PRに切り出します。レビュアーが「これは移行の差分か、挙動変更か」を迷わないためです。 最初のモジュールは手順を確立するために選びましたが、手順が定まってからは、 機能開発の案件で手を入れるモジュールを優先して移行 しています。移行だけを目的に工数を確保するのは難しく、案件で触るモジュールであれば動作確認の機会も自然に得られるためです。ただしPRとチケットは案件とは必ず分けます。 2. 親子関係にあるモジュールの統合 モジュールの移行が完了したら、そのモジュールのNavHostを他のNavHostへ組み込めます。統合には2種類あり、判断基準が異なります。 統合の種類 タイミング 根拠 親子関係にあるモジュールの統合(子モジュールは親モジュールからしか遷移されない) 子モジュールの全画面移行が完了したら、すぐ 実際の画面階層に沿う。XML側のエントリを早く減らせる。PRが小さい Activity直下の単一NavHostへの統合(トップレベルのモジュール群) 複数モジュールがまとまってから一括 1つだけ先に統合すると非対称な中間状態になる。Activity側のNavHost配線やXMLの大規模削除はまとめて行う方がリスクが低い この章では前者を扱います。後者は次章です。 2-1. NavGraphBuilder拡張としてのグラフ分離 統合に備えて、モジュールのグラフは NavGraphBuilder の拡張関数として切り出し、遷移の入り口を NavController の拡張関数として公開します。これは公式の Encapsulate your navigation code が示している形です。Googleの公式サンプルアプリであるNow in Androidも、各featureモジュールで同じ構成を取っています。 // SettingsGraph.kt — グラフ・Destination・navigateToXxx() を1ファイルに fun NavGraphBuilder.settingsGraph(navController: NavHostController) { // ネストグラフの入り口。1-1 の SettingsDestination に data object Graph を追加する navigation<SettingsDestination.Graph>( startDestination = SettingsDestination.Settings, ) { composable<SettingsDestination.Settings> { ... } composable<SettingsDestination.Account> { ... } composable<SettingsDestination.ChangeEmail> { ... } } } fun NavController.navigateToSettings() = navigate(SettingsDestination.Graph) // 統合先(MypageGraph.kt): 親モジュールの NavHost に子のグラフを組み込む fun NavGraphBuilder.mypageGraph(navController: NavHostController) { composable<MypageDestination.Home> { MypageRoute(onClickSettings = { navController.navigateToSettings() }) } settingsGraph(navController) } 2-2. 一時的なモジュール間依存の扱い 公式の モジュール化ガイド では、機能モジュールはデータ層のモジュールに依存し、機能同士のやり取りは仲介するモジュール(通常はappモジュール)を通す形が示されています。FAANSでも「機能モジュール同士は直接依存しない」をルールにしています。この統合では親モジュールが子モジュールへ依存する形になり、このルールと一時的に矛盾します。単一NavHostへの統合までの 期限付きの例外 と位置づけ、次の条件を設けています。 依存は一方向のみとする 解消タイミングをTODOコメントで必ず明示する 統合元の起点Fragmentは、他モジュールからの <action> が消えるまで削除しない 3. Activity直下の単一NavHostへの統合 ここは次のフェーズなので、現状と見通しだけを書きます。 3-1. ルート側の現状 Activityは NavHostFragment を1つ持ち、ルートのグラフ(XML)から各モジュールのグラフを <include> で取り込んでいる。スプラッシュや認証などアプリ共通の画面はまだFragment ディープリンクはActivityが受け取り、 findNavController(...) でXMLの <deepLink> に解決している Activityスコープで共有するViewModelが数種類あり、別Activityで動くフローもXMLの <activity> で繋がっている 3-2. 統合の見通し 統合の本体は「Activityの下に1つのNavHostを置き、各モジュールのグラフを並べる」という配線の置き換えで、画面のコードには手を入れません。以下は現時点の計画で、実装と検証はこれからです。 準備 :各モジュールのグラフを NavGraphBuilder 拡張の xxxGraph() に揃える。2章の統合で使っている形と同じで、単一NavHostへの統合ではこれを並べるだけになる。残りのモジュールとアプリ共通の画面の移行もここに含まれる 切り替え :Activityの setContent にルートのNavHostを置き、 NavHostFragment ・ルートのグラフ(XML)・各モジュールの起点Fragmentを削除する。モジュール横断の変更なので、揃った段階で一度に行う 付随する置き換え :ディープリンクは navDeepLink<T> と handleDeepLink(intent) で置き換える。共有ViewModelはネストグラフへのスコープ( hiltViewModel(parentEntry) )、別Activityの起動は activity<T> を使う。いずれもNavigation Composeに型安全なAPIがある // ルートの NavHost(計画) setContent { FaansTheme { val navController = rememberNavController() FaansNavHost(navController, startDestination = RootDestination.Splash) { composable<RootDestination.Splash> { ... } homeGraph(navController) // 各モジュールの xxxGraph() を並べる mypageGraph(navController) // 2章で settingsGraph() を組み込み済み } } } 切り替えの前後でも、移行前にシナリオを揃えて統合後に同じシナリオを流す手順をそのまま使います。 モジュール単位で積み上げてきたものを、最後に一度だけ配線し直して この移行を終える計画です。 移行の中で見えてきた課題 共存構造そのものは公式ガイドの延長です。しかし実際に運用すると、「外側と内側にNavControllerが2つある」ことや、FragmentとComposeでライフサイクルやスコープの単位が変わることに起因する課題がいくつか出てきました。 課題1:戻るボタンの連打で画面がホワイトアウトする 最初のモジュールを移行した直後のデザインレビューで、「戻るボタンを連続でタップすると画面が真っ白になる」という指摘を受けました。特定の端末でだけ再現する現象でした。 原因は、 1回目の戻るで画面が切り替わっている最中に、2回目の戻るが実行されていた ことです。モジュール内のNavHostは画面が少ないため、2回目の popBackStack() で起点画面まで消え、NavHostに表示するものがなくなって白い画面になります。 端末の戻るはNavHostが処理し、バックスタック1件では無効になるため、起点画面までは消えません。問題はツールバーの戻るボタンで、ここで popBackStack() を呼んでいました。対応は次のとおりです。 Compose側の戻る処理を dropUnlessResumed で包み、画面が RESUMED でないときのタップを無視する ツールバーの戻るを popBackStack() から navigateUp() に替える。 navigateUp() は バックスタックが1件だけのときはpopしない ので、起点画面が消えない 外側のNavControllerを呼ぶ経路でも同じことが起きるため、 RESUMED 未満なら何もしない safePopBackStack 拡張を用意する // 対応1: dropUnlessResumed で包む / 対応2: navigateUp() を使う onNavigateBack = dropUnlessResumed { navController.navigateUp() } // 対応3: 外側の NavController 用の拡張 fun NavController.safePopBackStack(): Boolean { val currentEntry = currentBackStackEntry ?: return false return if (currentEntry.lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)) { popBackStack() } else { false } } Lifecycle 2.8.0 で追加された dropUnlessResumed は、画面( NavBackStackEntry )が RESUMED でなければブロックを捨てます。 For Navigation users, it's recommended to safeguard navigate methods when using them while a composable is in transition as a result of navigation. — dropUnlessResumed — androidx.lifecycle.compose (KDoc) 要約:遷移アニメーション中のnavigate呼び出しは、この関数でガードすることが推奨されている。 対応2で popBackStack() を navigateUp() に替えたのは、端末の戻ると同じ挙動に寄せるためです。どちらもバックスタック1件でpopしないことは、androidxの navigateUpの実装 と 戻るコールバックの有効条件 で確認できます。 遷移中のタップは無視されるため、まれにもう一度押す必要がある場面はあり得ますが、白い画面になるよりは許容できると判断しました。 課題2:写真ピッカーから戻った直後の遷移が発火しない 課題1で入れた RESUMED のガードを、OSの写真ピッカーから戻った直後の遷移処理にも付けたところ、 遷移が発火しなくなりました 。原因はActivityの仕様です。 An activity can never receive a result in the resumed state. You can count on onResume being called after this method, though not necessarily immediately after. — Activity.onActivityResult — Android API reference 要約:Activityは RESUMED 状態で結果を受け取ることはなく、 onResume はその後に呼ばれる。 結果が配信される時点でActivityは RESUMED ではなく、 NavBackStackEntry のライフサイクルもホストの状態を超えられません。 dropUnlessResumed や RESUMED ガードは画面内のタップ起点にだけ付け、ActivityResultや非同期完了コールバック起点には付けない、という使い分けに落ち着きました。 課題3:未移行の画面を挟んでもバックスタックは失われなかった モジュール内で設定画面からアカウント設定へ進み、そこから未移行のFragmentへ遷移して、戻るを押したとします。期待するのはアカウント設定に戻ることです。ところが未移行のFragmentから戻ると起点のFragmentのビューが再生成されるため、その中のNavHostも作り直されて 設定画面まで巻き戻るのではないか 、と心配していました。 結果は、アカウント設定に戻ります 。 rememberNavController は rememberSaveable で状態を保持しています。そしてFragmentは、バックスタック上でビューを破棄する際に ビューツリーの SavedStateRegistry の状態を保存し、ビューの再生成時に復元します。Navigation Compose側のバックスタックはこの経路で引き継がれるので、問題ありませんでした。 課題4:ボトムシートを開き直しても前の内容が表示される 一覧の項目をタップすると開くボトムシートで、2つ目の項目をタップしても1つ目の内容が表示される現象をQAで見つけました。ボトムシートの中で hiltViewModel() をキーなしで取得していたのが原因です。 hiltViewModel() は 呼び出し元の遷移先( NavBackStackEntry )にスコープされます 。そのため同じ画面の中で条件付きに表示されるボトムシートは、どの項目から開いても同一のViewModelインスタンスを受け取ります。Fragment時代は DialogFragment ごとにViewModelが作られていたので起きなかった挙動でした。 // 修正前: 同じ画面内では常に同じインスタンスが返る viewModel: ItemDetailViewModel = hiltViewModel() // 修正後: 対象IDをキーにして、項目ごとに別のインスタンスにする viewModel: ItemDetailViewModel = hiltViewModel(key = itemId) 対応は対象IDをキーにすることです。ただし同じ形の問題はダイアログや LaunchedEffect でも起きるため、コーディング規約とレビュー観点を2つ追加しました。1つは「条件付きで表示するボトムシート・ダイアログは対象IDをViewModelのキーにする」です。もう1つは「パラメータ付きコンポーネントの初期化は LaunchedEffect(Unit) ではなく識別パラメータをキーにする」です。 課題5:画面遷移テストを書かずにどう担保するか 1-3で書いたとおり、 NavHostの画面遷移テストは書きませんでした 。Navigation Composeには TestNavHostController などテスト用のAPIが用意されています。それなのになぜ書かなかったのか。検討した順に書きます。 TestNavHostController と createComposeRule を使えば、JVM上のRobolectricで遷移テスト自体は書けます。ViewModelを持たない画面では実際に動きました。つまずいたのは hiltViewModel() です。画面のComposableの中で呼んでいると、テスト用の ComponentActivity がHiltのエントリポイントではないため失敗します。解決策は4つ検討しました。 解決策 メリット デメリット 1. NavHostの引数でViewModelを受け取る モックを渡すだけで済む 起点Fragment表示時に全ViewModelが生成される 2. テスト用にNavHostのコピーを作る 本番の引数を増やさない NavHost更新時にテストも同時更新(漏れリスク) 3. Factory引数を持たせ、画面のComposable呼び出し時に生成 1の問題を回避しつつモック可能 NavHostの引数が増える 4. Hiltテスト環境を構築する 本番コードを変えない データ層のテストダブル群が前提 案4は Now in Android が採る方式で、 @HiltAndroidTest で本物のActivityを起動し、 @TestInstallIn でデータ層をテスト用の実装に差し替えます。ただし データ層を最初から差し替えられる設計になっていることが前提 です。FAANSでは遷移テストの前にデータ層を作り替えることになり、移行そのものより大きな作業です。残る案3が最もバランスの良い形でしたが、実装へ進む前に、遷移テストで何を確かめたいのかを整理し直しました。 確かめたいことを分解すると、ボタンからラムダが呼ばれることはScreenのユニットテストで、 navigate(X) でXが表示されることはNavigationライブラリ自身のテストで担保されています。残るのは「ラムダに正しい navigate を渡しているか」というNavHostの配線だけです。公式のテストガイドも、テスト対象をここに限定しています。 The Navigation component handles all the work of managing navigation between destinations, passing arguments, and working with the FragmentManager . These capabilities are already rigorously tested, so there is no need to test them again in your app. What is important to test, however, are the interactions between the app-specific code in your fragments and their NavController . — Test Navigation — Android Developers 要約:ライブラリの機能は再テスト不要。テストすべきなのは、アプリ固有のコードとNavControllerのインタラクション。 その配線も、型安全ルートにしたことで大半のミスはコンパイル時に検出され、残るのはラムダ1行の宣言だけです。 そのためにHiltを含むテスト環境を整えるコストは見合わない と判断し、Screenのユニットテスト・コードレビュー・QAで担保することにしました。1-3のMaestroによる確認は、その後に加わったものです。遷移が複雑でテストが必要な画面については、ダミーデータを用意した結合テストで書く方針です。 移行の進捗と現時点の効果 約半年のあいだ、機能開発と並行して移行を進め、月次のリリースも継続できました。8つの機能モジュールのうち5つの移行が完了し、そのうち1つは親モジュールのNavHostへ統合され、XML側のエントリも削除できています。移行が完了したモジュールでは、「背景・課題」で挙げた二重管理が次のように解消されています。 移行前 移行後(完了したモジュール) 画面を1つ追加するときの作業 Fragmentクラスを作る / XMLに <fragment> と <action> を書く / Safe Argsを生成する / findNavController() で遷移を書く Destinationを追加し、 composable<T> を登録する。遷移のラムダと引数の受け取りも同じ場所に書く 遷移の定義・実行・引数の置き場所 XML / Fragment / Safe Args NavHostの1か所に集まり、引数は @Serializable なクラスでコンパイル時に検査される 副産物として、モジュールごとの画面遷移シナリオが揃い、Maestroで回帰確認できるようになりました。移行の手順もスキルとして残っているので、残りのモジュールは担当者が変わっても同じ進め方ができます。 最後に 残る3つは画面数の多い大型モジュールですが、同じパターンとシナリオで進められる見通しが立っています。その先にあるActivity直下の単一NavHostへの統合は3章の見通しに沿って進め、型安全ルートで揃えてあるのでNavigation 3への移行も同じ延長線上にあります。 Maestroのシナリオは今のところローカルで実行しているだけです。シナリオが揃ってきたらCIで継続的に回し、画面遷移が壊れていないことを自動で確かめられる状態にできればと考えています。実現できるかはまだ見えていませんが、揃ったシナリオの次の使い道として試したいことの1つです。 Fragment資産を抱えたままComposeを進めている方にとって、 既存の画面遷移を止めずに移行を進める1つのやり方 として参考になれば幸いです。 ZOZOでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください。 corp.zozo.com