先说说我为什么会盯上 Navigation 这层皮。项目做到中后期,页面越来越多,好几个业务模块都独立成单独的源码集了,每次跨模块跳转都要先写一堆路由管理代码。原本以为用原生 Navigation 的 nav_graph.xml 就能解决一切,但实际用下来,发现它和这种"页面由不同团队各自维护、跳转规则还得能动态更新"的场景,适配得并不顺手。于是我就把 Navigation 的导航图构建逻辑拆出来,自己造了一套轻量路由导航方案,最终的体验倒是出乎意料地舒服。
这套方案说白了就做两件事:第一,用代码动态生成导航图,替代写死的 XML;第二,把字符串路由路径和 Fragment 类做映射,跳转时直接按路径走。它适合什么场景呢?如果你也在做需要业务模块解耦的中大型项目,或者你单纯觉得 Navigation 的 XML 导航图维护成本太高,这篇文章可以直接给你一套能落到现场代码里的参考实现。接下来我就从设计思路、核心实现、常见坑位这几个角度,把整个改造过程拆开讲清楚。
1. 为什么要把 Navigation 改造成路由导航
1.1 组件化开发中页面跳转的真痛点
做组件化之后,第一个吃螃蟹的往往就是页面跳转。传统写法里,模块 A 想跳模块 B 的页面,无非三选一:直接依赖模块 B 编译,反射调用目标类,或者接一个谁都能用的公共路由框架。第一种最直接,但会让模块 A 和模块 B 在编译期死死绑在一起,B 改一个类名都要牵动 A 重新构建。第二种看似解耦,但所有跳转逻辑散落在各处,出了问题没法统一收口,参数传递也只能全靠猜。第三种最省心,但等于在项目里额外引入了一套架构体系,和 Navigation 本身的生命周期栈、SavedState 机制是两套东西,混着用反而多了很多心智负担。
如果你恰好已经在用 Navigation,事情会更有意思。导航图默认是写在res/navigation下的 XML 里,每个页面在这样的设计下,代码和配置是分离的,页面一多,XML 文件越来越长,跳转关系越来越难看懂,每次新增页面都得同时改两三处地方。
真正到了多团队协作的时候,问题还会更明显。比如用户模块的页面路径、首页模块的页面路径,全堆在同一个导航图里,任何团队改这个文件都可能产生冲突。路由跳转也变成了"经常要改导航图、改资源 ID"的强耦合操作。我就见过项目组因为一个 XML 的 merge 冲突,排查了大半天,最后发现问题只是两个人在同一个 nav_graph 里加了不同的页面。
1.2 原生 Navigation 的几个让人难受的地方
原生 Navigation 本身做得并不差,栈管理、转场动画、状态恢复都是一等一的体验。但在我的应用场景里,它有几个天然的短板。
一个是导航图必须在编译期确定。navController.navigate(R.id.some_page)这个R.id在编译期就固定死了,如果你希望某个跳转规则是根据服务器下发的配置动态变化的,原生 Navigation 就很难直接支持。你必须有预埋的 destination,或者干脆不用它。
另一个是跳转之后想要做统一拦截很别扭。比如我想做一个全局的路由守卫:判断是否登录、是否具备权限、是否是某个灰度用户组,原生 Navigation 没有提供类似的前置钩子。虽然可以用自定义 Navigator 或者包装 NavController 来做,但大多数人第一次接触这个点的时候,都会觉得无处下手。
还有一个是 DeepLink 的注册问题。原生支持在 XML 里声明<deepLink>,但这同样是编译期行为,没办法在 App 运行时动态追加链接。对于需要运营配置、中转落地页的场景就不太够了。
1.3 现成第三方路由和自研方案的取舍
那直接用 ARouter 或者 TheRouter 这类第三方路由库呢?它们确实很成熟,尤其是组件化场景下,注册、拦截、降级方案都很齐全。但我的情况有点特殊:项目已经用了 Navigation,我不想再为路由单独引入一套庞大的体系,更不想丢失 Navigation 的 Fragment 生命周期栈和状态恢复能力。
第三方路由框架在主流的实现里,通常是自己维护一个路由表,靠拦截Activity.startActivity或者分发 Fragment 事务来工作。这意味着 Navigation 的NavController和它根本没有打通,两个体系的返回栈、动画、状态恢复都是各管各的,混用起来很割裂。
所以我才决定从 Navigation 的内部机制入手:保留它强大的 Navigator 执行体系,只把导航图的构建方式改成"运行时动态生成"。这样跳转入口还是同样的navController.navigate(destinationId),但背后不再依赖 XML,而是依赖我们自己的路由表。从使用者的视角看,这就是一套轻量的路由导航方案,但它内芯又是正统的 Navigation。
2. 整体方案:动态导航图加路由映射
2.1 先搞懂 NavController 到底在导航什么
在展开代码之前,先把 Navigation 的工作机制理清楚。NavController本身并不负责真正打开 Fragment,它更像一个调度中心。它内部维护着一个NavigatorProvider,里面注册了各种Navigator。每当你调用navigate(destinationId),NavController会先从当前的NavGraph中根据 ID 找到对应的NavDestination,再根据这个 destination 绑定的 Navigator 类型,把具体的"打开页面"动作委托给对应的 Navigator。
对于常规的 Fragment 页面,这个任务落在FragmentNavigator身上。它内部用FragmentManager执行事务,把目标 Fragment 放进 NavHost 容器里,同时维护返回栈和 SavedState。
换句话说,NavGraph就是一张地图,Navigator就是交通工具。原生导航把地图画成了 XML,我们这次要做的事情,就只是把画地图的方式从 XML 改成代码,交通工具仍然用原生那套。这个认知非常关键,它决定了整个改造的复杂度不会失控。
2.2 为什么不需要重写整个 Navigator
很多人一提到"自定义 Navigation",第一反应就是继承Navigator自己写一套 Fragment 跳转器。我试过,这个坑很深。因为FragmentNavigator里面已经处理了 Fragment 的事务队列、状态保存、返回栈管理、动画参数,这些逻辑纠缠在一起,你很难在重写时不出漏洞。
后来我换了个思路:不重写交通工具,只重写地图。既然NavController只是根据 destination 的 id 去找地图上的节点,那我完全可以自己在代码里把地图画出来。这样我既保留了FragmentNavigator的稳定性,又从源头上控制了我所有页面可以动态增减的需求。
在这个设计里,导航执行的部分一个字都不用动,动的只是导航图的数据来源。说得更直白一些:我们自定义的是导航图的生成方式,而不是导航动作的执行方式。
2.3 路由表与导航图的对应关系
整套方案里,最核心的数据结构就是路由表。一个字符串路径,对应一个 Fragment 类。比如main_page这个路径,对应MainFragment.class;user_center对应UserCenterFragment.class。当用户执行跳转时,只需要传入路径字符串,框架内部把字符串换算成 destination id,交给NavController去导航。
映射关系本身很简单,但有几个设计点需要提前考虑。
第一个是 destination id 怎么定。Navigation 要求每个 destination 必须有一个唯一的 int id。既然我们面向的是字符串路径,最自然的想法是把字符串映射成 int。我用的方案是("route:" + path).hashCode()。这样只要路径不变,id 就不会变,而且因为加了前缀,和资源 id 冲突的概率被压得很低。
第二个是启动页怎么定。NavGraph必须有一个startDestination,这是我们 App 打开后 NavHost 容器里第一个要加载的页面。这个值也可以从路由表里取,约定一个固定路径,比如"app/launch",注册路由时保证它一定存在。
第三个是路由参数怎么传。Navigation 原生支持Bundle传参,这也是我们用的主要载体。跳转时传入 Bundle,目标 Fragment 在arguments里拿到数据。为了减少魔法键分散在各处的现象,我会在工具类里定义一个参数解析层,统一管理参数的读写名称。
3. 完整实现:手写一套路由导航框架
3.1 路由中心:先写一个注册表
路由中心不复杂,本质上就是一个线程安全的 Map,外加几个注册查找方法。这里我写得比较直接,核心代码看注释就很好懂。
data class RouteInfo( val path: String, val fragmentClass: Class<out Fragment> ) object RouterCenter { private val routes = ConcurrentHashMap<String, RouteInfo>() fun register(info: RouteInfo) { routes[info.path] = info } fun registerAll(infos: List<RouteInfo>) { infos.forEach { routes[it.path] = it } } fun find(path: String): RouteInfo? = routes[path] fun allRoutes(): Collection<RouteInfo> = routes.values fun clear() = routes.clear() }这个类的设计没有什么高明之处,但有两个细节值得注意。
第一,ConcurrentHashMap是为了防止在 Application 初始化路由时,多个模块并发注册造成数据竞争。虽然实际场景里注册通常发生在主线程,但加个并发容器更稳妥。
第二,RouteInfo里我只放了 path 和 fragmentClass 两个字段。有朋友可能会建议把动画参数、是否支持转场这些也放进去,从扩展性的角度确实可以,但不建议一开始就堆一大堆字段。我见过太多人把路由配置写得特别满,结果后面每次改动都牵一发动全身。
如果你希望支持启动模式、默认参数等高级功能,可以在RouteInfo上加一个extras: Bundle?字段,或者一个options数据类,等到确确实实需要的时候再加,不要提前过度设计。
注册入口一般放在 Application 的onCreate里,或者你项目如果有自定义的初始框架,也可以在对应模块的初始化器里调用:
class App : Application() { override fun onCreate() { super.onCreate() // 可以在这里集中注册 RouterCenter.registerAll( listOf( RouteInfo("app/launch", LaunchFragment::class.java), RouteInfo("app/main", MainFragment::class.java), RouteInfo("user/detail", UserDetailFragment::class.java), RouteInfo("goods/list", GoodsListFragment::class.java) ) ) } }3.2 自定义 NavHostFragment
接下来是整套方案的承重墙:自定义 NavHostFragment。它要做的事有且只有一件:在合适的时候,用我们自己的路由表把导航图动态构建出来,并设置给 NavController。
先看布局文件里怎么用它:
<androidx.fragment.app.FragmentContainerView android:id="@+id/nav_host" android:name="com.example.RoutingNavHostFragment" android:layout_width="match_parent" android:layout_height="match_parent" />注意这里android:name指向的是我们的自定义 Fragment,而不是默认的NavHostFragment。同时,这里刻意没有写app:navGraph="@navigation/xxx",因为我们并不想让它加载 XML 导航图,导航图完全由代码动态生产。
对应的 Fragment 实现如下:
class RoutingNavHostFragment : NavHostFragment() { override fun onCreateNavController(navController: NavController) { super.onCreateNavController(navController) // 如果以后想扩展自定义 Navigator,可以在这里注册 // navController.navigatorProvider.addNavigator(...) } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) // 首次创建时才动态构建导航图 if (savedInstanceState == null) { buildDynamicNavGraph() } } private fun buildDynamicNavGraph() { val controller = navController val provider = controller.navigatorProvider // 1. 创建根导航图 val graphNavigator = provider.getNavigator(NavGraphNavigator::class.java) val fragmentNavigator = provider.getNavigator(FragmentNavigator::class.java) val graph = graphNavigator.createDestination().apply { id = NAV_GRAPH_ID } // 2. 遍历路由表,为每个路径创建 destination RouterCenter.allRoutes().forEach { info -> val destination = fragmentNavigator.createDestination().apply { id = routeId(info.path) className = info.fragmentClass.name } graph.addDestination(destination) } // 3. 设置启动目的地 val startId = routeId(LaunchPath) if (graph.findNode(startId) != null) { graph.startDestination = startId } // 4. 应用导航图 controller.graph = graph } companion object { private const val NAV_GRAPH_ID = 1 private const val LaunchPath = "app/launch" fun routeId(path: String): Int { return ("route:$path").hashCode() } } }这里有个先后顺序值得细说。
NavHostFragment在onCreate时已经创建了NavController,但此时 Fragment 容器视图还没准备好。如果在这个过早的时机就去构建导航图并尝试加载 Fragment,会因为在视图树上找不到容器 ID 而失败。所以我放在onViewCreated里做,这个时机 childFragmentManager、容器 ID 全部就绪,是安全的。
另一个重点是savedInstanceState == null这个判断。当设备旋转、Activity 被系统重建时,savedInstanceState不为空,NavController自己会从保存的状态中恢复之前的导航图和当前 Fragment,我们如果再去覆盖 graph,就会导致恢复状态被破坏。最典型的表现是页面重复叠加,或者 Fragment 的 arguments 错乱。
3.3 FragmentNavigator.Destination 的 className 是什么
上面代码里的className可能是你第一次见,我来解释一下。
FragmentNavigator.Destination和普通的NavDestination最大的区别,就是它多一个className属性。这个属性保存的是一个 Fragment 类的完整限定名。FragmentNavigator在真正执行导航动作时,会通过这个类名反射构造出目标 Fragment 实例。
这也就是为什么我们在动态构建导航图时,不需要像 XML 里那样写一堆<fragment>标签和android:name属性,只要把className设置成对应 Fragment 的全限定名即可。它本质上等同于:
<fragment android:id="@+id/user_detail" android:name="com.example.UserDetailFragment" />这种方式也带来一个硬性要求:所有参与路由的 Fragment 都必须公开无参构造函数。因为FragmentNavigator反射实例化时,默认走的是无参构造。如果你的 Fragment 定义里写了一个带参构造器,又没有显式补一个无参版本的,运行时会直接抛NoSuchMethodException。这个问题后面踩坑环节还要再讲。
3.4 封装跳转入口:从路径到 id
导航图构建好了之后,跳转就变得非常简单。你只需要把字符串路径换算成 destination id,然后调用navController.navigate(id, bundle, options)。
我习惯在外面包一层静态工具方法,让调用方的代码保持简洁:
object Router { fun go( navController: NavController, path: String, args: Bundle = Bundle.EMPTY, navOptions: NavOptions? = null ) { val id = RoutingNavHostFragment.routeId(path) if (navController.graph.findNode(id) == null) { throw IllegalArgumentException("路由路径未注册: $path") } navController.navigate(id, args, navOptions) } }这里findNode(id)的检查很有必要。如果路由表里没有注册过这个路径,navigate会抛出一个非常难懂的异常,堆栈信息还不是一般人能一眼看明白的。我们自己先校验一遍,给出的错误提示就亲切许多,排查问题效率也高。
调用方式类似这样:
Router.go( navController = binding.navHost.getFragment<RoutingNavHostFragment>().navController, path = "user/detail", args = bundleOf("userId" to 10086) )如果你的页面跳转比较规律,还可以进一步把navController的获取逻辑封装掉,让业务层只需要关注路径和参数,不用关心NavController到底从哪里拿。
3.5 参数传递的正确姿势
路由跳转里的参数传递,是最容易被忽视、最容易出 bug 的环节。原生 Navigation 的navigate(id, args)接收一个Bundle,它会把这个 Bundle 存到 destination 的参数里,并在 Fragment 创建时作为arguments传入。所以目标 Fragment 里直接读取arguments就行。
但有一个非常反直觉的坑:如果你在go()里传入的 Bundle 被直接用到了跳转中,那么当配置变更、Fragment 被系统重建时,这个 Bundle 会被原样恢复。可如果你在 Fragment 内部对arguments做了修改,这个修改在重建时可能会丢失。尤其当你经过若干次出栈入栈之后,数据错乱会让人抓狂。
我自己的习惯是:把不可变标识参数通过路由 Bundle 传递,把可变数据放到 ViewModel 或者内存缓存里。比如userId这种标识符适合走路由参数,而用户详情对象这种大对象,最好通过共享 ViewModel 或者单例缓存去拿,不要让路由系统承担它不该承担的职责。
为了减少各 Fragment 内直接操作 Bundle 的麻烦,我封装了一个参数读取的工具:
inline fun <reified T> Bundle.required(key: String): T { val value = get(key) ?: throw IllegalArgumentException("缺少路由参数: $key") return value as T }这样在 Fragment 里读参数时就变得很贴心:
val userId = requireArguments().required<Long>("userId")至于参数类型,尽量避免用Serializable存复杂的业务对象。跨模块通信时,你根本不确定对方模块会不会改实体类结构。轻量级的基础类型,才是路由参数的正道。
3.6 导航动画和返回栈定制
动态构建导航图之后,页面之间的转场动画不一定要绑定在每个 destination 上,我们可以通过NavOptions更灵活地动态配置。
最常用的做法,是给 Router 开放一个带NavOptions参数的跳转接口:
val options = navOptions { anim { enter = R.anim.slide_in_right exit = R.anim.slide_out_left popEnter = R.anim.slide_in_left popExit = R.anim.slide_out_right } } Router.go(navController, "user/detail", args, options)结合业务场景还可以做更有意思的事情。比如列表页跳到详情页,返回时希望直接回列表页而不是一级一级往回弹,就可以用popUpTo指定返回时的目的地:
val options = navOptions { popUpTo(RoutingNavHostFragment.routeId("app/main")) { inclusive = false } }这些能力完全是原生 Navigation 自带的,我们只是把入口统一到了自定义 Router 上。业务开发不需要关心内部 Navigation 的 API 细节,只需要知道怎么拼路径、怎么传参数、怎么配动画。
3.7 统一拦截器:给路由加一道控制闸门
解决了页面跳转的基本问题后,我很快遇到了新的请求:某些页面需要在跳转前先检查登录状态。如果在每个业务 Fragment 的入口都去检查一遍登录逻辑,代码很快就会变得又臭又长。于是我想到给 Router 增加一个拦截器机制。
拦截器的实现思路并不复杂。在真正执行navController.navigate之前,先按照注册顺序执行所有拦截器,任何一个拦截器返回 false 或者抛出特定异常,整条跳转就被中止。这样登录检查、权限校验、游客模式提示都从业务页面里剥离出来了。
interface RouteInterceptor { fun shouldIntercept(path: String, args: Bundle): Boolean } object Router { private val interceptors = CopyOnWriteArrayList<RouteInterceptor>() fun addInterceptor(interceptor: RouteInterceptor) { interceptors.add(interceptor) } fun go(...) { interceptors.forEach { if (it.shouldIntercept(path, args)) { return } } // 继续执行跳转逻辑 } }实际落地时,我写了一个登录检查拦截器:
Router.addInterceptor { path, _ -> val needLoginPaths = setOf("user/center", "order/detail", "pay/confirm") if (path in needLoginPaths && !LoginManager.isLogin()) { // 跳到登录页,并记录原始目标路径,登录成功后回跳 Router.go(navController, "user/login", bundleOf("origin" to path)) true } else { false } }这一层封装带来的收益是巨大的。一段时间后,产品要针对某些特殊路径做灰度控制,我只需要新增一个灰度拦截器,业务代码和路由表完全不需要改动。这正是把路由逻辑独立出来的价值所在。
4. 踩坑实录与排查技巧
4.1 启动页路由未注册引发的崩溃
动态构建导航图有一个非常典型的崩溃场景。假设你的启动页路径是app/launch,但在 App 冷启动那一瞬间,路由表还没有被完整注册,buildDynamicNavGraph执行后 graph 里找不到app/launch对应的节点,graph.startDestination设置时就会得到一个 null,紧接着设置controller.graph就会抛出异常。
这种问题出现的时机往往很尴尬,它不像编译期错误那样预先暴露,而是会在用户手机上突然爆发。我的处理方式有两个:一是初始化时尽可能提前注册启动页路由,比如直接把它写死在 RouterCenter 的静态注册块里;二是在构建 graph 时做防御:
if (graph.findNode(startId) == null) { // 如果没有注册启动页,就添加一个默认的占位页面 // 或者直接抛出带提示的异常,方便排查 }另外,进程被杀后恢复的场景也要留意。如果系统把 App 从后台杀掉了,用户回到 App 时触发重建,savedInstanceState可能不为空,但有些项目的路由注册逻辑仍然会执行。这里我们要保证每次 Application 启动都能把路由表重新注册完整,不要依赖上一次进程的残留数据。
4.2 NavGraph 重复构建导致的 Fragment 错乱
这条坑我踩得最深。有一次我在自定义 NavHostFragment 里把buildDynamicNavGraph放到了onCreate里,没有判断savedInstanceState,结果每次 Fragment 重建、甚至每次回到页面,graph 都被重新设置一遍。症状表现为:返回上一个页面时,会看到之前已经关闭的 Fragment 又被加载了一遍,甚至出现两个一模一样的页面叠在栈里。
根本原因是navController.graph的 setter 在每次被调用时,都会触发现有导航状态的清理和重新构建。如果原有的 Fragment 还在栈里,而 graph 被换成了新的,状态自然就错乱了。
正确的姿势我前面已经演示过:在onViewCreated里判断savedInstanceState == null才构建。再进一步,在构建之前先判断一下controller.graph是否已经有节点了:
if ((controller.graph as? NavGraph)?.findNode(startId) != null) { return }这就是典型的"重建幂等"思想:无论 Fragment 被创建多少次,只要导航图已经存在,就不重复造。
4.3 Fragment 构造函数被忽略的问题
这个问题看似低级,实际发生频率高得吓人。某同事写 Fragment 时不知道从哪里继承了一个带构造参数的基类:
class UserDetailFragment(private val userId: Long) : Fragment()编译没问题,但我们的路由框架用fragmentClass.newInstance()反射创建对象时需要无参构造函数,结果运行时就报NoSuchMethodException。
解决方式并不复杂,但需要从框架层面做约束。我在RouterCenter.register里加了一个校验:
if (info.fragmentClass.constructors.none { it.parameterCount == 0 }) { throw IllegalArgumentException("Fragment必须有无参构造: ${info.fragmentClass.name}") }这样在 App 启动注册路由时就能直接发现错误,而不是等用户跳转那个页面的时候才崩溃。好的框架应该把错误尽早暴露出来,越晚出现越难办。
4.4 路由表与内存泄漏的边界
路由表存的是Class类型,这种对象本身不会持有 Activity 或者 Fragment 实例,所以不存在传统意义上的内存泄漏。但要注意的是:注册进去的 Fragment 类不要引用外部上下文,因为 Fragment 类是全进程共享的,一旦被某个带状态的缓存引用,问题就被放大了。
有一种情况特别容易被忽视。如果你在路由表里注册了一个来自某个动态模块的 Fragment 类,而那个模块的 code 可以被热更新或者卸载,那注册表里残留的 Class 引用就会让模块卸载不干净,形成事实上的资源滞留。
我的习惯是给 RouterCenter 增加一个unregister(path)方法,并且在模块生命周期结束时反注册。如果你的项目也有动态化能力,这个接口迟早用得上。
5. 从能用走向顺手用的几个建议
整套方案跑起来之后,我发现最受益的其实是新增页面这件事。以前加一个页面要改 XML 导航图、定义资源 ID、确认跳转路径,现在只需要注册一行路由,再直接调用Router.go就能跳转。尤其在多人并行开发时,页面路由不再共享同一个导航图文件,每个模块自己注册自己的路由,几乎不产生冲突。
如果后续你还想进一步扩展,可以往这几个方向走。
一个是把路由表拆到每个模块内部,由模块自己负责编辑,这和组件化的架构天然契合。另一个是接入 Deep Link,比如通过路由参数解析xxx://user/detail?id=1,这会比写在 XML 里的 DeepLink 灵活得多。
还有一个我非常推荐的做法:在开发调试阶段,给 Router 增加一个所有路由路径的汇总导出功能。把 RouterCenter 里的所有 path 同步到一份 JSON 文件里,每次 App 启动时打印出来,可以很方便地核对哪些路径已经注册、哪些路径缺失。这在联调页面跳转时属于救命级的功能。
从我个人的体会来说,这套方案最核心的价值其实不在于"重写了 Navigation",而在于把导航能力和业务能力做了一个优雅的隔离。页面怎么打开、怎么传参、怎么拦截,这些横切关注点从业务代码里抽离到了基础设施层,后续做大型重构时也少了很多阻力。
最后再分享一个小技巧。如果你现在还不确定这套方案是否适合团队,不要着急推翻现有导航方式。完全可以先把Router工具类写好,然后把新页面的跳转逐步切换到 Router 上,老页面继续沿用原来的方式。等新代码的比例超过八成时,再统一重构旧页面。这种渐进式替换的路径,比一次性重写整个导航体系要稳妥得多。