Jetpack Navigation 的 Compose 集成深度,深层链接的坑修完了吗

Jetpack Navigation 的 Compose 集成深度,深层链接的坑修完了吗

Jetpack Navigation 的 Compose 集成深度,深层链接的坑修完了吗


Jetpack Navigation 的 Compose 集成深度,深层链接的坑修完了吗


如果你的应用还在用单 Activity + Compose 的架构,大概率已经踩过这个坑:用户从通知栏点进来,或者从外部链接唤起 App,页面确实跳到了目标界面,但按一下返回键,逻辑就开始发疯。要么直接回到桌面,要么莫名其妙地穿过了一层根本不存在的中间页,要么底部的 Bottom Bar 高亮还停留在上一个 Tab。你查了日志,Navigation 的返回栈里一塌糊涂,而你的代码看起来跟官方文档一模一样。


这就是 Jetpack Navigation 在 Compose 环境下处理深层链接(Deep Link)的真实现状。Google 从 Navigation 2.4 开始喊多返回栈,2.7 又改了一堆内部状态机逻辑,但 Deep Link 在 Compose 里的体验,至今依然像在拆盲盒。


rememberNavController 记住的到底是什么


Compose 的入口看起来足够简洁,一个 rememberNavController() 加上一个 NavHost, route 一挂,Deep Link 一声明,似乎就能收工。但问题恰恰出在这个 remember 上。


rememberNavController() 本质上是在 Composition 的局部作用域里保持一个 NavHostController 实例。对于普通导航,这没问题。可一旦 Deep Link 介入,事情就变成了 Activity 级别的 Intent 处理与 Composition 生命周期的混合双打。系统把 Deep Link Intent 丢给 Activity 的时候,你的 Compose 界面可能还没重组完,甚至你的 NavGraph 都还没挂上去。


更隐蔽的是 Configuration Change。你旋转一下屏幕,rememberNavController 因为是 rememberSaveable 的底层支撑,确实能恢复控制器实例,但 Deep Link 带来的 Intent 数据呢?如果你是在 Activity.onCreate 里手动 handleDeepIntent,旋转后这个 Intent 逻辑怎么走?很多开发者发现,他们在 DisposableEffect(Unit) 里监听 Intent,结果屏幕一转,Side Effect 重新触发,Deep Link 又被消费了一次,导航栈里硬生生多出一个副本。


Navigation 2.5 之前,官方甚至没有一个标准的 Compose 范式来处理 onNewIntent。你在 XML View 时代可以在 Activity 里直接拦截 Intent,然后手动调用 navController.handleDeepLink()。到了 Compose,Google 让你把 Intent 塞给 NavHostdeepLinks 参数,或者通过副作用自己处理。问题是 NavHostdeepLinks 参数接受的是一组 URI 模式,它背后依赖的是 NavGraph 的构建时机。如果你的 Graph 是动态构建的——比如根据用户登录状态决定 startDestination——那么 Deep Link 匹配时 Graph 可能还没准备好,匹配直接失败。你在 logcat 里什么都看不到,因为 Navigation 库静默 swallow 了这次匹配失败。


说白了,rememberNavController 记住的是控制器对象,但它管不住 Android 系统 Intent 模型的异步性和粗暴性。Compose 的声明式 UI 假设状态是单向数据流,而 Deep Link 是操作系统突然拍在你脸上的副作用,这两者在架构层面就是互斥的。


Deep Link 的 Intent 血统与 Kotlin DSL 的排异反应


Jetpack Navigation 从 2.4 开始全面推 Kotlin DSL,XML 导航图被官方打上了 legacy 的标签。DSL 写起来确实爽,但 Deep Link 的声明方式暴露了一个历史包袱:Navigation 组件骨子里仍然是基于 Intent Filter 和 URI 匹配设计的,这套东西从 Android 5.0 时代就长这样,跟 Compose 的函数式路由完全是两套语义。


在 Compose 里,你写一个 composable("profile/{userId}"),然后挂一个 deepLinks = listOf(navDeepLink { uriPattern = "https://app.example.com/profile/{userId}" })。这看起来像是声明式路由,实际上底层走的还是 NavDestination.addDeepLink(),最终生成的是 NavDeepLink 对象,匹配逻辑和 XML 时代一模一样。这意味着你依然在跟 PendingIntentTaskStackBuilderFLAG_ACTIVITY_CLEAR_TOP 打交道。


