本文へスキップ
すべての記事

Jetpack Composeにおける型安全なナビゲーション:ルートを文字列ではなくデータとして扱う

文字列ベースのCompose Navigationルートを、シリアライズ可能なKotlinオブジェクトに置き換える方法 — 型安全な引数、ネストしたグラフ、そしてリファクタリングを生き延びるディープリンク。

MFKAPPS 1 分で読めます

Compose Navigationが登場してから最初の数年間、ルートはすべて文字列だった。"item/{itemId}"を組み立て、それをNavHostでマッチさせ、NavBackStackEntryからgetString("itemId")itemIdを取り出す——出るときに書いたキー名と、入るときに書いたキー名が一致していることを祈りながら。コンパイラは何も助けてくれない。どちらの文字列にタイプミスがあってもコンパイルは通り、たいてい変更した本人が見ていない画面で実行時にクラッシュする。Navigation Composeの型安全なルートは、ルートを文字列ではなくKotlinオブジェクトにすることでこれを解決する。実際に稼働中のアプリの全画面をこの方式に移行してみると、引数のタイプミスというクラスのバグはまるごと消えた——コンパイラがビルドの完了前に捕まえてくれる。

ルートをsealed階層として定義する

考え方はシンプルだ。遷移先をsealed interfaceとして定義し、それぞれに@Serializableを付け、ルートへの変換とその逆変換をkotlinx.serializationに任せる。

sealed interface Screen {
    @Serializable
    data object Home : Screen

    @Serializable
    data class ItemDetail(val itemId: Long) : Screen

    @Serializable
    data class EditItem(val itemId: Long, val fromDetail: Boolean = false) : Screen
}

文字列テンプレートも、同期を保つべきキー名も存在しない——引数はクラスのプロパティそのものだ。ナビゲーションは、実際の型を持つ関数呼び出しになる。

navController.navigate(Screen.ItemDetail(itemId = item.id))

itemIdがどこか上流でリネームされたり、型がLongからStringに変更されたりした場合、実行時にパースが失敗する代わりに、すべての呼び出し箇所がコンパイルエラーになる。

NavHost側もこれと対をなす形で、composable<T>()オーバーロードを使う。これは文字列パターンの代わりに型をreifiedパラメータとして受け取る。

NavHost(navController = navController, startDestination = Screen.Home) {
    composable<Screen.Home> {
        HomeScreen(onItemClick = { id -> navController.navigate(Screen.ItemDetail(id)) })
    }

    composable<Screen.ItemDetail> { backStackEntry ->
        val args: Screen.ItemDetail = backStackEntry.toRoute()
        ItemDetailScreen(itemId = args.itemId)
    }

    composable<Screen.EditItem> { backStackEntry ->
        val args: Screen.EditItem = backStackEntry.toRoute()
        EditItemScreen(itemId = args.itemId, cameFromDetail = args.fromDetail)
    }
}

toRoute()はバックスタックエントリを遷移先の型へ直接デシリアライズする。Bundleのキーをタイプミスする余地も、欠けた引数を手動でnullチェックする必要もない——存在しない必須プロパティはそもそもコンパイルが通らず、オプションのプロパティは他のKotlin関数の引数と同様、宣言されたデフォルト値を受け取る。

ネストしたグラフも型安全なまま

同じパターンはネストしたナビゲーショングラフにも及ぶ。ここは以前、文字列ルートが本当に厄介だった箇所だ——ネストしたグラフの開始地点にタイプミスがあっても、静かに失敗して空白の画面が表示されるだけだった。sealedルートを使えば、ネストしたグラフはそれ自体が独立した型になる。

sealed interface OnboardingGraph : Screen {
    @Serializable data object Welcome : OnboardingGraph
    @Serializable data object Permissions : OnboardingGraph
    @Serializable data class Done(val skippedPermissions: Boolean) : OnboardingGraph
}

navigation<OnboardingGraph>(startDestination = OnboardingGraph.Welcome) {
    composable<OnboardingGraph.Welcome> { /* ... */ }
    composable<OnboardingGraph.Permissions> { /* ... */ }
    composable<OnboardingGraph.Done> { entry ->
        val args: OnboardingGraph.Done = entry.toRoute()
        // ...
    }
}

OnboardingGraphScreenを継承しているため、アプリ内の他のコードが外側のグラフに対してナビゲートする際、ネストしたグラフが存在することを知る必要はない——ただnavigate(OnboardingGraph.Welcome)を呼ぶだけで、グラフの境界は文字列のプレフィックスマッチングではなく構造的に解決される。

間違えようのないディープリンク(URIテンプレート不要)

ディープリンクも同じ型付き遷移先に紐づくため、受信したURIは手動でパースする文字列の集まりではなく、実際のオブジェクトへときちんと解決される。

composable<Screen.ItemDetail>(
    deepLinks = listOf(navDeepLink<Screen.ItemDetail>(basePath = "myapp://item"))
) { backStackEntry ->
    val args: Screen.ItemDetail = backStackEntry.toRoute()
    ItemDetailScreen(itemId = args.itemId)
}

myapp://item/42とアプリ内のnavigate(Screen.ItemDetail(42))は、どちらも同じcomposableに同じ型付き引数を伴って到達する——通知のタップから来ようと、ウィジェットから来ようと、アプリ内のボタンから来ようと、ItemDetailという遷移先の形を定義している場所はただ一つだけだ。

移行前に知っておくべき2つのこと

ルート内のカスタム型には独自のNavTypeが必要になる。 LongString型の引数はそのまま動くが、enumやvalue classを保持するルートには、composable呼び出しに登録する小さなNavType<T>の実装が必要になる。バックスタックは最終的にルートをシリアライズされた文字列として保存するため、自分の型をどうエンコード・デコードするかを知っておく必要があるからだ。数行で済むとはいえ、タダではない——プリミティブでない引数を必要とする最初の画面では、その分の時間を見込んでおくといい。

ルート側からではなく、末端の画面から移行する。 それらを抱えるグラフより先に一番内側の画面を変換していけば、すべてのルートが完了するまで何もコンパイルが通らないビッグバン方式の書き換えではなく、文字列ルートと型安全なルートを移行の途中でも並行して動かせる。私はオンボーディング、次にアイテム詳細、そして設定と、一度に1つのフローずつ移し、それぞれの後に手動でナビゲーションを確認した。その結果、状態が不整合なファイルが数個以上に膨らむことは一度もなかった。

結果として得られるのは、より速いナビゲーションコードではない。以前ならコードレビューをすり抜けていたクラスのバグ——リネームされた引数が間違った型のままパースされてしまう、というたぐいのもの——がそもそも存在しなくなったナビゲーションコードだ。一人で保守しているアプリでは、タイプミスを拾ってくれる二人目のレビュアーはいない。だからこそ、これが何よりも重要な論点になる。