inSmartBank

AI家計簿アプリ「ワンバンク」を開発・運営する株式会社スマートバンクの Tech Blog です。より幅広いテーマはnoteで発信中です https://note.com/smartbankinc

Navigation 2 → 3 を段階移行する — 共存アーキテクチャの設計とAIによる自動化

スマートバンク の yokomii です。前回の記事では Navigation 3 における Shared ViewModel の実現方法について書きました。今回はその続編として、実際に私たちが提供する「ワンバンク」の Android アプリで Navigation 2 から Navigation 3 への移行をどう進めたか、そしてその移行作業を AI コーディングエージェントのスキル(プロンプト)で自動化した取り組みについてお話しします。

背景: 83 個の NavGraph を移行する

ワンバンクの Android アプリは Gradle Multi-Module 構成で、app モジュールと複数の feature モジュールに分かれています。Navigation 2 で構築された NavGraph は 83 個。これを1つずつ手作業で Nav3 に書き換えていくのは、率直に言って気が遠くなる作業量です。

しかも、NavGraph の移行は単純な文字列置換では済みません。NavGraph ごとに画面数もネスト構造も異なりますし、他の NavGraph との依存関係も絡んできます。構造に応じた変換が必要であり、さらに移行順序を間違えるとビルドが通らなくなるリスクもありました。

そこで、移行パターンを体系化し、AI コーディングエージェント(Claude Code)のスキルとして実装することで、判断を伴う移行作業を自動化するアプローチを取りました。

公式の移行ガイドとワンバンクのアプローチ

Navigation 3 の公式マイグレーションガイドは、Nav2 のコードを アトミックに(一度に)Nav3 に置き換える方法を案内しています。Route に NavKeyandroidx.navigation3.runtime.NavKey)を実装する、composable<T>entry<T> に置き換える、NavGraphBuilderEntryProviderScope に変えるといった基本的な変換パターンは、このガイドに沿った形です。

ただし、公式ガイドは Nav2 と Nav3 の段階的な共存をサポートしていません。ガイドにも「Navigation 2 コードと Navigation 3 コードを併用する増分移行を行っていない」と明記されています。アプリの規模が小さければ一括移行も現実的ですが、83 個の NavGraph を持つワンバンクではそうはいきません。一括で書き換えると差分が巨大になりレビューが困難ですし、問題が起きたときの切り分けも難しくなります。

そこでワンバンクでは、Nav2 と Nav3 を共存させながら1つずつ段階的に移行する戦略を取りました。公式が提供していない interop の仕組みを自前で構築する必要がありましたが、それによって得られた「1 NavGraph ずつ移行・レビュー・リリースできる」という安全性は、83 個の移行を進める上で不可欠でした。

移行パターンの分類

まず、NavGraph の書き換え方を構造に応じて分類しました。

パターン A: フラット — 画面が1つだけ、またはサブグラフを持たない NavGraph です。Nav2 の composable<Route> が Nav3 の entry<Route> に変わる、比較的シンプルな変換です。公式マイグレーションガイドで紹介されている変換パターンがほぼそのまま適用できるため、具体例はそちらを参照してください。移行対象の中では多数派でした。

パターン B: ネストグラフ — 複数の画面を持ち、NavGraph 内部で画面間遷移が発生するケースです。公式ガイドでは「複数レベルのネストされたナビゲーション」はサポート対象外とされています。

ワンバンクでは 前回の記事 で設計した独自のユーティリティで対応しました。Nav2 の navigation() によるネストグラフでは、ViewModel をグラフ内の複数画面で共有するためにボイラープレートが必要でしたが、独自の graphEntry + childEntry という DSL を導入しています。graphEntry がネストグラフの親スコープを表し、その中の childEntry が個々の画面を定義します。graphEntry のスコープで ViewModel を生成するだけで全 childEntry から自然に共有できるのが特徴です(詳細は前回の記事を参照してください)。

パターンが定まれば、次の問題は「83 個をどう段階的に移行していくか」です。

