☰
Navigation自定义路由:从路由表到Navigator的改造
2026/10/17 2:06:35 网站建设 项目流程

先讲个我实际遇到的场景。

早先在模拟项目X里,页面跳转全靠一手NavController加一堆常量,大概到二十几个页面的时候,跳转代码开始失控:每个页面为了取参数都要先判空,跳之前还得单独处理登录、埋点、渠道来源,新同学接手根本不敢动跳转逻辑。后来我按“路由表 + 动态目的地 + 自定义Navigator”的思路把Navigation重新包了一层,才彻底解决跳转散乱的问题。这篇文章就把完整思路、核心代码和踩坑过程写出来。如果你所在的项目页面多、模块多,或者想从Activity跳转迁移到Fragment导航,应该能直接用得上。

核心想表达的是:Navigation不是一个只能用来写XML导航的死框架,它的扩展点就藏在Navigator和NavGraph这两个抽象里。只要理解了这两个东西,你完全可以自己定义一套“按路由名跳转”的导航体系,同时享受系统自带的BackStack和状态恢复能力。

1. 先弄明白Navigation的导航机制,才知道该改哪里

1.1 一次navigate()调用背后发生了什么

在Jetpack Navigation里,表面上是NavController在指挥一切,但它其实是个调度者。真正干活的是一群Navigator。可以这样理解:NavController只负责维护一个栈,栈里的每个元素叫NavBackStackEntry,它记录了“我要去哪个目的地、带什么参数”。至于目的地到底是Fragment还是普通透明页面,以及用什么样的转场动作,都由目的地对应的Navigator实现。

当你在代码里写findNavController().navigate(R.id.detailFragment)的时候,大致发生了四件事:

  1. NavController从当前NavGraph里根据资源ID查到对应的NavDestination;
  2. 把Destination的ID和arguments包装成一个NavBackStackEntry,算出它在栈中的位置(要不要覆盖上一页等由NavOptions决定);
  3. 找到绑定在这个Destination上的Navigator(fragment destinations对应FragmentNavigator),调用它的navigate方法;
  4. Navigator实际执行页面切换。FragmentNavigator内部也就是拿FragmentManager做一个replace/add事务。

这里最关键的一点:NavController根本不在乎你跳到的是一个Fragment、一个Activity,还是一块自定义View。它只认Navigator的名字和Destination的ID/route。所以只要你能让NavController“认识”某个Navigator,再往NavGraph里挂上自定义Destination,整条链路就是通的。

1.2 原生按ID跳转,为什么一到大型项目就变别扭

原生写法最舒服的场景是页面数量少、跳转层级固定。一旦页面到了二十个以上,至少有三个问题会让你想动手改造:

  • 跳转目标写死在代码里,R.id.detailFragment这种资源ID散布在十几个页面,模块拆分后一个页面可能在不同的feature模块里,你没法跨模块直接引用;
  • 导航前逻辑(登录检查、埋点、渠道参数)散落各处,每新增一个页面就要记着在哪调一次;
  • XML NavGraph是全局静态的,页面一多,inflate时全部destination都要实例化,启动耗时会感知到。

这三个痛点,正好对应路由表、拦截器、动态目的地三个改造方向。下面的实现也围绕这三件事展开。

2. 路由表与注册中心:自定义路由的第一根支柱

2.1 RouteItem里该放什么信息,不该放什么

一开始容易把路由表设计得很重,恨不得把页面标题、图标、权限都塞进去。我的建议是最小可用信息:

