用 KSP 给 Navigation 3 加一层「跨模块路由」:nav3-helper 设计与使用
Compose Navigation 3 的一个很重要的变化是:导航状态回到了开发者手里。
听起来可能觉得没什么,但实际写项目时会很舒服。页面栈可以作为普通状态维护,页面本身可以是一个 NavKey,配合 NavDisplay 渲染当前页面,整个模型比过去更贴近 Compose 的状态驱动思路。
不过,当项目开始模块化之后,另一个问题又会冒出来:
A 模块想跳到 B 模块的页面,但 A 模块不应该直接依赖 B 模块的页面类,怎么办?
如果继续手写一套字符串路由、参数解析、页面注册表和入口分发,很快就会变成重复劳动。而且页面参数一多,类型安全和默认值处理也会变得烦。
所以我做了这个库:nav3-helper。
看名称也也看出来 有个 helper 只是官方的辅助库,所有功能都强依赖官方的 Navigation 3。
这个库我去年就开始做了,看下最早的时间:

25 年 11 月份,那时候 Navigation 3 还没有出稳定版本,我在一直在等官方稳定版本出来,再做最后的完善。中间也在搞一些 KMP 相关的东西,后来 Navigation 3 也可以支持跨平台了,这个库也肯定要支持了,加上年底事情又多,一直拖到现在才发出来。
现在 AI 时代了,这个库我也让AI 帮我干了一些事件,部分代码的生成、所有英文注释、英文README、字符串解析全是AI 干的。
AI 干活确实快,效率上去了,只是一些思路和想法,不要被 AI 给误导了:


