ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Compose Destinations 2.x 完全指南:注解驱动的安全导航实战

Compose Destinations 2.x 完全指南:注解驱动的安全导航实战 我已经不用再手动拼profile/{id}?sourcelist这种魔法字符串了。把导航从 Navigation Compose 手写方案迁到 Compose Destinations 2.x 之后最直观的变化是页面参数改名会导致编译错误而不再像以前那样留到运行时才炸。这两年是 Compose Destinations 2.x 快速迭代的阶段API 和 1.x 相比变化不小网上的资料大多停留在 1.x 的 KAPT 时代。这篇文章我把 2.x 的完整 API 使用链路整理一遍从 Gradle 配置、核心注解、NavHost 装配到参数序列化、底部弹窗、深链、多导航图和 Hilt 集成全部用我实际验证过的代码说话。适合正在选型导航方案的团队也适合已经迁移到一半、被各种生成代码问题卡住的人。1. 我为什么把两百多个页面的导航整体切到 2.x1.1 手写 Navigation Compose 的日常维护成本项目大了以后手写导航的痛点其实不在跳转本身而在参数。Navigation Compose 里跳转要这样写navController.navigate(profile/${id}?sourcelist)接收方要通过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只走 KSPKotlin 也要求 2.0 以上因为 Compose 编译器插件在 2.0 之后是单独启用的。另一个大变化是底座换成了 Navigation Compose 2.8 那一代这代底层导航库本身做了重写Compose Destinations 2.x 的DestinationsNavHost和rememberDestinationsNavigator都是包在它之上的封装。除此之外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、NavTypeSerializer3.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 的根图里必须有一个每个子图里也必须有一个。deepLinks和style我在第 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)页面归属导航图用泛型DestinationRootNavGraph(start true) Composable fun HomeScreen() { ... } DestinationAuthNavGraph(start true) Composable fun LoginScreen() { ... } DestinationAuthNavGraph 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 : NavTypeSerializerUserId() { 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: StateFlowString? 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 // 可选可空 ) { ... }调用方必须传articleIdhighlight和source可以不传。生成的路由大概长这样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 类型不支持时的处理路径如果某个参数类型既不是基础类型也没有对应的NavTypeSerializerKSP 会在构建时直接报错错误信息会明确告诉你哪个参数、哪个类型不受支持。处理路径只有两条要么把这个参数从页面参数改成 ViewModel 或仓库来拿数据要么给类型写序列化器。我个人的建议是页面参数只放轻量标识符比如id、tabIndex复杂对象一律通过 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-filteractivity android:name.MainActivity intent-filter action android:nameandroid.intent.action.VIEW / category android:nameandroid.intent.category.DEFAULT / category android:nameandroid.intent.category.BROWSABLE / data android:schemehttps android:hostexample.com / /intent-filter /activityrelease 包深链失效是常见问题。先确认两点链接里的 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.auth、NavGraphs.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 迁移操作顺序不要一把梭全仓替换按这个顺序推进先把 Kotlin 升到 2.0加上org.jetbrains.kotlin.plugin.compose插件确认项目在旧导航下能正常编译运行。在 Gradle 里把 KAPT 的 compiler 依赖替换成 KSP 的 dependency同时删掉kapt插件。逐个模块打开生成代码确认NavGraphs和各个Destination对象能正常生成。迁移MainActivity的 NavHost换成DestinationsNavHost(navGraph NavGraphs.root)。批量把navController.navigate(xxxScreen/...)替换成navigator.navigate(XxxDestination(...))这一步建议按页面模块分批做每批回归一次。处理 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 字符串第三升级版本前先看 changelogbeta 版本之间破坏性变更比较频繁我遇到过 2.8 到 2.9 之间底部弹窗 API 的调整平级跨多个版本升级风险更大第四新模块可以先小范围试点一个典型流程验证生成代码、深链、Hilt 集成都没问题再铺开全项目。最后分享一个我自己吃过大亏的经验迁移期间一定要把链接深链测试纳入回归列表。手写方案你可以临时改 route 字符串补救但 Compose Destinations 的深链路径和函数参数是绑定的出了问题往往要改代码发版。迁移完成后建议在 CI 里加一条深链冒烟测试把关键路径的深链在每次构建后自动点一遍这个成本很低能挡住大多数导航回归。
返回列表