本文译自「Migrating App to Navigation 3: Pain, Overtimes, and Hotfixes」,原文链接medium.com/proandroidd...,由Tetiana Synytsyna发布于2026年6月30日。

2026 年初,我们将拥有超过 170 个页面的 Android 应用从 Navigation 2 迁移到了 Navigation 3。
Navigation 3 引入了类型安全的目标位置、持久化的返回栈以及更现代化的导航 API。虽然整个迁移过程相对顺利,但我们仍然遇到了一些意想不到的挑战------从处理底部面板到修复发布后才出现的崩溃问题。
在本文中,我将分享我们决定迁移的原因、迁移过程、遇到的问题以及从中汲取的经验教训。
如果你正计划迁移到 Navigation 3,本文的经验或许能帮你节省一些时间,并避免一些麻烦。
Navigation 2 的使用体验
迁移前的情况
我们的应用开发于 2021 年,其导航基础基于 Jetpack Compose Navigation,当时这是 Google 推出的一个相对较新且尚未完全稳定的解决方案。
因此,和任何大型应用一样,我们面临着:
-
遗留代码
-
导航扩展函数
-
为弥补标准库的不足而编写的自定义解决方案
值得一提的是,我们的底部表单也是基于该导航框架实现的。
而 Navigation 3 的出现,则从根本上重新定义了导航方式。
我们为何决定迁移
我们开始权衡技术债务与未来机遇,最终决定进行迁移。对我们而言,这不仅仅是"更新一个库"。
我们将此次迁移视为对平台的一项投资。
首先,我们的产品路线图已经涵盖平板电脑和折叠屏设备,而现有的导航实现与自适应布局和多窗格场景并不兼容。
其次,导航堆栈逐渐被扩展功能和自定义逻辑所淹没。虽然它能够运行,但导航层中每个后续功能的维护成本都越来越高。
我们明白迁移意味着风险、影响范围广以及潜在的回归问题。然而,推迟迁移只会增加技术债务,并使未来的迁移成本更高。
是什么说服了我们
- Compose-first 方法: 应用程序中的整个 UI 和导航已经基于 Compose 构建。
- MVI 和状态驱动的导航: Nav3 将导航堆栈视为常规状态。这与我们的 MVI 架构非常有机地结合在一起。
从概念上讲,导航开始看起来像这样:
kotlin
val navigationState = rememberNavigationState(
startKey = MainScreenPointScreen,
topLevelKeys = setOf(MainScreenPointScreen)
)
val navigator = remember { Navigator(navigationState) }
fun navigate(key: NavKey) {
when (key) {
state.currentTopLevelKey -> clearSubStack()
in state.topLevelKeys -> goToTopLevel(key)
else -> goToKey(key)
}
}
fun goBack(): Boolean {
backStack.removeLastOrNull()
return true
}
导航变得非常接近我们已经在应用程序的其他部分中使用的状态驱动方法。
- 自适应布局: 商业计划包括对平板电脑和 Pixel Fold 的支持。
- Google 要求: Google 正在积极推动自适应布局和大屏幕。此外,从 Android 17 开始,固定屏幕方向实际上成为大多数应用程序的传统方法。 Google Play 商店还将正式优先考虑支持大屏幕的应用程序。
因此,对我们来说,这看起来像是一个选择,"要么现在以受控的方式进行,要么稍后再返回,但压力要大得多。"
另外,我建议正在启动新应用程序的开发人员从一开始就使用 Nav3 设计架构。
迁徙是如何发生的以及迁徙之后还有生命吗?
2025 年 11 月
我看到 Nav3 的稳定版本已发布,作为一名积极主动的开发人员,受到新导航方法的启发,我为下一个冲刺创建了一个调查任务。
调查的目标很简单:评估迁移的可行性、受影响的区域、风险,并了解它对于我们应用程序的规模是否现实。很快,人们就清楚了:这并不容易。
我立即发现了主要风险------增量迁移是不可能的。由于 Nav2 和 Nav3 之间缺乏向后兼容性,只有一次性迁移所有页面才能进行转换。部分迁移不是一种选择。
调查结束后,领导和我评估了风险、潜在的回归以及未来几个季度的路线图。由于已经规划了自适应布局,因此迁移获得了批准。
2026 年 2 月
在下一个冲刺计划中,我被分配了一个专门的迁移冲刺,并且在整个持续时间(漫长而有趣且充满痛苦的两周)中,我专门从事导航工作。迁移完全是手动完成的。
是的,我尝试过使用人工智能。但事实证明,对于遗留下来的代码库来说,迁移过于复杂。由于扩展功能、自定义导航解决方案和重要的依赖关系,Claude Code 有时会为我们的特定旧设置提供不一致的结果。它不会迁移到 Nav3,而是返回 Nav2 代码并写入:_"迁移成功完成,该项目现在可以在最新的稳定版本的导航上运行"。_但我们稍后会回到这一点。
迁移到 Nav3 花了我漫长的两周时间,而且每天都加班。免责声明:不,公司没有强迫我。是的,他们会给我尽可能多的时间。是的,没有人让我写这个😄。
有趣的事实:Gemini 和 Claude Code 估计整个迁移过程需要团队花费 6 至 8 周的时间。最终我一个人用了两周时间完成了。
加班。为什么?
像这样的大规模重构有一个主要缺点------合并冲突。我们特意决定在迁移期间不冻结开发,因为这对业务来说成本过高。当我并行进行迁移时,团队继续开发新功能。
有趣的部分来了:当我手动重写应用程序中的每个页面时,我的同事们同时继续编写代码。创建新的页面和功能。在旧的导航上。是的,我试图与他们协商让他们在这段时间休假------但没有成功。
所以我很快意识到:最好尽快完成这次迁移,因为拖得越久,解决合并冲突的成本就越高,也就越痛苦。
意外的
底页
在计划迁移时,我错过了一个关键细节 - 底页。 Nav3 没有现成的解决方案来通过导航显示底部表单。我必须围绕 entry<> 编写自己的、不太优雅的包装器,在 metadata 中传递信息,指示它是底部工作表,如果为真,则通过 ModalBottomSheetLayout (材料 3)而不是 NavDisplay 显示此页面。
kotlin
val NavEntry<out NavKey>.isBottomSheet: Boolean
get() = metadata["isBottomSheet"] as? Boolean == true
inline fun <reified T : NavKey> EntryProviderScope<NavKey>.bottomSheetEntry(
noinline content: @Composable (T) -> Unit,
) {
entry<T>(
metadata = mapOf("isBottomSheet" to true),
content = content
)
}
NavDisplay(
entries = navigationState.toEntries(mainEntryProvider)
.filter { it.isBottomSheet.not() },
onBack = { navigator.goBack() },
)
这不是一个理想的解决方案------更多的是一个让我们能够继续前进的临时解决方案。如今,通过"SceneStrategy"存在更好的方法,我们后来替换了这个实现。
深层链接
迁移的一个意想不到的好处是有机会正确重新思考我们的深层链接。从历史上看,我们应用程序中的深层链接并不是以最佳方式构建的,而且并不总是很明显是哪个页面处理它们。
在迁移过程中,我将它们整合到一个地方并集中了逻辑。这带来了一个简单但非常切实的好处:现在更容易准确理解每个深层链接发生的情况。
之前:
bash
composable(
deepLinks = "$uri/${ScreenName.PROFILE.value}/?$PROFILE_ID={$PROFILE_ID}" +
"&$EVENT_CONTEXT={$EVENT_CONTEXT}" +
"&$EVENT_ANCHOR={$EVENT_ANCHOR}" +
"&$ENTRY_POINT={$ENTRY_POINT}" +
"&$STREAM_ID={$STREAM_ID}",
route = profileRoute(),
arguments = profileArguments(),
) {
ProfilePreview(
//profile params
)
}
之后(概念):
kotlin
@Serializable
data class ProfilePreviewPointScreen(
override val deepLinkPath: String = "${ScreenName.PROFILE.value}"
) : NavKey,
DeepLinkable
entry<ProfilePreviewPointScreen>(
metadata = BottomSheetSceneStrategy.bottomSheet()
) { entry ->
ProfilePreview(
//profile params
)
}
它可能看起来不像是大规模的重构,但实际上,它极大地简化了维护和调试。
稳定化
完整的回归测试、稳定性和错误修复花费了大约一周半的时间。老实说,我以为我们会发现错误并再稳定一个月(只是不要告诉我的领导)。借助自动化测试,我们发现了大量很难手动重现的问题,但用户肯定会留下有关这些问题的评论。
发布
我们稳定下来,进行回归,发布,打开 crashlytics,向 1% 的用户推出,屏住呼吸,等待。我们没等太久------崩溃就开始出现了。
后退导航问题
要了解这些崩溃的本质,我们需要查看后退导航。这种导航有两种变体:
- 标准后退导航: 从当前页面,按后退将使用户返回到上一页面。

