Compose Destinations 2.x 完全指南:注解驱动的安全导航实战
2026/9/15 6:16:03 网站建设 项目流程

我已经不用再手动拼"profile/{id}?source=list"这种魔法字符串了。把导航从 Navigation Compose 手写方案迁到 Compose Destinations 2.x 之后,最直观的变化是:页面参数改名会导致编译错误,而不再像以前那样留到运行时才炸。这两年是 Compose Destinations 2.x 快速迭代的阶段,API 和 1.x 相比变化不小,网上的资料大多停留在 1.x 的 KAPT 时代。这篇文章我把 2.x 的完整 API 使用链路整理一遍,从 Gradle 配置、核心注解、NavHost 装配,到参数序列化、底部弹窗、深链、多导航图和 Hilt 集成,全部用我实际验证过的代码说话。适合正在选型导航方案的团队,也适合已经迁移到一半、被各种生成代码问题卡住的人。

1. 我为什么把两百多个页面的导航整体切到 2.x

1.1 手写 Navigation Compose 的日常维护成本

项目大了以后,手写导航的痛点其实不在"跳转"本身,而在参数。Navigation Compose 里跳转要这样写:navController.navigate("profile/${id}?source=list"),接收方要通过navArgs或者backStackEntry.arguments?.getString("source")去解析。这里每一层都是字符串,编译器完全不帮你检查。改一个参数名,你得全局搜索 route 字符串、调用点、解析点,漏掉任何一个,运行时才会报IllegalArgumentException

更麻烦的是深链。线上版本如果有一个深链的 path 和 route 对不上,用户从短信点链接进来直接白屏,这类问题在 release 包才复现,排查链路又臭又长。我当时统计了一下,光导航相关的手写 boilerplate 就有两千多行,而且每个页面都在重复"定义 route、定义参数解析、注册 composable"这三件事。

1.2 注解生成模式带来的变化

Compose Destinations 2.x 的思路是反过来:你在普通 composable 函数上打@Destination注解,KSP 在编译期扫描这些注解,自动生成导航需要的全部代码。函数的参数列表就是路由参数契约,id: Long会变成路由里的{id}占位符和 NavType 声明,name: String = "游客"会被处理成可选参数并带上默认值。

这套模式解决了几个实际问题。第一,参数类型安全,路由字符串的拼接和解析都由生成代码处理;第二,单一数据源,页面函数签名改了,所有调用点同步编译报错;第三,深链、返回栈配置、底部弹窗这些导航要素,全都在注解里声明,代码评审的时候一眼能看到这个页面的完整导航行为。

1.3 2.x 相比 1.x 的分水岭变化

如果你是从 1.x 升上来的,要先接受几个底层变化。2.x 砍掉了 KAPT,只走 KSP,Kotlin 也要求 2.0 以上,因为 Compose 编译器插件在 2.0 之后是单独启用的。另一个大变化是底座换成了 Navigation Compose 2.8 那一代,这代底层导航库本身做了重写,Compose Destinations 2.x 的DestinationsNavHostrememberDestinationsNavigator都是包在它之上的封装。

除此之外,2.x 把 Compose Multiplatform 也纳入了支持范围,commonMain 里可以直接用同一套注解。底部弹窗和对话框也从实验状态转正,直接用style = DestinationStyle.BottomSheet::class就能声明一个底部弹窗页面。如果你的项目正在用 1.x,别急着升,先看完第 7 节的迁移清单再动手。

2. 工程配置:插件、版本目录与同步失败排查

2.1 一次到位的 Gradle 配置

先说结论。在 Android 模块的build.gradle.kts里,你需要这些插件:

plugins { id("com.android.application") id("org.jetbrains.kotlin.android") id("org.jetbrains.kotlin.plugin.compose") // Kotlin 2.0 之后必须单独启用 id("org.jetbrains.kotlin.plugin.serialization") id("com.google.devtools.ksp") }

serialization插件不是强制要求,但建议直接加上。自定义参数类型序列化、以及底层导航库对@Serializable的支持都用得上。不加的话,后面想用自定义 NavType 时还得回头补。

依赖就两条,注意 core 和 ksp 的 artifact 必须完全同版本:

dependencies { implementation("io.github.raamcosta.compose-destinations:core:2.10.0-beta") ksp("io.github.raamcosta.compose-destinations:ksp:2.10.0-beta") }

core已经传递依赖了 Navigation Compose 2.8 和 kotlinx-serialization-json,不需要再手动加 navigation 依赖。除非你要直接操作NavHostController或者自定义NavHost行为,那时才需要显式引入对应版本的 navigation-compose。

2.2 版本对齐的策略

这个库的版本号一直跟着底层 Navigation Compose 走,目前 2.x 都是 beta 阶段,选版本时核心看三样东西:Kotlin 版本、KSP 版本、Compose BOM 版本。表格是我目前在用的参考组合:

依赖版本示例说明
Kotlin2.0.212.x 常见搭配
KSP2.0.21-1.0.27必须与 Kotlin 精确对应
compose-destinations core/ksp2.10.0-beta两个 artifact 同版本
Compose BOM2024.10.00 附近与 Kotlin 兼容即可

KSP 和 Kotlin 的对应关系是个高频坑。KSP 的版本号前半段就是它支持的 Kotlin 版本,比如2.0.21-1.0.27只能用在 Kotlin 2.0.21 上。升级 Kotlin 后忘记同步升 KSP,会看到一堆莫名其妙的ksptask 失败。

2.3 第一次同步最容易翻车的三个点

第一个是Unresolved reference: NavGraphs。这个类是生成的,第一次配置完需要执行一次 build 或者kspDebugKotlin任务,IDE 里的红色报错通常 build 一次就消失。如果 clean 之后还报错,检查 Android Studio 的 Kotlin 插件版本和 Gradle 里的一致。

第二个是 core 和 ksp 版本不一致。比如 core 用了 2.9.0-beta、ksp 用了 2.10.0-beta,生成的代码引用了 core 里还不存在的 API,报错信息会指向某个生成类的方法签名。这类问题不看 changelog 很难定位,所以养成习惯:升级时两个版本号一起改。

第三个是我见过最多的,KAPT 残留。1.x 时代生成的代码缓存在build/generated/source/kapt里,迁移到 KSP 后这些旧生成物可能被 IDE 缓存继续引用,导致你改了注解但跳转行为还是老的。处理办法是./gradlew clean,然后在 Android Studio 里File -> Invalidate Caches / Restart

提示:生成代码的位置在build/generated/ksp/{flavor}/{buildType}/kotlin,排查导航行为异常时,养成先看生成类内容的习惯。

3. 核心注解拆解:@Destination、@NavGraph、@NavTypeSerializer

3.1 @Destination 的参数逐个说

@Destination是唯一一个你每天都要写的注解,它的各个参数分别管一件事:

@Destination( route = "profile", // 自定义路由名,不写则用函数名生成 start = true, // 是否是所在导航图的起始页 deepLinks = [NavDeepLink("https://example.com/profile")], style = DestinationStyle.Root::class // Root / BottomSheet / Dialog ) @Composable fun ProfileScreen( id: Long ) { ... }

route字符串里可以带参数占位符,比如route = "profile/{id}",但大多数情况下你不用手写它。函数参数会自动拼进路由,库生成的默认 route 形如profileScreen?id={id}。只有当你想让外部深链路径更简短、或者路由名和函数名不一致时,才需要显式指定route

start = true标记所在导航图的起始页。整个 App 的根图里必须有一个,每个子图里也必须有一个。deepLinksstyle我在第 6 节展开讲。

3.2 导航图声明与 2.x 的泛型写法

2.x 声明导航图的方式是:先写一个用@NavGraph标注的注解类,再在@Destination的泛型参数里引用它。

@NavGraph annotation class RootNavGraph(val route: String = "root") @NavGraph annotation class AuthNavGraph(val route: String = "auth")

页面归属导航图用泛型:

@Destination<RootNavGraph>(start = true) @Composable fun HomeScreen() { ... } @Destination<AuthNavGraph>(start = true) @Composable fun LoginScreen() { ... } @Destination<AuthNavGraph> @Composable fun RegisterScreen() { ... }

如果你不写泛型参数,页面默认放在根图里。这里要注意:2.x 的推荐写法是泛型参数,早期 1.x 那种@Destination(navGraph = AuthNavGraph::class)的写法在部分 2.x beta 里已经不推荐了。你项目里如果是从 1.x 迁移来的老代码,先确认目标版本对navGraph参数是否还兼容,再决定要不要批量改。

3.3 自定义参数类型:@NavTypeSerializer 实战

导航参数必须是可字符串化的。Int、Long、String、Boolean 这些原生类型没问题,但你的页面参数经常是业务对象,比如UserId。这时候用@NavTypeSerializer自定义序列化器:

@Serializable data class UserId(val raw: Long) @NavTypeSerializer class UserIdNavTypeSerializer : NavTypeSerializer<UserId>() { override fun toRouteString(value: UserId): String = value.raw.toString() override fun fromRouteString(routeString: String): UserId = UserId(routeString.toLong()) override fun toJson(json: Json, value: UserId): String = value.raw.toString() override fun fromJson(json: Json, text: String): UserId = UserId(text.toLong()) }

它做的事情可以理解为"自定义类型与路由字符串之间的双向翻译"。toRouteString/fromRouteString负责路由里的短格式,toJson/fromJson负责序列化场景下的完整格式。定义好之后,页面函数里直接写:

@Destination @Composable fun ProfileScreen(userId: UserId) { ... }

调用方就变成了ProfileScreenDestination(userId = UserId(42)),这个跳转在编译期就是类型安全的。

注意:@NavTypeSerializer的实现类必须有无参构造,并且要在模块里能被 KSP 扫描到。放在 internal 或私有位置可能导致运行时找不到序列化器。

4. DestinationsNavHost 与 Navigator 的完整用法

4.1 NavHost 的两种装配方式

页面都注解好之后,MainActivity里用DestinationsNavHost装配。最简写法:

setContent { DestinationsNavHost(navGraph = NavGraphs.root) }

NavGraphs.root是 KSP 生成的导航图入口对象,根图、子图、起始页都在它下面组织好了。如果你的 App 需要一个 NavController 做更底层的事情(比如配合accompanist或者某些系统级跳转),可以自己创建并传进去:

val navController = rememberNavController() DestinationsNavHost( navController = navController, navGraph = NavGraphs.root )

我自己一般建议传自定义 NavController,因为后面做多返回栈、或者要在 Activity 外面拿 controller 做逻辑时,有个引用会方便很多。

4.2 navigate 的各种姿势与返回栈配置

页面内部通过rememberDestinationsNavigator()拿导航器。推荐把navigator: DestinationsNavigator直接声明为 composable 函数参数,Compose Destinations 会自动注入,不传也行,函数内部自己remember也可以:

val navigator = rememberDestinationsNavigator() // 最简跳转 navigator.navigate(ProfileScreenDestination(id = 7)) // 带返回栈配置 navigator.navigate(ProfileScreenDestination(id = 7)) { popUpTo(NavGraphs.root) { inclusive = true } launchSingleTop = true restoreState = true } // 防重复点击 navigator.navigate(ProfileScreenDestination(id = 7), onlyIfResumed = true)

navigate的第二参数是NavOptionsBuilder,和 Navigation Compose 的写法一致。popUpTo的层级你可以填具体 destination,也可以直接填NavGraphs.root。这里有个容易混的点:popUpTo(NavGraphs.root)默认不会把 root 自己弹出,要配合inclusive = true才连根弹出。

onlyIfResumed是实用价值很高的参数。底部 tab 的切换按钮、列表点击跳转这些高频入口,用户狂点两下,不加这个参数就会出现两个页面叠在栈里的情况。加上之后第二个 navigate 会被忽略。

4.3 返回栈判断与页面结果回传

判断当前是否在某个页面:

val isHome = navigator.isCurrentDestinationOnBackStack(HomeScreenDestination)

返回值栈:

navigator.navigateUp() navigator.popBackStack()

页面间回传结果,官方思路是走savedStateHandle。回传页这样做:

navigator.previousBackStackEntry?.savedStateHandle?.set("edit_result", newName) navigator.popBackStack()

接收页用 ViewModel 接收:

@HiltViewModel class ProfileViewModel @Inject constructor( savedStateHandle: SavedStateHandle ) : ViewModel() { val editedName: StateFlow<String?> = savedStateHandle.getStateFlow("edit_result", null) }

这里有个经验:不要用全局事件总线传页面结果,页面销毁重建后容易丢。savedStateHandle跟着返回栈条目走,系统杀进程恢复时数据还在,这是最稳的方案。

5. 参数契约:函数签名决定路由,生成代码如何落地

5.1 必填参数与可选参数

在 Compose Destinations 2.x 里,页面 composable 的非默认参数会成为路由的必填参数,带默认值或者可空类型的参数会成为可选参数。举例:

@Destination @Composable fun ArticleScreen( articleId: Long, // 必填,路由里是 {articleId} highlight: Boolean = false, // 可选,带默认值 source: String? = null // 可选,可空 ) { ... }

调用方必须传articleIdhighlightsource可以不传。生成的路由大概长这样:articleScreen?highlight={highlight}&source={source}articleId作为 path 参数放在路径段里。这些细节你不用手写,但要理解生成规则,排查路由不匹配问题时会用到。

5.2 默认值与可空类型的坑

坑主要在默认值。生成代码里的默认值和你的函数默认值保持一致,但它在底层实现上还是要走字符串编码。Boolean 会被编码成"true"/"false",可空 String 的 null 在查询参数里直接省略。如果某个可选参数继续传递给下一个页面,从savedStateHandle取出来时可能拿到的是默认值字符串而不是空值,这点在写 ViewModel 时要留意。

另一类问题在深链。深链 URL 里如果没带可选参数,生成代码会用它声明的默认值兜底,这是合理的。但如果你后来改了函数参数的默认值,老版本深链 URL 的行为会跟着变,线上用户手里的历史链接可能表现出不同的页面状态。所以页面参数的默认值一旦定了,尽量不要频繁改语义。

5.3 生成的 Direction 与 NavArgs 长什么样

KSP 会为每个@Destination生成两个核心类,一个是Direction实现,一个是NavArgs解析类。以ProfileScreen(id: Long, name: String = "游客")为例,生成代码简化后长这样:

object ProfileScreenDestination : Direction { var id: Long = 0L var name: String = "游客" operator fun invoke(id: Long, name: String = "游客"): ProfileScreenDestination = this.apply { this.id = id this.name = name } override val route: String = "profileScreen?id={id}&name={name}" } object ProfileScreenNavArgs { fun fromNavArgs(backStackEntry: NavBackStackEntry): ProfileScreenNavArgs { ... } fun fromSavedStateHandle(savedStateHandle: SavedStateHandle): ProfileScreenNavArgs { ... } }

注意,ProfileScreenDestination是单例对象,invoke操作符让它用起来像构造函数。这也是为什么你可以写ProfileScreenDestination(id = 7)而不用new。知道这一点对排查问题有帮助:单例意味着对象属性是有状态的,在非导航场景里别把它当数据类到处传。

5.4 类型不支持时的处理路径

如果某个参数类型既不是基础类型,也没有对应的@NavTypeSerializer,KSP 会在构建时直接报错,错误信息会明确告诉你哪个参数、哪个类型不受支持。处理路径只有两条:要么把这个参数从页面参数改成 ViewModel 或仓库来拿数据,要么给类型写序列化器。

我个人的建议是:页面参数只放轻量标识符,比如idtabIndex,复杂对象一律通过 id 二次查询。自定义类型能用的时候再用,不要把所有业务对象都塞进导航参数。参数越复杂,深链、进程恢复、跨组件复用的心智负担越大。

6. 弹窗、底部表单、深链与多导航图实战

6.1 BottomSheet 和 Dialog 的写法

这是 2.x 相比手写方案优势最大的场景。手写 Navigation Compose 做底部弹窗页面要配置BottomSheetNavigator和对应的Sheetcomposable,麻烦。Compose Destinations 里只改一个参数:

@Destination(style = DestinationStyle.BottomSheet::class) @Composable fun FilterSheet() { ... } @Destination(style = DestinationStyle.Dialog::class) @Composable fun LogoutDialog() { ... }

跳转方式完全一样:

navigator.navigate(FilterSheetDestination) navigator.navigate(LogoutDialogDestination)

弹窗页面同样支持参数、深链和返回栈逻辑,它们就是导航图里的普通节点。这里有个注意事项:start = true的页面不要设成 BottomSheet 或 Dialog,不然 App 冷启动时直接先弹一个底部弹窗,体验会非常奇怪。

6.2 深链配置与 release 失效排查

深链声明在注解里:

@Destination( deepLinks = [NavDeepLink("https://example.com/profile/{id}")] ) @Composable fun ProfileScreen(id: Long) { ... }

注意{id}占位符要和函数参数名一致。AndroidManifest 里要给承载DestinationsNavHost的 Activity 加 intent-filter:

<activity android:name=".MainActivity"> <intent-filter> <action android:name="android.intent.action.VIEW" /> <category android:name="android.intent.category.DEFAULT" /> <category android:name="android.intent.category.BROWSABLE" /> <data android:scheme="https" android:host="example.com" /> </intent-filter> </activity>

release 包深链失效是常见问题。先确认两点:链接里的 id 是否匹配参数类型、scheme/host 是否正确。然后跑一次adb shell am start -W -a android.intent.action.VIEW -d "https://example.com/profile/123" 包名/.MainActivity看日志。如果 debug 正常 release 不行,八成是 manifest placeholder 或者 R8 规则把自定义 NavType 裁掉了,给序列化器加 keep 规则:

-keep class * extends io.github.raamcosta.compose.destinations.core.NavTypeSerializer { *; }

6.3 多导航图的组织与跨图跳转

导航图的意义在于组织页面层级和控制返回栈。比如登录流程单独一个AuthNavGraph,主页流程一个MainNavGraph。定义方式在第 3 节已经讲过,生成之后你可以通过NavGraphs.authNavGraphs.main访问各个图。

跨图跳转不需要特殊 API,直接:

// 在 LoginScreen 里跳回主图 navigator.navigate(HomeScreenDestination) { popUpTo(NavGraphs.root) { inclusive = true } }

这段代码通常配合登录态做:登录成功后清空整个登录栈,回到主图首页。多图的另一个价值是延迟加载:可以把大模块的图单独封装,等用户进入模块时再初始化,页面切换速度会明显改善。

6.4 与 Hilt ViewModel 的配合

Hilt 集成在实际项目里几乎是标配。Compose Destinations 对 Hilt 的配合方式很直接,页面函数里用hiltViewModel()拿 ViewModel 即可:

@Destination @Composable fun ProfileScreen( viewModel: ProfileViewModel = hiltViewModel() ) { val uiState by viewModel.uiState.collectAsState() ... }

它的好处是 ViewModel 自动绑定到当前导航图条目的作用域,页面销毁返回时 ViewModel 也跟着清掉,不会像 Activity 级 ViewModel 那样堆积。跨图跳转时,不同图里的页面即使路由相同,ViewModel 也是隔离的。

7. 从 1.x 迁移到 2.x 的清单与实测避坑记录

7.1 迁移操作顺序

不要一把梭全仓替换,按这个顺序推进:

  1. 先把 Kotlin 升到 2.0+,加上org.jetbrains.kotlin.plugin.compose插件,确认项目在旧导航下能正常编译运行。
  2. 在 Gradle 里把 KAPT 的 compiler 依赖替换成 KSP 的 dependency,同时删掉kapt插件。
  3. 逐个模块打开生成代码,确认NavGraphs和各个Destination对象能正常生成。
  4. 迁移MainActivity的 NavHost,换成DestinationsNavHost(navGraph = NavGraphs.root)
  5. 批量把navController.navigate("xxxScreen/...")替换成navigator.navigate(XxxDestination(...)),这一步建议按页面模块分批做,每批回归一次。
  6. 处理 2.x 的破坏性变更,主要是注解参数写法和底部弹窗相关 API。

7.2 运行时常见问题排查链路

现象排查方向处理
跳转后白屏,Logcat 报IllegalArgumentExceptionroute 与参数不匹配打开生成代码核对 Default route 里的参数占位符
自定义类型参数在深链场景拿不到值NavTypeSerializer 有没有被 R8 裁掉加 keep 规则,本地 debug 和 release 分别验证
底部弹窗关不掉/多次弹出style 设置与页面启动模式冲突检查是否误设 start,检查 navigate 是否触发了多次
返回栈异常,popBackStack回不到预期页面popUpTo的层级不正确打印返回栈,确认inclusive设置
页面参数在不同版本深链里行为不一致参数默认值语义被改动参数默认值保持稳定,必要时版本分支处理

排查白屏问题有个笨但有效的方法:把 build 目录里这个页面对应的生成Destination类打开,看它的route属性。路由字符串会明确列出所有参数占位符,和日志里的实际入栈 route 一对比,问题基本就浮出来了。

7.3 性能和工程实践建议

性能上,Compose Destinations 是编译期生成代码,运行时没有反射,额外开销集中在生成对象的invoke调用和底层 Navigation Compose 本身,对 UI 性能的影响可以忽略。实际体感上,页面切换动画、参数解析速度和手写方案没有可感知的差异。

工程实践上有几条建议。第一,页面参数尽量少而精,只传标识符和必要的 UI 状态;第二,导航相关逻辑集中在@Destination声明里,不要在调用侧散落 route 字符串;第三,升级版本前先看 changelog,beta 版本之间破坏性变更比较频繁,我遇到过 2.8 到 2.9 之间底部弹窗 API 的调整,平级跨多个版本升级风险更大;第四,新模块可以先小范围试点一个典型流程,验证生成代码、深链、Hilt 集成都没问题,再铺开全项目。

最后分享一个我自己吃过大亏的经验:迁移期间一定要把"链接深链测试"纳入回归列表。手写方案你可以临时改 route 字符串补救,但 Compose Destinations 的深链路径和函数参数是绑定的,出了问题往往要改代码发版。迁移完成后,建议在 CI 里加一条深链冒烟测试,把关键路径的深链在每次构建后自动点一遍,这个成本很低,能挡住大多数导航回归。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询