data class RouteItem( val route: String, // 路由名,全局唯一,例如 "profile/detail" val fragmentClass: KClass<out Fragment>, // 对应的Fragment类 val needLogin: Boolean = false, // 是否要求登录 val argsClass: KClass<*>? = null // 参数数据类,用于校验 )

逐个解释一下:

  • route:不是URI,就是简单字符串,用/只是习惯,方便阅读;
  • fragmentClass:真正要创建的目标类;
  • needLogin:仅供拦截器使用,不参与导航本身;
  • argsClass:目标页面的参数签名类,后面专门讲。

“不该放什么”这条我要多说一句。有人会把跳转动画、是否singleTop也都写进RouteItem,结果全局想统一调整动画风格时改得头皮发麻。这些属于导航策略,交给调用方通过NavOptions传入更合适。路由表描述的是“这个目的地是什么”,不描述“这一次怎么过去”。

2.2 RouterRegistry的注册流程与启动期收集

注册中心用单例就够了:

object RouterRegistry { private val routeMap = LinkedHashMap<String, RouteItem>() fun register(item: RouteItem) { check(routeMap.put(item.route, item) == null) { "路由重复注册:${item.route}" } } fun get(route: String): RouteItem? = routeMap[route] }

注册时机我建议放在Application的onCreate里,用一个聚合类统一注册:

object AppRouteGroup { fun registerAll(registry: RouterRegistry) { registry.register(RouteItem("home/index", HomeFragment::class)) registry.register(RouteItem("article/detail", ArticleDetailFragment::class)) registry.register(RouteItem("profile/edit", ProfileEditFragment::class, needLogin = true)) } }

如果模块多,就按模块划分出HomeRouteTable、ArticleRouteTable、ProfileRouteTable,再在AppRouteGroup里依次调用,模块之间不互相依赖。有人会用注解+KSP自动收集注册表,这确实能省事,但初次上手不建议加这一层复杂度,先把手动注册跑通,自动化以后再说。

启动期注册的另一个好处是能尽早发现重复路由。check直接崩在启动时,比运行到某个跳转才崩要好处理得多。我在项目里甚至加过一层debug日志,把当前已注册的路由全部打出来,方便测试同学点外链时定位目标页面。

2.3 参数的固定Schema:让Bundle不再裸奔

自定义路由的难点往往不在跳转,而在“参数传递”这一层。原生Fragment跳转习惯是bundle.putString("id", ...),到目标Fragment再arguments?.getString("id")。问题在于:key拼错不报错,类型拿错经常拿不到,有些参数被谁传过根本说不清。

我们可以给每个路由定义一个参数签名。简单一点,就是为每个页面提供自己的Args数据类,并挂到RouteItem上:

data class ArticleDetailArgs( val articleId: String, val from: String = "unknown" ) { fun toBundle(): Bundle = Bundle().apply { putString("article_id", articleId) putString("from", from) } companion object { fun from(bundle: Bundle?): ArticleDetailArgs? { if (bundle == null) return null return ArticleDetailArgs( articleId = bundle.getString("article_id").orEmpty(), from = bundle.getString("from").orEmpty() ) } } }

RouteItem里加一个参数类型标记之后,跳转时用argsClass做类型校验,能提前暴露不少低级错误。这一步能做到六十分,剩下四十分靠路由API层的统一收口,下一节会讲到。

3. 最省事的做法:直接用NavController封装路由API

坦白说,很多人自定义Navigation其实不需要动Navigator层。只要把“目标页面注册进NavGraph”和“发起跳转”这两件事封装统一,就已经解决了大部分工程问题。下面这套我称之为“轻量方案”,对存量项目改动最小。

3.1 动态目的地:不写死NavGraph也能跳

Navigation原生要求目标页面先在NavGraph里声明,XML写死之后,动态模块就很难玩了。我们可以在每次跳转前检查当前NavGraph是否包含目标route,没有就临时把Destination挂上去。

fun ensureDestinationAdded(navController: NavController, item: RouteItem) { val graph = navController.graph if (graph.findNode(item.route) != null) return val navigator = navController.navigatorProvider.getNavigator("fragment") val destination = navigator.createDestination().apply { // 演示用;生产环境建议用 NavDestination.createRoute(route) 生成稳定id id = item.route.hashCode() setClassName(item.fragmentClass.java.name) } graph.addDestination(destination) }

然后封装统一的跳转入口:

class AppRouter(private val navController: NavController) { fun navigate(route: String, args: Bundle? = null, options: NavOptions? = null) { val item = RouterRegistry.get(route) ?: throw IllegalArgumentException("未注册路由: $route") // 这里预留参数校验 // 这里预留拦截器 ensureDestinationAdded(navController, item) navController.navigate(item.route.hashCode(), args, options) } }

这个方案改动小,不动Navigator实现,只做“目的地动态补录+统一入口”。实际项目里我推荐先做这一版,因为它风险最低,出了问题回滚也快。关于id,Navigation 2.4之后提供了NavDestination.createRoute(route),能生成稳定的route id,并且支持graph.findNode(route)直接查找,建议尽早切到新API,别用hashCode撑太久。

3.2 拦截器与登录态:把导航前逻辑收口

在navigate统一入口里加一道拦截器,是最容易出成果的一步。我的做法是定义两个接口:

interface RouteInterceptor { fun intercept(routeItem: RouteItem, args: Bundle?, next: () -> Boolean): Boolean }

调用链里先跑所有拦截器,任何一个返回false就中止跳转。登录检查写在最前面:

class LoginInterceptor : RouteInterceptor { override fun intercept(routeItem: RouteItem, args: Bundle?, next: () -> Boolean): Boolean { if (routeItem.needLogin && !SessionHolder.isLoggedIn()) { LoginActivity.launch() return false } return next() } }

注意一点:拦截器里不要去做“跳转到另一个路由再回来”这种动作。因为拦截器只是延迟了当前跳转,并没有把当前跳转标记为“已完成”;如果你在里面直接启动登录页,登录页回来后可能会出现双重跳转或栈混乱。正确做法是返回false,由外层统一跳登录页。这个我后面踩坑里会详细说。

我习惯把拦截器按职责拆成多个小类,而不是写一个几百行的RouteInterceptorManager。比如埋点拦截器只负责往args里塞渠道来源;权限拦截器只负责判断权限;登录器只负责登录。串起来执行时像中间件一样,顺序可配置。

3.3 回退栈控制:singleTop和singleTask的取舍

Navigation的NavOptions提供了setLaunchSingleTop和setLaunchSingleTask(2.4以后)。实际使用中有一个容易混淆的地方:

  • singleTop:当栈顶已经是目标路由时,不会重复入栈,但Fragment没有类似Activity的onNewIntent回调,实际是复用现有的BackStackEntry,不会重建Fragment;如果你需要“刷新数据”,得自己在onResume里判断。
  • singleTask:会把当前路由之上所有页面出栈,再跳过去。这个常用于Tab页、首页这类“只保留一个”的页面。

自定义路由系统里的singleTask实现其实很简单,我用一个RouteOptions数据类来承载业务语义:

data class RouteOptions( val singleTop: Boolean = false, val popUpToRoute: String? = null, val popUpToInclusive: Boolean = false, val enterAnim: Int = 0, val exitAnim: Int = 0, val popEnterAnim: Int = 0, val popExitAnim: Int = 0 )

然后在AppRouter.navigate里把RouteOptions转成NavOptions。业务调用方不用知道NavOptionsBuilder的细节,只传“我要把这个页面设为栈顶,并且清掉上面的页面”这种业务语义就够了。

4. 升级版:自定义Navigator接管真实导航事务

如果你觉得上面的轻量方案还不够,或者想彻底摆脱XML NavGraph,那就要走到真正意义上的“自定义Navigation”这一步了。这一步的难点不在代码量,而在你要理解Navigator生命周期。

4.1 继承FragmentNavigator,还是从Navigator硬写?

我直接给结论:没有特殊原因,请继承FragmentNavigator,不要从Navigator裸写。因为FragmentNavigator已经处理好了FragmentManager的事务、BackStack状态的保存与恢复,这些逻辑重写一遍非常容易出bug。

自定义Navigator的常见模板:

class AppFragmentNavigator( context: Context, manager: FragmentManager, containerId: Int ) : FragmentNavigator(context, manager, containerId) { override fun navigate( entries: List<NavBackStackEntry>, args: Bundle, navOptions: NavOptions?, navigatorExtras: Extras? ): NavBackStackEntry? { // 在真正跳转之前做统一路由埋点 val route = args.getString(KEY_ROUTE) if (route != null) { RouteTracker.report(route) } return super.navigate(entries, args, navOptions, navigatorExtras) } }

从这个例子可以看出,重写navigate方法不是必须的,除非你想在事务提交前做点什么。更常用的重写点是createDestination(),因为在inflate XML或者代码构建NavGraph时,系统会用Navigator创建Destination。如果你想让自定义Destination带一些额外属性,比如绑定业务标签,可以在createDestination里返回一个特殊对象。

4.2 用代码构建NavGraph,彻底摆脱XML

如果您决定完全不用XML,可以直接new NavGraph:

val graphNavigator = navController.navigatorProvider .getNavigator(NavGraphNavigator::class.java) val navGraph = NavGraph(graphNavigator) // 起始页 val startDest = navController.navigatorProvider .getNavigator("fragment") .createDestination() .apply { id = "home/index".hashCode() setClassName(HomeFragment::class.java.name) } navGraph.addDestination(startDest) navGraph.startDestination = startDest.id navController.graph = navGraph

这段代码的技术细节:NavGraph需要一个Navigator<NavDestination>,名字叫NavGraphNavigator。NavController在用XML inflate时也是先创建这个NavGraph,再往里填destination,所以代码构建和XML是等价的。

代码构建的好处是:不需要在R.navigation里维护一堆XML,路由可以来自模块自己的注册表,甚至来自远程配置。配合动态补录,就实现了“跳A页时A才被注册”的懒加载效果。对超大App来说,启动时不用inflate所有页面,这个收益是很实在的。

4.3 注册自定义Navigator的三种方式和优先级

要让自定义Navigator生效,有几种注册方式:

第一种:在拿到NavController后直接注册。

navController.navigatorProvider.addNavigator( AppFragmentNavigator(requireContext(), childFragmentManager, containerViewId) ) navController.setGraph(R.navigation.nav_graph)

注意:先注册,再setGraph;否则inflate时找不到你的Navigator。这里的containerViewId是你布局里NavHostFragment容器的ID,不同Navigation版本获取方式有差异,最稳的是用下面的第二种方式。

第二种:自定义NavHostFragment,重写系统的创建方法:

class CustomNavHostFragment : NavHostFragment() { override fun createFragmentNavigator(): FragmentNavigator { return AppFragmentNavigator( requireContext(), childFragmentManager, containerViewId // 由系统在内部创建时传入,不会拿错 ) } }

第二种是我最推荐的方式。这样XML里不需要做任何特殊修改,系统在创建NavController时就会用自定义Navigator替换默认的fragment导航器,容器ID也由系统内部管理,不会出现页面Fragment被add到错误ViewGroup的情况。

第三种:用setNavigatorProvider整体替换,除非你是要从零搭建一套完全不同的导航栈,否则我不建议。它会把默认的NavGraphNavigator也一并覆盖,很容易把系统内部的东西搞丢。

注册自定义Navigator后,XML中所有的fragment destination会使用自定义Navigator的createDestination创建。所以如果你只是想在destination创建阶段加一点逻辑,这种注册就够了。要接管真正的切换事务,再重写navigate方法。

5. 工程化细节:状态、动画和进程恢复里的暗坑

5.1 NavBackStackEntry的状态保存在谁手里

自定义路由最容易翻车的是进程重建后的恢复。

系统Activity进程被回收后,Navigation会通过onSaveInstanceState保存整个BackStack的entry列表以及每个Navigator的状态。恢复时,NavController会重建NavBackStackEntry,再通知Navigator执行恢复。

自定义Navigator如果重写了navigate和popBackStack,但没有重写saveState/restoreState,可能出现:Fragment被重建了,但参数丢了或重复提交事务。如果你用的是继承FragmentNavigator且只重写了navigate,它内部的状态保存逻辑还在,通常问题不大。最忌讳的是在自定义Navigator里自己改了args的引用,比如把args中某个大对象直接设为null,这在状态恢复时会炸。

5.2 转场动画与transitionName为什么容易丢

在自定义路由里设置转场动画,有两种方式:

  • 通过NavOptions里的enterAnim/exitAnim/popEnterAnim/popExitAnim;
  • 通过FragmentNavigatorExtras传递共享元素。

我遇到过的现象:用代码动态补录destination后再跳转,有时候动画不生效,原因是NavOptions里的动画资源ID没有正确传进FragmentNavigator,或者destination不是XML inflate的,FragmentNavigator在popBackStack时找不到对应动画资源。

解决办法很土:如果动态路由是重头戏,先别依赖Navigation自带的XML动画,改用FragmentTransaction.setCustomAnimations,把我们自己的动画信息放在Navigator内部维护的Map里,在跳转时手动apply。但这样会和Navigation自身的动画机制重复,维护成本高。

更推荐的路径是:动画这类视觉策略尽量少做在自定义路由的第一步。先把路由跑通,稳定两个版本,再慢慢把动画接回去,这样排查面会小很多。

5.3 用单元测试锁定路由解析逻辑

路由系统一旦上马,最怕改坏。

我的建议是把RouterRegistry和RouteItem的参数转换逻辑抽成纯Kotlin类,不要依赖Android Context。这样在本地JVM测试里就能断言:

  • 注册表里没有空route;
  • 没有重复route;
  • 每个route的fragmentClass确实存在;
  • 已知的ArticleDetailArgs.from(bundle)在缺少必填字段时能返回空而不是崩溃;
  • 拦截器执行顺序对需要登录的路由能正确短路。

Navigation本身不太好单测,但路由解析层是纯逻辑,可以测得很干净。等到所有路由都注册完成后,再写一个UI测试遍历所有路由,逐个跳转一次再返回,通常能提前发现八成的低级问题。

6. 我在模拟项目X上踩过的坑(附补救方案)

6.1 动态addDestination重复导致目的地被顶替

我最开始在ensureDestinationAdded里没判断findNode,每次跳同一页面都new一个destination往graph里加。跳第一次正常,第二次开始闪退,日志指向FragmentNavigator找不到FragmentManager事务的containerId。查半天发现graph里有多个同样route/id的destination,navigate(id)时系统取了其中一个已失效的节点。

补救方案就是前文写的:

if (graph.findNode(route) != null) return

并且注册表要做好check防重复。这两个配合,问题就消失了。

6.2 Fragment重建后路由参数丢失

有一次在详情页切到后台,进程被杀后恢复,发现arguments里的articleId变成了null。原因是参数Bundle里塞的是一个实现了Parcelable但不是稳定反序列化的自定义对象,系统保存时把它序列化到磁盘,恢复时反序列化失败却不会抛异常,只返回null。

后面统一要求:跨页面路由参数只允许放基本类型、String、以及经过稳定Parcelable实现的对象。对复杂对象统一先存本地仓库,路由里只传id。这个约定贯穿所有页面的Args类,到了后来基本没有再遇到恢复丢参数的情况。

6.3 跳转前拦截里千万不要直接开新Activity

这条是我在日志里看到的现象:点击一个需要登录的页面,因为网络慢,用户连点三次,结果登录页被launch了三次,栈乱了。根因是拦截器里自己startActivity(LoginActivity),还返回false,入口函数不再处理,登录回来也没地方接续原跳转。

规范做法是:拦截器只做判断,返回false后由AppRouter.navigate统一路由到登录页,并在登录成功回调后再重新执行原跳转。最好配合一个“pendingRoute”的队列,避免连点。这样登录页只会有一个实例,登录成功后的落点也完全可控。

如果你准备在自己的项目里动手,我给的建议是:先不要急着写Navigator,第一版就把RouterRegistry + AppRouter.navigate + 登录拦截做好,跑上两个版本观察;等确实需要动态页面懒加载时,再引入自定义Navigator。我最开始一次性把整套框架全上,结果连动画失效和状态恢复一起排查,很痛苦。反过来说,只要路由表和参数协议设计得稳,后面加Navigator只是换一颗引擎,业务代码几乎不用动。这套思路我自己在模拟项目X上已经跑了一年多,稳定性和开发效率都让我满意。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询