用 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,自己把 backStackentryProvider 传进去:

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 是全局 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 快速挂载导航 Host
  • NavCenter.navigate(url) 支持跨模块 route 跳转
  • query 自动恢复基础类型、默认值、nullable 和 @Serializable 参数
  • setResult / consumeResultEffect 支持页面结果回传

库的宗旨就是:页面声明保持简单,跨模块导航保持清晰,Navigation 3 的状态模型不被破坏。

目前是第一版本,可能有部分功能支持不够好。有兴趣的话,欢迎大家一起完善。

如果你正在做 Compose Multiplatform 或者模块化 Compose 项目,可以试试这个辅助库。

相关推荐
GeekBug1 小时前
Claude Code 如何帮我写 80% 的 Android 样板代码
android·claude
dora1 小时前
手把手带你实现一个Android抽卡集图鉴功能
android
海雅达手持终端PDA1 小时前
海雅达Model 10X—高通6490工业三防平板,生产制造仓储管理应用
android·物联网·能源·制造·信息与通信·交通物流·平板
liu_sir_2 小时前
安卓设置界面-关于手机修改为关于设备
android·大数据·elasticsearch
new_bie_B2 小时前
Android16 应用安装流程源码分析
android
帅次2 小时前
LazyColumn 懒加载、items 与 key
android·flutter·kotlin·android studio·webview
zhangphil2 小时前
Android显示系统RenderThread绘制HARDWARE/普通格式Bitmap与GPU与CPU处理机制
android
美狐美颜SDK开放平台2 小时前
什么是美颜SDK?高并发场景下的企业级美颜SDK如何开发?
android·人工智能·ios·美颜sdk·第三方美颜sdk·视频美颜sdk
YF02113 小时前
Protobuf与 gRPC 的关系:从理论到 Android + Go 实战通信全解析
android·后端·grpc