像我上边让他做一些事件,总是习惯用 nav2 的方式去实现。一定要坚持自己的想法和思路,只让他干活就好
言归正传 nav3-helper 它的目标很简单:
- 保留 Navigation 3「状态优先」的导航模型
- 用 KSP 自动生成 Destination 和 Registry
- 支持通过固定 route key 做跨模块导航
- 支持从 URL query 恢复轻量参数
- 支持页面结果回传
- 适配 Kotlin Multiplatform / Compose Multiplatform 场景
看到这里你可能想说,这不又回到 nav2 的 字符串形式了吗?。不是的,字符串只是为了跨模块,最终还是解析成对应的 Destination 来导航
先看最终用法
假设我们有一个详情页:
kotlin
@Screen(route = "https://www.app.cn/compose-app/detail")
@Composable
fun DetailScreen(
detailId: Int = 0,
name: String? = null
) {
Text("DetailScreen detailId=$detailId name=$name")
}
标一个 @Screen,KSP 会为它生成对应的 Destination,例如:
kotlin
DetailScreenDestination(
detailId = 110,
name = "Aleyn"
)
如果在同一个导航 Host 内部跳转,可以直接使用生成的 Destination:
kotlin
val backStack = LocalNavBackStackState.current
backStack.navigate(
DetailScreenDestination(
detailId = 110,
name = "Aleyn"
)
)
或者直接使用 NavCenter也没问题:
kotlin
NavCenter.navigate(
DetailScreenDestination(
detailId = 110,
name = "Aleyn"
)
)
如果是跨模块跳转,可以用统一入口:
kotlin
NavCenter.navigate(
"https://www.app.cn/compose-app/detail?detailId=110&name=Aleyn"
)
也就是说,库内部把两类诉求拆开了:
- 本地导航:直接使用生成的 Destination,类型更直接
- 跨模块导航:使用稳定的 route key,模块之间不需要直接引用页面类
为什么需要 route key
在模块化项目里,一个页面通常不只是一个函数,它也是一个对外契约。
比如 child_first 模块希望跳到 child_second 模块:
kotlin
NavCenter.navigate("https://www.app.cn/child-second/main")
调用方只需要知道这个 route key,不需要依赖 SecondScreenDestination,也不需要知道 SecondScreen 的实现在哪里。
被跳转页面这样声明:
kotlin
@Screen(route = "https://www.app.cn/child-second/main", start = true)
@Composable
fun SecondScreen() {
// ...
}
route key 本身不强制协议格式,下面几种都可以:
text
https://www.app.cn/user/detail
app://user/detail
user/detail
我的建议是:在正式项目里把 route 当成模块间 API 来管理。它不应该跟函数名强绑定,也不应该频繁变动。
接入方式
Android 项目
模块可以这样引入:
kotlin
dependencies {
implementation("io.github.aleyn97:navigation3-helper:1.0.0")
ksp("io.github.aleyn97:nav3-ksp-compiler:1.0.0")
}
插件配置:
kotlin
plugins {
id("com.android.application") // 或 com.android.library
kotlin("android")
id("com.google.devtools.ksp")
}
多平台项目
Kotlin Multiplatform 项目可以把 KSP 编译器加到 common metadata:
kotlin
dependencies {
implementation("io.github.aleyn97:navigation3-helper:1.0.0")
add("kspCommonMainMetadata", "io.github.aleyn97:nav3-ksp-compiler:1.0.0")
}
如果项目还启用了平台侧 KSP task,也可以继续补充:
kotlin
dependencies {
add("kspAndroid", "io.github.aleyn97:nav3-ksp-compiler:1.0.0")
add("kspIosX64", "io.github.aleyn97:nav3-ksp-compiler:1.0.0")
add("kspIosArm64", "io.github.aleyn97:nav3-ksp-compiler:1.0.0")
add("kspIosSimulatorArm64", "io.github.aleyn97:nav3-ksp-compiler:1.0.0")
}
声明 @Screen 的模块需要应用 KSP:
kotlin
plugins {
kotlin("multiplatform")
id("com.google.devtools.ksp")
}
如果页面参数里使用了 @Serializable 类型,需要额外应用 Kotlin serialization 插件:
kotlin
plugins {
kotlin("plugin.serialization")
}
在部分 KMP 项目里,还需要把 commonMain 的 KSP 生成目录加回 source set:
kotlin
kotlin {
sourceSets {
commonMain {
kotlin.srcDir("build/generated/ksp/metadata/commonMain/kotlin")
}
}
}
初始化 Registry
KSP 会按模块生成 Registry。应用启动时,把这些 Registry 注册进去:
kotlin
fun initNavigation() {
loadNavRegistry(
ComposeAppRegistry,
ChildFirstRegistry,
ChildSecondRegistry
)
}
然后在 Compose 根节点挂载导航 Host:
kotlin
@Composable
fun App() {
NavDisplayHelper(ComposeAppRegistry.defaultStartScreen)
}
NavDisplayHelper 做了几件事情:
- 创建并持有
NavBackStackState - 给页面提供
LocalNavBackStackState - 把当前 Host attach 到
NavCenter - 内部调用 Navigation 3 官方的
NavDisplay - 默认接入 saveable state 和 ViewModelStore 的 entry decorator
如果你希望完全控制 NavDisplay,也可以不用 helper,自己把 backStack 和 entryProvider 传进去:
kotlin
val backStack = rememberHelperBackStack(
startRoute = ComposeAppRegistry.defaultStartScreen,
navRegistrySet = setOf(ComposeAppRegistry)
)
NavDisplay(
backStack = backStack.navBackStack,
onBack = { backStack.goBack() },
entryProvider = getEntryProvider(setOf(ComposeAppRegistry))
)
页面参数如何处理
对于普通本地导航,参数就是生成 Destination 的构造参数:
kotlin
backStack.navigate(
DetailScreenDestination(
detailId = 110,
name = "Aleyn"
)
)
对于 route 跳转,动态值统一放到 query 里:
kotlin
NavCenter.navigate(
"https://www.app.cn/compose-app/detail?detailId=110&name=Aleyn"
)
库内部会把 route 分成两部分:
- route key:页面身份,例如
https://www.app.cn/compose-app/detail - query parameters:运行时参数,例如
detailId=110&name=Aleyn
匹配页面时只看 route key,query 不参与页面身份判断。
当前 query 参数支持:
String- Kotlin primitive 类型
- nullable 类型
- 默认值
@Serializable对象
比如页面参数有默认值:
kotlin
@Screen(route = "app://user/detail")
@Composable
fun UserDetailScreen(
id: Long,
tab: String = "post"
) {
// ...
}
运行时可以传:
kotlin
NavCenter.navigate("app://user/detail?id=123&tab=comment")
如果没有传 tab,就会走默认值 "post"。
如果必填参数缺失、基础类型解析失败,或者 @Serializable JSON 解析失败,本次 route resolve 会失败,不会生成错误页面对象。
传递 Serializable 参数
有些参数是对象,比如用户信息、筛选条件等。可以用 @Serializable:
kotlin
@Serializable
data class UserInfo(
val userId: String,
val avatarUrl: String,
val nickName: String
)
@Screen(route = "https://www.app.cn/compose-app/me")
@Composable
fun MeScreen(userInfo: UserInfo) {
// ...
}
跳转时用 serializeRouteQueryValue 编码后放进 query:
kotlin
val userInfo = UserInfo(
userId = "66666",
avatarUrl = "https://www.app.cn/image/avatar.png",
nickName = "Aleyn"
)
val userInfoParam = serializeRouteQueryValue(userInfo)
NavCenter.navigate(
"https://www.app.cn/compose-app/me?userInfo=$userInfoParam"
)
这里要注意一点:URL query 更适合轻量、公开、可恢复的路由参数。复杂对象、大对象、敏感业务状态,不建议塞进 URL。更好的方式是在页面内部根据 id 再去加载。
页面结果回传
除了跳转,页面之间经常还需要回传结果。
比如从 FirstHomeScreen 跳到 SecondScreen,第二个页面关闭时返回一个字符串:
kotlin
@Screen(route = "https://www.app.cn/child-second/main", start = true)
@Composable
fun SecondScreen() {
val backStack = LocalNavBackStackState.current
Button(onClick = {
backStack.setResult("SecondScreen Back")
backStack.goBack()
}) {
Text("Back And Return Value")
}
}
上一个页面消费结果:
kotlin
@Screen(route = "https://www.app.cn/child-first/main", start = true)
@Composable
fun FirstHomeScreen() {
val backStack = LocalNavBackStackState.current
var resultData by remember { mutableStateOf("") }
backStack.consumeResultEffect<String> {
resultData = it.orEmpty()
}
// ...
}
结果 API 有这些:
kotlin
setResult(...)
peekResult(...)
consumeResult(...)
consumeResultEffect(...)
hasResult(...)
clearResult(...)
默认情况下,结果 key 使用类型本身。如果一个流程里有多个同类型结果,也可以传自定义 key。
KSP 生成了什么
这个库的 KSP 编译器主要生成两类东西。
第一类是页面 Destination。
对于这样的页面:
kotlin
@Screen(route = "app://user/detail")
@Composable
fun UserDetailScreen(
id: Long,
tab: String = "post"
) {
// ...
}
会生成类似这样的 NavScreen:
kotlin
@Serializable
data class UserDetailScreenDestination(
val id: Long,
val tab: String = "post"
) : NavScreen
如果页面没有参数,则生成 object,避免不必要的对象创建和样板代码:
kotlin
@Serializable
data object HomeScreenDestination : NavScreen
第二类是模块 Registry。
Registry 里会包含:
- 当前模块的 route 集合
- 默认 start destination
- Navigation 3 的 entryProvider
- route 到 Destination 的 resolve 逻辑
- NavKey 多态序列化所需的 serializersModule
也就是说,业务代码只负责声明页面:
kotlin
@Screen(route = "app://home", start = true)
@Composable
fun HomeScreen() {
// ...
}
页面注册、参数解析、入口分发这些重复工作都交给 KSP。
route 规则
为了让跨模块导航稳定,route 需要遵守几个规则:
- route key 应该全局唯一
@Screen(route = ...)里只写固定页面身份,不写 query- 不使用
user/{id}这类 path template - 动态值统一通过运行时 query 传递
- query 参数名和 composable 参数名保持一致
- route key 不要跟函数名强绑定
库内部还会做一些归一化:
- query 和 fragment 不参与页面身份匹配
- 空 path segment 会被忽略,所以结尾 slash 不影响匹配
- scheme 和 authority 会转成小写
- 同一个 query key 出现多次时,最后一个值生效
此外,Registry 注册时会检查重复 route。如果不同模块声明了同一个 route key,会直接 fail fast,而不是等到运行时随机跳到某个页面。
NavCenter 的边界
NavCenter 是全局 route 入口,但它并不拥有导航状态。
它只负责:
- 保存全局 Registry
- 保存 URL interceptor
- 根据 URL resolve 出
NavScreen - 把
NavScreen交给当前 attach 的 Host
真正的页面栈仍然由 NavBackStackController 管理。
这个边界对 Compose 很重要:导航状态不是藏在全局单例里,而是由页面 Host 明确持有。NavCenter 更像一个跨模块分发器。
如果需要做登录拦截、灰度跳转、URL 改写,也可以通过 interceptor 处理:
kotlin
NavCenter.addInterceptor { url ->
// 返回新的 URL,或者返回 null 拦截本次跳转
url
}
这个库适合什么场景
我觉得它比较适合这些项目:
- 已经在使用 Compose Navigation 3
- 项目有多个业务模块
- 页面跳转希望保留类型安全
- 跨模块跳转希望通过稳定 route 契约完成
- 需要在 Android、iOS、Desktop、Wasm 等 Compose Multiplatform 目标上复用导航声明
如果项目很小,页面都在一个模块里,直接使用 Navigation 3 原生写法就已经足够了。
nav3-helper 主要解决的问题:少写重复注册代码,少手搓参数解析,减少模块之间的直接依赖。
小结
Navigation 3 把导航重新拉回了状态驱动模型,这对 Compose 来说是一个很好的方向。
nav3-helper 在这个基础上补了一层模块化项目里常见的能力:
@Screen声明页面- KSP 生成 Destination 和 Registry
NavDisplayHelper快速挂载导航 HostNavCenter.navigate(url)支持跨模块 route 跳转- query 自动恢复基础类型、默认值、nullable 和
@Serializable参数 setResult/consumeResultEffect支持页面结果回传
库的宗旨就是:页面声明保持简单,跨模块导航保持清晰,Navigation 3 的状态模型不被破坏。
目前是第一版本,可能有部分功能支持不够好。有兴趣的话,欢迎大家一起完善。
如果你正在做 Compose Multiplatform 或者模块化 Compose 项目,可以试试这个辅助库。