Jetpack Composeにおける型安全なナビゲーション:ルートを文字列ではなくデータとして扱う
文字列ベースのCompose Navigationルートを、シリアライズ可能なKotlinオブジェクトに置き換える方法 — 型安全な引数、ネストしたグラフ、そしてリファクタリングを生き延びるディープリンク。
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を組み立てる
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()
// ...
}
}
OnboardingGraphがScreenを継承しているため、アプリ内の他のコードが外側のグラフに対してナビゲートする際、ネストしたグラフが存在することを知る必要はない——ただ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が必要になる。 LongやString型の引数はそのまま動くが、enumやvalue classを保持するルートには、composable呼び出しに登録する小さなNavType<T>の実装が必要になる。バックスタックは最終的にルートをシリアライズされた文字列として保存するため、自分の型をどうエンコード・デコードするかを知っておく必要があるからだ。数行で済むとはいえ、タダではない——プリミティブでない引数を必要とする最初の画面では、その分の時間を見込んでおくといい。
ルート側からではなく、末端の画面から移行する。 それらを抱えるグラフより先に一番内側の画面を変換していけば、すべてのルートが完了するまで何もコンパイルが通らないビッグバン方式の書き換えではなく、文字列ルートと型安全なルートを移行の途中でも並行して動かせる。私はオンボーディング、次にアイテム詳細、そして設定と、一度に1つのフローずつ移し、それぞれの後に手動でナビゲーションを確認した。その結果、状態が不整合なファイルが数個以上に膨らむことは一度もなかった。
結果として得られるのは、より速いナビゲーションコードではない。以前ならコードレビューをすり抜けていたクラスのバグ——リネームされた引数が間違った型のままパースされてしまう、というたぐいのもの——がそもそも存在しなくなったナビゲーションコードだ。一人で保守しているアプリでは、タイプミスを拾ってくれる二人目のレビュアーはいない。だからこそ、これが何よりも重要な論点になる。
// 関連記事
ジャーナルの他の記事
Android の In-App Review API:うっとうしくならずに評価を頼む方法
Google の In-App Review API の実践ガイド。実際の仕組み、表示すべきタイミング、そしてありがちな『評価してください』ポップアップが Play ストアの評価を静かに損なっている理由。
Androidのアプリショートカットとクイック設定タイル:アプリを開かずに水分補給を記録する
AndroidのダイナミックShortcutManager APIとTileServiceの実践ガイド — ワンタップの操作でアプリ起動を完全にスキップする方法と、多くの実装が陥りがちな落とし穴について。
2026年のAndroidにおけるフォアグラウンドサービスタイプ: 自分の機能に本当に当てはまるものを選ぶ
Androidのフォアグラウンドサービスタイプの制限に関する実践ガイド — dataSync、mediaPlayback、specialUse、shortService — 強制終了や審査却下を避けて正しいタイプを選ぶ方法。