Nav2 と Nav3 ではナビゲーションの構築方法がまったく異なります。Nav2 では NavGraphBuilder の拡張関数として composable<Route> { ... } で画面を登録しますが、Nav3 では EntryProviderScope の拡張関数として entry<Route> { ... } で画面を登録します。API の見た目は似ていますが、内部の仕組みが異なるため互換性はありません。Nav2 の NavHost の中から Nav3 の entry を呼ぶことはできませんし、逆もまた然りです。

この断絶を埋めるために、2つのユーティリティを作りました。

  • navBridgeComposable(暫定方式) — Nav2 の親 NavGraph の中に Nav3 の画面を埋め込む。親がまだ Nav2 のまま、子を先行して Nav3 化したい場合に使う
  • navDisplayNavGraph(最終方式) — Nav3 化が完了した NavGraph をルートの NavHost に直接統合する。すべての子が Nav3 化された NavGraph の最終形

アイデアはシンプルです。Nav2 の composable の中で Nav3 の NavDisplay を起動してしまえば、その内側は Nav3 の世界として動作します。外側(親 NavGraph)から見れば普通の Nav2 画面ですが、内側では Nav3 のバックスタック管理や画面遷移が独立して動きます。

/**
 * Nav2 の NavGraphBuilder 内に Nav3 の NavDisplay を埋め込む composable を登録する。
 *
 * Nav2 → Nav3 の段階的移行で、親 NavGraph がまだ Nav2 のまま
 * 子 NavGraph 単位で Nav3 化したい場合に使う。
 *
 * @param startRouteSelector Route から NavBackStack の初期 NavKey を抽出する関数。
 *   Route 自体が NavKey を実装している場合はセレクター不要のオーバーロードを使える。
 * @param additionalTypeMap Route のプロパティに NavKey のサブタイプがある場合に、
 *   その型の NavType を追加で指定する。
 */
inline fun <reified T : Any> NavGraphBuilder.navBridgeComposable(
    navController: NavHostController,
    additionalTypeMap: Map<KType, NavType<*>> = emptyMap(),
    crossinline startRouteSelector: (T) -> NavKey,
    crossinline entryProviderBuilder: EntryProviderScope<NavKey>.(NavBridgeNavigator) -> Unit,
) {
    // Nav2 の composable として登録する
    composable<T>(
        // navKeyNavType(): NavKey を JSON でシリアライズ/デシリアライズする独自の NavType 実装
        typeMap = mapOf(typeOf<NavKey>() to navKeyNavType()) + additionalTypeMap,
    ) { entry ->
        // Nav2 の Route から Nav3 の初期 NavKey を取り出す
        val route = entry.toRoute<T>()
        val backStack = rememberNavBackStack(startRouteSelector(route))

        // Nav3 のバックスタック末尾で戻ろうとしたとき、Nav2 側の popBackStack を呼ぶ Navigator
        val navigator = rememberNavBridgeNavigator(backStack, navController)

        // この中は Nav3 の世界
        // 実際には NavDisplay をラップした独自の Composable を使用している(EntryDecorator や SceneStrategy の設定を共通化するため)
        NavDisplay(
            backStack = backStack,
            entryProvider = entryProvider {
                entryProviderBuilder(navigator)
            },
            onBack = { navigator.goBack() },
        )
    }
}

外側は Nav2 の composable<T> で、Nav2 のルーティングシステムに普通に登録されます。typeMap には NavKey のシリアライズ/デシリアライズを処理する独自の NavType を渡しています。Nav2 は Route を SavedStateHandle 経由で保存・復元するため、Nav3 の NavKey をそこに載せるには JSON シリアライズが必要になります。navKeyNavType() はその変換を担う実装です。

内側では Nav3 標準の rememberNavBackStack でバックスタックを作り、NavDisplay を起動します。ここから先は完全に Nav3 の世界で、entry<T>graphEntry が使えます。rememberNavBridgeNavigator は後述する独自の NavBridgeNavigatorremember するヘルパーです。

ここで登場する Navigator はワンバンク独自のクラスです。Nav3 の NavBackStackSnapshotStateList ベースで、遷移操作の API は add / removeLast といったリスト操作がそのまま露出しています。これを navigate() / goBack() / navigateReplacingCurrent() といったナビゲーションの意図が伝わるメソッドでラップしたのが Navigator です。