- 返回导航 2+ 个页面返回: 例如,当在聊天中阻止用户时,你需要将用户弹出几个页面。

对于第一种情况,一切都很稳定。对于第二种情况,我们通过导航(迁移后)传递了一个"entryPoint: NavKey"参数,以准确知道在出现阻塞时将用户返回到哪个页面。
kotlin
data class BlockUserRoute(
val entryPoint: NavKey
)
这导致了崩溃并出现以下错误: 致命异常:kotlinx.serialization.SerializationException。
在 Nav3 中,通过导航传递的所有参数都必须是可序列化的。我们的"NavKey"不可序列化,因此我们必须创建一个包装器:
kotlin
@Serializable
open class NavStateSerializable : NavKey
我们发布了一个修补程序,并在第二次尝试中成功完全发布。
积极主动是好事,但并不总是如此
还记得我提到过我手动迁移了所有内容吗?尝试过人工智能,但失败了。好吧,在我们发布一个半月后,Google 专门针对 Nav3 迁移推出了 Claude Code 技能。
我很好奇克劳德·科德会对我有多大帮助。我切换回迁移前分支并尝试使用 Claude Code 进行迁移。我可以自信地说,这可以节省我一周的工作时间。这意味着迁移速度会快两倍。是的,扩展和遗留代码仍然需要手动迁移,并且在 Claude Code 之后验证更改会花费大量时间,但不可否认的是,Claude Code 会优化我所做的大量单调的手动工作。
此外,Google 已经为 Bottom Sheets 提供了内置解决方案 - 特别是通过"SceneStrategy"。我们已经用"BottomSheetSceneStrategy"替换了自定义解决方法。这种方法要方便得多。
你可以在此处找到"BottomSheetSceneStrategy"的完整代码。
因此,如果我没有急于迁移并等待几个月,那么由于 Claude Code 和 Bottom Sheets 的现成解决方案,迁移会更容易一些,并且开发速度会快两倍。不过,我认为稳定和修复错误所需的时间不会有太大变化。
结论和经验教训
底页...再次
由于我们的底部工作表位于导航堆栈内,因此存在一个关键的细微差别。
仔细看看这个流程:

考虑这种情况:显然,由导航驱动的底部工作表是导航堆栈的一部分。但是,在从底部工作表导航到后续页面的情况下,如果你不手动关闭它,则从该新页面按返回将再次显示底部工作表,因为它仍在堆栈中。因此,在导航到另一个页面之前,必须手动关闭底部工作表。这很容易被忘记。
你始终可以为此编写扩展,但它仍然可能不明显,特别是对于团队中的新开发人员而言。
当我不建议迁移时
我强烈建议在以下情况下权衡转向 Nav3 的必要性:
- 基于片段的架构: 在迁移之前,你必须完全摆脱片段。这可能意味着具有高风险的大规模重构。
- 紧迫的期限: 即使你已经使用 Compose 导航,迁移也会造成巨大的受影响区域,并且由于回归可能会给业务带来高昂的成本。
- 专有导航解决方案: 如果你的自定义解决方案已经稳定运行,则迁移可能是不合理的。
- 游戏: 如果你的应用程序是游戏并且你严格依赖屏幕方向(在本例中主要是横向),Google 允许例外以保持方向固定。
优点
尽管存在所有问题,我相信迁移是完全合理的,最终我们赢了。具体来说:
- 多窗格 UI: 自适应布局对我们来说不再是问题。我们可以通过简单地为特定页面指定适当的"sceneStrategy"来轻松实现这一点。
- 可预测的导航: 导航堆栈变得更加透明且更易于调试。
- 深层链接: 在迁移过程中,我重构了大量遗留代码,并将深层链接引入了干净的结构。
总结
整个迁移过程大致可以总结为:
- 团队: 1 名活跃且尚未精疲力竭的开发人员
- 开发时间: 2 周
- 解决合并冲突: 1 天
- 受影响区域: 302 个文件,或者简单地说 --- 整个应用程序
- 测试和稳定: ~1.5 周
- 修补程序: 1
至关重要的是,我们通过几个实际标准评估了迁移的成功:
- 发布后导航流程的稳定性(崩溃和与导航相关的回归)。
- 将新页面集成到新导航框架的速度。
- 能够无缝构建自适应布局,无需额外的解决方法。
- 调试导航堆栈(是否变得更容易重现场景)。
迁移后,最明显的变化出现在日常开发中:
- 新页面更容易连接导航,无需额外的扩展功能。
- 导航堆栈更可预测且更易于调试。
- 很大一部分遗留逻辑就这样消失了。
- Nav3 事实证明更接近于状态驱动的方法,并且自适应布局不再感觉像一个巨大的独立项目。
但如果你还计划迁移,请分配更多时间来保持稳定,不要低估底部工作表,并仔细检查导航中的序列化设置。
希望我的经验可以帮助你避免我的错误,让你的迁移更快、更少痛苦!
祝撸码快乐! 😃
欢迎搜索并关注 公众号「稀有猿诉」 获取更多的优质文章!
保护原创,请勿转载!