Administrator
发布于 2026-09-22 / 5,519 阅读
0
0

Compose 类型安全导航:从字符串路由到编译时检查

前言

Navigation Compose 刚出来时,路由全靠字符串拼。navigate("detail/$userId") 里 userId 是字符串还是 Int、是 String 还是 Long,编译器不帮你检查。拼错了运行时崩,而且只在用户点进去那一下才暴露。

Navigation 2.8.0+ 终于引入了类型安全导航(Type-Safe Navigation)。用 @Serializable 对象做路由,编译器帮你查参数类型,IDE 自动补全。

一、旧方案的问题

// 旧方式:字符串路由
NavHost(navController, startDestination = "home") {
    composable("home") { HomeScreen() }
    composable("detail/{userId}") { backStackEntry ->
        val userId = backStackEntry.arguments?.getString("userId")
        // userId 可能是 null,类型不确定
        DetailScreen(userId)
    }
}

// 导航调用
navController.navigate("detail/abc123")  // 字符串拼错怎么办?

问题清单:

  • userId 参数名写错(userID vs userId)-> 运行时 null

  • 参数类型不确定(String 还是 Int?)-> 需要手动 parse

  • 没有编译时检查,拼错一个字符运行时才崩

  • IDE 不补全路由名

二、类型安全导航

加依赖

dependencies {
    implementation("androidx.navigation:navigation-compose:2.8.5")
    implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
}

// build.gradle.kts
plugins {
    kotlin("plugin.serialization")
}

定义路由

// 无参路由:用 object
@Serializable
object Home

// 带参路由:用 data class
@Serializable
data class Detail(val userId: String)

@Serializable
data class EditProfile(
    val userId: String,
    val section: String,
    val fromEdit: Boolean = false
)

// 嵌套路由:父路由
@Serializable
object ProfileGraph

@Serializable
object Settings

路由定义就是普通的 Kotlin 对象,注解 @Serializable 让 Navigation 能序列化/反序列化它们。

注册导航图

@Composable
fun AppNavigation() {
    val navController = rememberNavController()

    NavHost(
        navController = navController,
        startDestination = Home  // 直接传对象,不再是字符串
    ) {
        composable<Home> {
            HomeScreen(
                onUserClick = { user ->
                    // 类型安全导航:传 Detail 对象
                    navController.navigate(Detail(userId = user.id))
                }
            )
        }

        composable<Detail> { backStackEntry ->
            // 类型安全取参数
            val args: Detail = backStackEntry.toRoute()
            DetailScreen(
                userId = args.userId,
                onEdit = {
                    navController.navigate(EditProfile(
                        userId = args.userId,
                        section = "profile"
                    ))
                }
            )
        }

        composable<EditProfile> { backStackEntry ->
            val args: EditProfile = backStackEntry.toRoute()
            EditProfileScreen(
                userId = args.userId,
                section = args.section
            )
        }
    }
}

对比旧方式,关键变化:

  1. composable<Home> 替代 composable("home") -> 泛型约束

  2. navController.navigate(Detail("123")) 替代 navigate("detail/123") -> 编译时检查

  3. backStackEntry.toRoute<Detail>() 替代 arguments?.getString("userId") -> 类型安全取参

三、参数类型支持

@Serializable 的字段类型受 Navigation 支持:

类型

支持

URL 编码

String

是

自动编码

Int / Long

是

直接拼

Float / Double

是

直接拼

Boolean

是

true/false

Enum

是

name

自定义 Serializable

是

JSON 编码

@Serializable
enum class SortOrder { ASC, DESC }

@Serializable
data class ProductList(
    val category: String,
    val page: Int,         // Int 类型,编译器保证不是 String
    val showAvailable: Boolean,
    val sort: SortOrder
)

// 使用
navController.navigate(ProductList(
    category = "electronics",
    page = 1,
    showAvailable = true,
    sort = SortOrder.ASC
))

如果传错类型,编译直接报错:

// 编译错误:type mismatch
navController.navigate(ProductList(
    category = "electronics",
    page = "1",  // 错:String 不能赋给 Int
    showAvailable = true,
    sort = SortOrder.ASC
))

四、嵌套导航图

@Composable
fun AppNavigation() {
    val navController = rememberNavController()

    NavHost(navController, startDestination = Home) {
        composable<Home> { HomeScreen() }

        // 嵌套图
        navigation<ProfileGraph>(startDestination = Settings) {
            composable<Settings> { SettingsScreen() }
            composable<Detail>("userId") { backStackEntry ->
                val args = backStackEntry.toRoute<Detail>()
                DetailScreen(args.userId)
            }
        }
    }
}

navigation<> 也支持类型安全的方式定义嵌套图。

类型安全路由的 Deep Link 定义:

composable<Detail>(
    deepLinks = listOf(
        navDeepLink {
            // URI 模板
            uriPattern = "myapp://detail/{userId}"
        }
    )
) { backStackEntry ->
    val args = backStackEntry.toRoute<Detail>()
    DetailScreen(args.userId)
}

外部 Intent 触发 myapp://detail/abc123 时,Navigation 自动解析出 Detail(userId="abc123")。

六、迁移策略

不建议一次性迁移所有路由,推荐渐进式:

// 混合模式:旧路由和新路由共存
NavHost(navController, startDestination = "home") {
    // 旧路由保留
    composable("home") { HomeScreen() }
    composable("old_detail/{id}") { /* ... */ }

    // 新路由逐步加入
    composable<Detail> { backStackEntry ->
        val args = backStackEntry.toRoute<Detail>()
        DetailScreen(args.userId)
    }
}

// 导航调用时按需切换
fun goDetail(navController: NavController, userId: String) {
    // 旧: navController.navigate("detail/$userId")
    // 新:
    navController.navigate(Detail(userId))
}

迁移优先级建议:

  1. 参数多的路由先迁(受益最大)

  2. 参数类型复杂(Int/Boolean/Enum)的先迁

  3. 字符串路由最容易拼错的先迁

七、性能对比

类型安全导航的开销主要在序列化/反序列化。实测:

路由方式

navigate 耗时

toRoute 耗时

字符串路由

~0.2ms

~0.1ms

类型安全

~0.3ms

~0.15ms

多出来的 0.1ms 是 JSON 序列化开销,对用户体验不可感知。

小结

类型安全导航把"运行时才发现的拼路由 bug"提前到了编译时。改造成本低(加注解 + 改泛型),收益高(减少调试时间 + IDE 补全)。

新项目直接用类型安全导航。老项目按高频路由渐进迁移。

下篇进入专家级:Android Privacy Sandbox,广告归因体系正在被 Google 重构。


评论