abstract class Navigator<K : NavKey>(internal val backStack: NavBackStack<NavKey>) {
    fun navigate(route: K) {
        if (backStack.lastOrNull() == route) return  // 重複遷移を防ぐ
        backStack.add(route)
    }

    fun navigateReplacingCurrent(route: K) {
        val current = backStack.lastOrNull()
        if (current != null) navigateReplacingTo(route, popUpTo = current)
        else navigate(route)
    }

    fun navigateReplacingTo(route: K, popUpTo: NavKey, inclusive: Boolean = true) {
        // Nav2 の popUpTo オプションに相当: 指定した Route まで戻してから遷移する
        val popIndex = backStack.indexOfLast { it == popUpTo }
        if (popIndex != -1) {
            val from = if (inclusive) popIndex else popIndex + 1
            if (from in 0 until backStack.size) backStack.subList(from, backStack.size).clear()
        }
        navigate(route)
    }

    open fun goBack() { backStack.removeLastOrNull() }
}

そして NavBridgeNavigatorNavigator を継承し、Nav2 との境界をまたぐ「戻る」操作を追加したクラスです。goBack() をオーバーライドし、バックスタックの残りが1つ(= Nav3 領域の最初の画面)の状態で「戻る」が発生したとき、Nav2 側の navController.popBackStack() を呼んで親の NavGraph に戻ります。

class NavBridgeNavigator(
    backStack: NavBackStack<NavKey>,
    private val nav2Controller: NavHostController,
) : Navigator<NavKey>(backStack) {
    override fun goBack() {
        if (backStack.size == 1) {
            // Nav3 領域の最初の画面 → Nav2 側に戻る
            nav2Controller.popBackStack()
        } else {
            super.goBack()
        }
    }
}

これにより、ユーザーは Nav2/Nav3 の境界を意識することなく自然に画面遷移できます。なお、Nav2 側からシステムの戻るボタンが押された場合は、Nav2 の BackStackEntry が NavHost から除去されるため、内部の Nav3 NavDisplay も Composable のライフサイクルに従って自然に破棄されます。

また、Nav2 の Route が NavGraphKey(前回の記事で紹介したネストグラフ用のインターフェース)を実装している場合は、子 Route の型情報を typeMap に自動登録するオーバーロードも用意しています。

// NavGraphKey 実装の Route 用オーバーロード
// CK(子 Route 型)の NavType を typeMap に自動登録するため、呼び出し側で手動指定が不要
@JvmName("navBridgeComposableWithGraphKey")
inline fun <reified T, reified CK : NavGraphChildKey> NavGraphBuilder.navBridgeComposable(
    navController: NavHostController,
    crossinline entryProviderBuilder: EntryProviderScope<NavKey>.(NavBridgeNavigator) -> Unit,
) where T : NavGraphKey<CK> {
    navBridgeComposable<T>(
        navController,
        additionalTypeMap = mapOf(typeOf<CK>() to navKeyNavType()),
        startRouteSelector = { it },
        entryProviderBuilder = entryProviderBuilder,
    )
}

navBridgeComposable は個々の NavGraph を Nav3 化するための仕組みですが、アプリのルートはまだ Nav2 の NavHost なので、Nav3 の NavGraph をその直下に羅列することはできません。Nav3 化された NavGraph ごとに個別の navBridgeComposableNavHost に配置すると、Nav2 のルーティングテーブルに Nav3 の画面が散在して管理が煩雑になります。そこで、Nav3 化された NavGraph を1箇所に集約し、Nav2 の NavHost から呼び出せるエントリーポイントを1つだけ用意しました。それが navDisplayNavGraph です。

// Nav3 の世界への入口となる Nav2 Route。startRoute に遷移先の NavKey を持つ
@Serializable private data class NavDisplayRoute(val startRoute: NavKey)