有一个非常具体的 bug 场景:你用 NavDeepLinkBuilder 构建一个通知跳转的 PendingIntent,指向某个特定页面。用户点击通知,App 从后台切到前台。如果此时你的 Activity 已经因为内存压力被系统杀掉了,Navigation 会重建整个返回栈——这本来是它的卖点——但重建出来的栈,跟 Deep Link 目标之间经常会出现重复。比如你原来在 Tab A 的详情页,Deep Link 想跳到 Tab B 的详情页,结果返回栈里既有 Tab A 的重建实例,又塞了一个 Tab B 的新实例,按返回键的时候,用户会先退到 Tab A 的重建页,一脸懵逼。


Google Issue Tracker 上有个持续了近三年的 issue,描述的就是 NavHost 在 Deep Link 场景下 startDestination 和 Deep Link destination 同时入栈的问题。官方回复一直在打太极,先说是预期行为,后又在 2.7.0-alpha 里改了状态恢复逻辑,但至今没有根治。开发者社区里普遍的 workaround 是:在 Activity 里手动接管 Intent,自己判断是不是 Deep Link,如果是,就不让 NavHost 自动处理,而是等 Composition 稳定后手动 navigate()。这等于说官方组件自己的 Deep Link 能力在复杂场景下是靠不住的,你得写一套自己的拦截层。


我个人不太认同把 Deep Link 直接等同于路由的做法。Deep Link 是操作系统级的事件,路由是应用内部的状态转换。Navigation 组件硬要把两者揉在一起,在 View 时代还能勉强通过 Activity 和 Fragment 的生命周期兜底,到了 Compose 时代,这个兜底没了,漏洞全露出来了。


多返回栈是良药还是麻药


Navigation 2.4.0 最大的卖点是多返回栈支持,也就是 BottomNavigation 或者 NavigationRail 切换 Tab 时,每个 Tab 的返回栈独立保存。这对普通页面流转是雪中送炭,但对 Deep Link 简直是雪上加霜。


想象这个场景:你的 App 有三个 Bottom Tab,首页、消息、我的。用户在消息 Tab 二级页面时把 App 切到后台,系统杀了进程。第二天用户点了一条推送,Deep Link 指向首页的某个活动详情页。App 冷启动,NavHost 的 startDestination 是首页,同时 Deep Link 匹配到活动详情页。因为多返回栈的保存状态机制,NavHost 会尝试恢复之前的返回栈状态——也就是消息 Tab 的二级页面——然后又要处理 Deep Link 的目标页面。最终的结果往往是:用户看到了活动详情页,但点击返回时,回到了消息 Tab 的二级页面;再点返回,才回到首页;底部的 Bottom Bar 高亮却一直在首页。


这个行为在 2.4.x 和 2.5.x 版本里非常不稳定。saveStaterestoreState 的文档写得模棱两可,实际测试下来,Deep Link 触发的导航几乎不会正确恢复或清理对应的返回栈。Navigation 2.6 之后官方引入了一些 clearBackStack 的 API,但那是事后擦屁股,不是事前预防。


更讽刺的是,为了配合多返回栈,官方在 Now in Android 参考应用里写了一套极其复杂的 Deep Link 处理逻辑,基本上是在 ViewModel 里手动解析 URI,然后决定导航到哪、要不要清栈、恢复哪个 Tab。如果官方自己的参考应用都不敢直接用 NavHost 的自动 Deep Link 匹配,而是选择手动路由,那普通开发者凭什么相信这套机制是 production-ready 的?


多返回栈解决的是 Tab 切换时的状态记忆问题,但它没有解决——甚至加剧了——Deep Link 作为外部事件闯入时的状态一致性问题。Google 似乎假设了 Deep Link 只会发生在 App 的简单线性流程里,但现实世界的 App 全是 Tab 嵌套、底部导航、侧边栏混用的复杂结构。


进程死亡与通知点击的真实噩梦


最让开发者崩溃的场景,是 App 进程死亡后的通知点击。你发了一个 Firebase 通知,带一个 Deep Link URI。用户点击时,系统通过 ActivityManager 启动你的 Launcher Activity,携带这个 Intent。


