先说说我为什么会盯上 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.classuser_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: Classout Fragment ) object RouterCenter { private val routes ConcurrentHashMapString, RouteInfo() fun register(info: RouteInfo) { routes[info.path] info } fun registerAll(infos: ListRouteInfo) { infos.forEach { routes[it.path] it } } fun find(path: String): RouteInfo? routes[path] fun allRoutes(): CollectionRouteInfo 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:idid/nav_host android:namecom.example.RoutingNavHostFragment android:layout_widthmatch_parent android:layout_heightmatch_parent /注意这里android:name指向的是我们的自定义 Fragment而不是默认的NavHostFragment。同时这里刻意没有写app:navGraphnavigation/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:idid/user_detail android:namecom.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.getFragmentRoutingNavHostFragment().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().requiredLong(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 CopyOnWriteArrayListRouteInterceptor() 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?id1这会比写在 XML 里的 DeepLink 灵活得多。还有一个我非常推荐的做法在开发调试阶段给 Router 增加一个所有路由路径的汇总导出功能。把 RouterCenter 里的所有 path 同步到一份 JSON 文件里每次 App 启动时打印出来可以很方便地核对哪些路径已经注册、哪些路径缺失。这在联调页面跳转时属于救命级的功能。从我个人的体会来说这套方案最核心的价值其实不在于重写了 Navigation而在于把导航能力和业务能力做了一个优雅的隔离。页面怎么打开、怎么传参、怎么拦截这些横切关注点从业务代码里抽离到了基础设施层后续做大型重构时也少了很多阻力。最后再分享一个小技巧。如果你现在还不确定这套方案是否适合团队不要着急推翻现有导航方式。完全可以先把Router工具类写好然后把新页面的跳转逐步切换到 Router 上老页面继续沿用原来的方式。等新代码的比例超过八成时再统一重构旧页面。这种渐进式替换的路径比一次性重写整个导航体系要稳妥得多。