// Nav2 側から Nav3 の任意の画面に遷移するための拡張関数
internal fun NavController.navigateToNavDisplay(
    startRoute: NavKey,
    builder: NavOptionsBuilder.() -> Unit = {},
) {
    // 同じ画面への重複遷移を防ぐ: 現在表示中の startRoute と同じなら launchSingleTop で重複を抑制
    // 現在の BackStackEntry が NavDisplayRoute でない場合、toRoute() は例外を投げるため防御
    val currentStartRoute = runCatching {
        currentBackStackEntry?.toRoute<NavDisplayRoute>()?.startRoute
    }.getOrNull()
    navigate(NavDisplayRoute(startRoute)) {
        launchSingleTop = currentStartRoute == startRoute
        builder()
    }
}

// NavHost 内に1つだけ配置する Nav3 のエントリーポイント
// Navigator<NavKey> は前述の独自クラス
internal fun NavGraphBuilder.navDisplayNavGraph(
    navController: NavHostController,
    entryProviderBuilder: EntryProviderScope<NavKey>.(Navigator<NavKey>) -> Unit,
) {
    navBridgeComposable<NavDisplayRoute>(
        navController,
        startRouteSelector = { it.startRoute },
        entryProviderBuilder = entryProviderBuilder,
    )
}

navDisplayNavGraph 自体も navBridgeComposable を使っています。NavDisplayRoute は Nav2 の Route ですが startRoute: NavKey プロパティを持ち、startRouteSelector でそこから Nav3 の初期 NavKey を取り出します。

MainScreenNavHost にはこの navDisplayNavGraph が1つだけ配置され、Nav3 化された全 NavGraph の画面定義がここに集約されます。

// MainScreen.kt(イメージ)
NavHost(navController, startDestination = ...) {
    // Nav2 のままの NavGraph たち
    featureANavGraph(navController, ...)
    featureBNavGraph(navController, ...)

    // Nav3 化された NavGraph はすべてここに統合される
    navDisplayNavGraph(navController) { navigator ->
        featureC(navigator = navigator, ...)
        featureD(navigator = navigator, ...)
        featureE(navigator = navigator, ...)
        // ... Nav3 化が進むたびにここが増えていく
    }
}

移行の進め方: 葉から根へ

この2つの仕組みにより、移行は以下のように段階的に進みます。

  1. 末端の NavGraph(他の NavGraph を内部に持たないもの)を Nav3 にリライトする
  2. 親が Nav2 のままなら navBridgeComposable で埋め込む(暫定方式)
  3. 親がすでに Nav3 化されている、あるいはルートの NavHost から直接呼ばれているなら navDisplayNavGraph に統合する(最終方式)
  4. すべての子が Nav3 化されたら、親も Nav3 にリライトして navDisplayNavGraph に昇格させる

木構造の葉から根に向かって移行するイメージです。逆順(親を先に移行)にすると、Nav3 の EntryProviderScope 内から Nav2 の NavGraphBuilder を呼べないため、ビルドエラーになります。

実例: 一覧画面 + 詳細画面の移行(パターン B)

ここでは、一覧画面と詳細画面の2画面を持ち、ViewModel を共有するネストグラフの移行例を示します。パターン B の典型的なケースです。

Before: Nav2

// ── Route 定義 ──
@Serializable data object ItemHistoryHostRoute  // ネストグラフのルート(ViewModel のスコープ)
@Serializable data object ItemHistoryRoute       // 一覧画面
@Serializable data object ItemDetailRoute        // 詳細画面

// ── NavGraph 定義 ──
fun NavGraphBuilder.itemHistoryNavGraph(
    navController: NavHostController,
    navigateToHelp: () -> Unit,
    navigateToOtherFeature: () -> Unit,
) {
    // ネストグラフ: ItemHistoryHostRoute をルートとし、内部に2画面を持つ
    navigation<ItemHistoryHostRoute>(
        startDestination = ItemHistoryRoute::class,
    ) {
        composable<ItemHistoryRoute> { entry ->
            // 親の BackStackEntry を取得して ViewModel を共有する
            val parentEntry = remember(entry) {
                navController.getBackStackEntry<ItemHistoryHostRoute>()
            }
            val viewModel = hiltViewModel<ItemHistoryViewModel>(parentEntry)
            ItemHistoryScreen(
                viewModel = viewModel,
                onClickBack = { navController.popBackStack() },
                onClickHelp = navigateToHelp,
                onClickItem = { navController.navigateToItemDetail() },
            )
        }
        composable<ItemDetailRoute> { entry ->
            // 同じパターンで親の ViewModel を取得
            val parentEntry = remember(entry) {
                navController.getBackStackEntry<ItemHistoryHostRoute>()
            }
            val viewModel = hiltViewModel<ItemHistoryViewModel>(parentEntry)
            ItemDetailScreen(
                item = viewModel.selectedItem,
                onClickBack = { navController.popBackStack() },
            )
        }
    }
}