在 Compose 里,你的 Launcher Activity 的 onCreate 被触发,你 setContent,里面放 NavHoststartDestination 指向你的 Main Screen。同时,你在 LaunchedEffect(Unit) 或者 DisposableEffect 里想读取 activity.intent?.data。这里有一个致命的时序竞赛:NavHost 在 composition 里一旦发现自己被设置了 graph,且 Intent 里有匹配的 Deep Link,它会自动尝试处理。而你的副作用代码可能同时也在尝试读取并导航。


如果你用的是 androidx.navigation:navigation-compose:2.6.0 之前的版本,你会发现 NavHost 的自动 Deep Link 处理优先级完全不透明。有时候它抢先处理了,你的副作用再 navigate 一次,导致两个相同页面入栈;有时候它没处理,你的副作用处理了,但 NavHost 在重组后又突然想起来要去匹配一次 Intent——尤其是在你调用 navController.setGraph() 之后。setGraph 这个调用在 Kotlin DSL 里是隐式的,NavHost 内部会调用它,而 setGraph 会重新触发 Deep Link 匹配。


Navigation 2.7 之后,Google 给 NavHost 加了 onDeepLink 参数,或者改了一些默认行为,让自动处理在特定条件下跳过。但文档里对此的描述只有寥寥数语,而且不同小版本之间的行为差异巨大。你升级一个 patch 版本,比如从 2.7.0 升到 2.7.5,Deep Link 的行为可能就变了,因为某个 Google 工程师在内部提交里"修复"了一个状态恢复 bug,结果打破了你的 workaround。


PendingIntent 的构建方式也是个大坑。如果你用 NavDeepLinkBuilder(context).setGraph(R.navigation.nav_graph).setDestination(R.id.somePage).createPendingIntent(),这在 XML 时代工作良好。但在 Compose DSL 里,你没有 R.navigation.nav_graph 的资源 ID,除非你同时维护一套 XML 只给 Deep Link 用。所以大多数人转而用显式 Intent 携带 URI 数据,然后自己解析。这等于把 Navigation 组件的 Deep Link 能力完全架空,只拿它当普通路由用。


现在去搜 GitHub 上主流的开源 App,比如 Now in Android、Jetcaster、JetNews,看它们怎么处理通知 Deep Link,你会发现清一色地在 ViewModel 或 Activity 里做手动路由。官方示例都不敢用官方 API,这本身就说明了问题。


官方的免责声明与开发者的自救


Google 在 Android 开发者文档里对 Deep Link 的 Compose 集成写得很乐观,基本就是告诉你 deepLinks = listOf(...) 然后 intent.data 自动匹配。但如果你仔细看 Now in Android 的源码,特别是 NiaAppStateMainActivity 里的逻辑,你会发现它们对 Deep Link 的处理极其谨慎。


在那个项目里,Deep Link 是在 MainActivityonCreateonNewIntent 里被捕获,然后通过一个 Channel 或者 StateFlow 传到 Compose 层,再由一个中心化的导航逻辑去消费。消费的时候还要判断当前是否已经处理过,防止重复导航。这套代码本质上是在 Compose 外围包了一层命令式导航壳,把 Navigation 组件的自动 Deep Link 能力完全绕开了。


这很 Google。文档和示例代码永远呈现出最美好、最简单的使用路径,但 production 代码里全是 TODO 和 workaround。Navigation Compose 的 API 设计给人一种"这里很简单"的错觉,实际上把最难的部分——状态恢复、进程死亡、返回栈一致性——全部留给了开发者自己去填坑。


我个人觉得,Navigation Compose 最大的设计失误,是试图让 NavHost 同时承担三项职责:UI 容器、路由表、以及 Deep Link 处理器。在 View 时代,这三件事至少分散在 Activity、XML Graph、和 Intent Handler 里。现在全挤在一个 Composable 函数里,时序问题、生命周期问题、副作用问题全部交织在一起,不出 bug 才怪。


有开发者尝试完全放弃 Navigation 的 Deep Link 能力,改用 Compose Router 或者自己基于 AnimatedContent 写导航。但这样做又失去了多返回栈、类型安全参数(Navigation 2.8 开始推的 kotlinx.serialization 导航参数)、以及与系统返回手势的集成。Google 把生态位占住了,体验却没跟上,这是最难受的。


那出路到底在哪


Navigation 2.8 开始全面转向基于 Kotlin Serialization 的类型安全

Material You 动态取色,实际项目中落地难点 2026-08-10

评论区