はじめに
NavigationやScreen遷移を「文字列やID」ではなく型で保証するルーティング設計
1. 従来の問題:文字列ベースルーティングの限界
例えば、よくある Navigation の実装:
navController.navigate("detail/123")
問題点:
-
"detail/123"は文字列依存で型安全性ゼロ - 引数の数・型・順序が保証されない
- 画面遷移が増えると「typo」「バージョン不整合」が頻発
これを Kotlin の型システムを活かして、
コンパイル時にルートの整合性を保証できる仕組みを作ります。
2. Reified + Sealed + Generic の基本構成
コアアイデア:
-
sealed class Route<T>で 「行き先と型」 を定義 -
inline + reifiedで 実行時にも型を保持 -
RouteBuilderDSL で 宣言的に遷移表現
3. 型安全な Route 定義
sealed class Route<T>(
val path: String
) {
data object Home : Route<Unit>("home")
data class Detail(val id: Int) : Route<Detail>("detail/$id")
data class Profile(val userId: String) : Route<Profile>("profile/$userId")
}
特徴:
-
sealed classにより、定義済みルート以外はコンパイルエラー - 各 Route が自分の引数を保持(
id,userIdなど) - 型パラメータ
<T>により 結果型や引数型を結びつけられる
4. RouteBuilder DSL の実装
@DslMarker
annotation class RouteDsl
@RouteDsl
class RouteBuilder {
private val routes = mutableListOf<Route<*>>()
fun route(route: Route<*>) {
routes += route
}
fun build(): List<Route<*>> = routes
}
fun navigation(block: RouteBuilder.() -> Unit): List<Route<*>> =
RouteBuilder().apply(block).build()
利用例:
val appRoutes = navigation {
route(Route.Home)
route(Route.Detail(42))
route(Route.Profile("anna"))
}
結果:
[Route.Home, Route.Detail(id=42), Route.Profile(userId=anna)]
5. Navigation DSL の組み立て(with reified)
今度は、reified 型を使って安全に遷移を実行します。
inline fun <reified R : Route<*>> navigateTo(route: R) {
println("Navigating to ${route.path} (${R::class.simpleName})")
}
利用:
navigateTo(Route.Detail(10))
navigateTo(Route.Profile("Anna"))
出力:
Navigating to detail/10 (Detail)
Navigating to profile/Anna (Profile)
-
R::class.simpleNameで実行時の型が利用可能 - 未定義のルートは sealed class により 定義漏れを防止
6. 型付き引数と戻り値を持つルート
次に、「遷移引数と戻り値」を型安全に定義してみます。
sealed interface RouteSpec<Arg, Result> {
val path: String
}
object HomeRoute : RouteSpec<Unit, Unit> {
override val path = "home"
}
data class DetailRoute(val id: Int) : RouteSpec<Int, String> {
override val path get() = "detail/$id"
}
そして reified × generic なナビゲーションAPI:
inline fun <reified Arg, reified Result> navigate(
route: RouteSpec<Arg, Result>,
arg: Arg,
onResult: (Result) -> Unit
) {
println("Navigate → ${route.path} with arg=$arg")
// 実際には Intent や NavHost を呼ぶ
val mockResult: Result = "OK" as Result
onResult(mockResult)
}
使用例:
navigate(DetailRoute(42), 42) { result ->
println("Got result: $result")
}
出力:
Navigate → detail/42 with arg=42
Got result: OK
Arg と Result が 型パラメータでバインドされているため、
引数・戻り値のミスマッチがコンパイル時に検出されます。
7. Compose / Android / KMM への応用
Jetpack Compose に統合
@Composable
fun NavGraphDSL() {
val navController = rememberNavController()
NavHost(navController, startDestination = Route.Home.path) {
composable(Route.Home.path) { HomeScreen(navController) }
composable("detail/{id}") { backStack ->
val id = backStack.arguments?.getString("id")?.toInt() ?: 0
DetailScreen(id)
}
}
}
→ Route オブジェクトを使うことで、
-
Route.Detail(10).path→"detail/10" - NavigationGraph と定義が 1箇所で同期
Kotlin Multiplatform (KMM)
-
RouteSpec<Arg, Result>は共通コードに置ける - Android / iOS 両方で同じ型安全DSLを共有可能
- AndroidではIntentに、iOSではCoordinatorにマッピング可能
8. 設計パターンまとめ
| 要素 | 概念 | 効果 |
|---|---|---|
sealed class |
ルートの有限集合化 | 定義漏れ・typo防止 |
reified |
実行時型保持 | DSLで R::class 参照可能 |
generic |
Arg/Result の型束縛 | 引数と戻り値の整合性を保証 |
@DslMarker |
スコープ安全 | ネストした DSL の誤参照防止 |
RouteBuilder |
宣言的構築 | NavigationMap の組み立てを簡略化 |
RouteSpec<Arg, Result> |
汎用プロトコル化 | Compose / Intent / iOS 共有可能 |
まとめ
| 技術 | 役割 |
|---|---|
| Reified | 実行時型の取得を可能にする |
| Sealed | 限定的なルート定義を強制 |
| Generic | Arg / Result の型をバインド |
| DSL構文 | 宣言的で誤りに強いルーティング定義 |
Kotlin DSL の力で、ルーティングも「文字列操作」から「型システムに守られた設計」へ。