Nav2 でネストグラフ内の画面間で ViewModel を共有するには、navController.getBackStackEntry<ParentRoute>() で親の BackStackEntry を取得し、それを viewModelStoreOwner として渡す必要があります。このボイラープレートが各 composable ブロックに毎回登場します。

画面遷移は navController を直接操作します。navigateToItemDetail() のような NavHostController の拡張関数を定義して Route オブジェクトを navigate に渡すスタイルです。どの遷移がグラフ内部でどれが外部かは、コードの読み手が文脈から判断するしかありません。

After: Nav3

// ── Route 定義 ──

// 外部からの遷移先 かつ ネストグラフのルート
// NavKey は Nav3 標準のインターフェース(すべての Route が実装する)
// NavGraphKey・NavGraphChildKey は前回の記事で設計した独自インターフェースで、
// ネストグラフの親子関係を型で表現する
@Serializable data object ItemHistoryRoute : NavKey, NavGraphKey<ItemHistoryChildRoute> {
    override val startChildKey = ItemHistoryChildRoute.List
}

// 子 Route を sealed interface で定義。NavGraphChildKey(独自)を実装する
// グラフ内部でのみ使うため private
private sealed interface ItemHistoryChildRoute : NavGraphChildKey {
    @Serializable data object List : ItemHistoryChildRoute
    @Serializable data object Detail : ItemHistoryChildRoute
}

// ── NavGraph 定義 ──

fun EntryProviderScope<NavKey>.itemHistory(
    navigator: Navigator<NavKey>,       // 前述の独自 Navigator。親レベルの遷移に使う
    navigateToHelp: () -> Unit,         // 外部画面への遷移はコールバックで受け取る
    navigateToOtherFeature: () -> Unit,
) {
    // graphEntry(独自 DSL)で囲むと、内部の childEntry 間で ViewModel を自然に共有できる
    // 第1型パラメータ: 親 Route、第2型パラメータ: 子 Route の sealed interface
    graphEntry<ItemHistoryRoute, ItemHistoryChildRoute>(
        parentNavigator = navigator,
    ) { _, graphNavigator ->
        // graphEntry のスコープで ViewModel を生成 → 全 childEntry から参照可能
        // Nav2 のように parentEntry を取得するボイラープレートは不要
        val viewModel = hiltViewModel<ItemHistoryViewModel>()

        childEntry<ItemHistoryChildRoute.List> {
            ItemHistoryScreen(
                viewModel = viewModel,
                onClickBack = { graphNavigator.goBack() },           // グラフ内の「戻る」
                onClickHelp = navigateToHelp,                        // 外部遷移はコールバック
                onClickItem = {
                    graphNavigator.navigate(ItemHistoryChildRoute.Detail) // グラフ内遷移
                },
            )
        }

        childEntry<ItemHistoryChildRoute.Detail> {
            ItemDetailScreen(
                item = viewModel.selectedItem, // ViewModel を共有しているので直接参照
                onClickBack = { graphNavigator.goBack() },
            )
        }
    }
}

Nav3 版には大きく4つのメリットがあります。

  1. Route 構造が階層的になる
    Before では ItemHistoryHostRoute(親)、ItemHistoryRoute(一覧)、ItemDetailRoute(詳細)の3つが独立した Route でした。After では ItemHistoryRoute が「外部からの遷移先」と「ネストグラフの親」を兼ね、子 Route は ItemHistoryChildRoute という sealed interface にまとめています。sealed interface にすることで、子 Route の網羅性をコンパイラがチェックできるほか、子 Route を private にして公開 API を親の ItemHistoryRoute だけに絞れます。Before のように HostRoute と一覧画面の Route を分離する必要がなくなったのは、graphEntry が親 Route 自体を ViewModel のスコープとして扱えるためです。

  2. ViewModel 共有がシンプルになる
    Nav2 では親の BackStackEntry を取得するボイラープレートが各画面に必要でしたが、Nav3 では graphEntry のスコープで ViewModel を生成するだけで、全 childEntry から自然に参照できます。

  3. 遷移操作が2層に分かれる
    graphNavigator はグラフ内部の画面遷移(List ↔ Detail)を担当し、navigator は親レベルの遷移(グラフ外への移動)を担当します。Nav2 では1つの navController がすべてを管理していたため、どの遷移がグラフ内部でどの遷移が外部かはコードの読み手が文脈から判断する必要がありましたが、Nav3 では型レベルで区別されます。

  4. 外部遷移がコールバック化される
    navigateToHelp のような他の NavGraph への遷移は、NavGraph 関数の引数としてコールバックで受け取ります。これにより NavGraph が navController に依存しなくなり、feature モジュール内で完結するようになりました。

AI コーディングエージェントのスキルで自動化する

ここまで見てきたように、移行パターン自体は体系化できます。しかし実際の移行では、各 NavGraph に対して「どのパターンを適用するか」「どの遷移が内部でどれが外部か」「どの順序で移行を進めるか」といった、コードベースの文脈を踏まえた判断が随所で求められます。こうした判断と変換ルールの正確な適用を両立するために、AI コーディングエージェント(Claude Code)のスキルとして実装しました。

スキル構成

移行作業を4つのスキルとそれを束ねるオーケストレーターに分解しました。

/orchestrate-nav3-migrate(オーケストレーター)
  │
  ├─ refactor-nav3-migrate-analyze        … 構造分析・パターン判定・移行順序の決定
  │
  ├─ refactor-nav3-migrate-rewrite        … NavGraph の Nav3 リライト
  │
  ├─ refactor-nav3-migrate-navbridge      … 暫定方式: navBridgeComposable での統合
  │
  └─ implement-nav3-integrate-navdisplay  … 最終方式: navDisplayNavGraph への統合

/orchestrate-nav3-migrate を実行して対象の NavGraph ファイルを指定すると、分析 → リライト → 統合 → ビルド確認 → コミットまでが一貫して進行します。各フェーズの間にはユーザー確認が入るため、判断結果をレビューしながら進められます。

分析スキル: 移行の前提条件を洗い出す

分析スキルは移行全体の起点です。指定された NavGraph のコードを読み取り、移行に必要な情報を自動で判定・出力します。

最も重要なのが ブロッカー検出です。例えば、ある NavGraph がまだ Nav2 の子 NavGraph を内部で呼び出している場合、親を先に Nav3 化すると EntryProviderScope の中から NavGraphBuilder を呼ぶことになり、ビルドが通りません。分析スキルがこの依存関係を事前に検出し、「この子 NavGraph を先に暫定方式で移行してください」と提案します。

また、同一 feature モジュール内の NavGraph 同士が互いの Route を参照し合っているケースも検出し、「これらは同一サブタスクで移行する必要がある」と報告します。片方だけ移行すると、もう片方から参照している Route や navigateToXxx() 関数の型が合わなくなりビルドエラーになるためです。

分析結果はマークダウンの表形式で出力され、Route の変更計画(どの Route を public に残しどれを private にするか)、navigateToXxx() 関数の内部/外部分類、統合方式の判定理由などが一覧で確認できます。

リライトスキル: 変換ルールを正確に適用する

分析結果に基づいて、実際にコードを書き換えるスキルです。先ほどの Before/After の例で見たような変換を、分析で確定した計画に従って正確に実行します。

特に工夫が必要だったのが ViewModel の引数受け渡しの変換です。Nav2 では SavedStateHandle.toRoute<XxxRoute>() で Route から引数を取り出していましたが、Nav3 では Route オブジェクトが entry / childEntry のラムダ引数として Composable 側に直接渡されます。つまり Route の引数は Composable が受け取るものになるため、ViewModel のコンストラクタに SavedStateHandle 経由で自動注入する従来の方法が使えなくなります。代わりに Hilt の @AssistedInject を使い、Composable 側から ViewModel のファクトリ経由で引数を渡す形に変わります。この変換は NavGraph ファイルだけでなく ViewModel クラスのコンストラクタやファクトリインターフェースも書き換える必要があり、変更範囲が複数ファイルにまたがります。

統合スキル: 既存のナビゲーション構造に組み込む

リライトが完了した NavGraph を、アプリのナビゲーション構造に接続するスキルです。最終方式(navDisplay 統合)と暫定方式(navBridge 統合)で別々のスキルになっています。

最終方式では MainScreen.ktnavDisplayNavGraph ブロックに新しい呼び出しを追加し、外部遷移のコールバックを実装します。暫定方式では既存の Nav2 親 NavGraph に navBridgeComposable を追加するだけなので、影響範囲を限定した移行が可能です。

スキルにナレッジを蓄積する

最初に作ったスキルは主要なパターンしかカバーしていませんでしたが、移行を進めるたびに新しいエッジケースが見つかりました。

いくつか具体例を挙げます。

  • popBackStack() + navigate()navigateReplacingCurrent(): Nav2 で画面を置き換えるために popBackStack() してから navigate() する2段階の操作は、Nav3 では navigateReplacingCurrent() という単一の操作に対応します。なお navigateReplacingCurrent() や次の navigateReplacingTo() は Nav3 標準 API ではなく、前述の独自 Navigator に実装したメソッドです。Nav3 のバックスタックはリスト操作で直接制御するため、こうしたよくあるパターンをメソッドとして用意しておくと変換ルールが明確になります
  • popUpTo + inclusivenavigateReplacingTo(): Nav2 の navigate オプションで popUpTo を使って特定画面まで戻してから別画面に遷移するパターンは、navigateReplacingTo(route, popUpTo, inclusive) への変換が必要でした
  • ネストグラフからの外部遷移時のバックスタック操作: ネストグラフ内で外部画面に遷移する際、graphNavigator.goBack() でグラフ自体をバックスタックから除去してから外部遷移コールバックを呼ぶ必要があります。ここで誤ってグラフ内のスタックリセット用 API(startChildKey への遷移)を使うと、グラフのエントリが再生成されてコールバックが繰り返し呼ばれるループに陥るという問題がありました
  • @Assisted パラメータの命名: Route の引数を ViewModel に @AssistedInject で渡す際、プリミティブ型(Boolean など)が複数あると Hilt が型だけでは区別できないため、明示的な名前が必要です。この命名ルールは何度かビルドエラーに遭遇して初めて体系化できました

こうしたパターンを発見するたびにスキルに追記していくことで、移行回数を重ねるほどスキルの精度が上がる好循環が生まれました。同じエッジケースで2度つまずくことがないのは、スキルにナレッジを蓄積するアプローチの大きなメリットです。

まとめ

Navigation 2 → 3 の移行は、83 個の NavGraph を対象とした大規模なリファクタリングです。公式マイグレーションガイドが一括移行を前提としている中で、navBridgeComposable による Nav2/Nav3 共存の仕組みを自前で構築し、移行パターンを体系化した上で AI コーディングエージェントのスキルとして実装することで、安全に段階的な移行を進めることができています。現在も移行は進行中であり、1 NavGraph ずつ着実に Nav3 化を進めています。

大規模なコード移行は「やり方がわかっている反復作業」と「コードベースの文脈を踏まえた判断」が混在します。その両方を AI コーディングエージェントのスキルとして記述し、ナレッジを蓄積しながら回せるようにしたことで、移行の速度と精度を両立できています。同様の移行作業を検討されている方の参考になれば幸いです。

We create the new normal of easy budgeting, easy banking, and easy living.
In this tech blog, engineers and other members will share their insights.