Flow Coordinators(流程协调器)是我在重构一个中型 iOS 项目时最先引入的架构调整。当时项目里的页面跳转已经乱到没法看:一个视图控制器能直接 push 三四个后续页面,业务模块之间互相 import,每次需求改动都要顺着导航链找半天。换用 Coordinator 之后,导航逻辑从控制器里被整个抽出来,视图控制器只负责展示和回传事件,页面流转的“地图”由协调器统一维护。如果你也被 Storyboard 连线、固定导航栈和控制器之间互相持有搞得头疼,这篇文章值得看完。
Flow Coordinators 适合谁?简单说,对纯代码布局、SwiftUI 与 UIKit 混编,或者打算把导航逻辑做成可复用模块的团队,这套思路能直接落地。哪怕你只是个人开发者,学会 Coordinator 之后,新功能加页面时的改动量会明显变小。接下来我不打算按教科书方式讲模式定义,会从“为什么导航会腐败”开始,讲到协议怎么定、子协调器怎么管理、内存泄漏怎么避免,最后把我在真实项目中踩过的问题整理成一份排查速查表。
1. 为什么需要 Flow Coordinator,而不只是堆页面
1.1 传统导航模式的痛点:ViewController 干了太多脏活
传统 MVC 下,ViewController 除了管理视图和响应事件,还兼职了“导航员”。一个列表页收到 cell 点击后,要先构造详情页,配置它的属性,再通过navigationController推入新的页。这听起来合理,但真正跑起来后问题会慢慢发酵。
首先是编译耦合。首页为了跳转,必须 import 详情页、设置页、登录页、个人页的头文件。一旦某个详情页的初始化方法变了,所有跳转到它的页面都会编译报错。其次是逻辑分散。同样的登录跳转可能出现在五个页面里,需求改动时你甚至要全局搜索,逐一确认这五个地方是否都要改。再往下走,很多开发者会在控制器里写一个“统一跳转方法”,比如openDetail(model:),看起来集中了,但本质上还是把导航能力堆在单薄的外层,缺少流程级的状态管理。
还有更麻烦的:页面 A 推入页面 B,B 又通过闭包把结果回调给 A。闭包会捕获外层变量,稍不注意就循环引用。我曾经在某个老模块里见过,弹窗控制器和主控制器互相持有,最后连代理都没及时释放。这种代码不是不能工作,而是每次改完都提心吊胆。
导航本质上是一种状态流转,它和某一个具体页面的业务并不强绑定。传统方式把这个状态打散在每个控制器内部,等于让每个地标都有一张局部地图。
1.2 控制器之间的通信路径越来越复杂
页面一多,通信路径就变成网状。A 要通知 B 刷新数据,B 要等 C 的回调,C 又需要从 A 那里拿登录态。如果没有统一的消息中枢,最常见的做法是让页面之间直接持有彼此。我接手过的一个订单模块,订单列表页面把自己的实例用单例缓存起来,就是为了让支付结果页面回来之后能调用刷新方法。单例一多,应用启动流程完全不可预测,排查问题只能靠断点慢慢走。
更隐蔽的是页面生命周期回调里的逻辑。很多团队用viewWillAppear或者viewDidAppear去刷新数据,因为“从别的页面返回时需要刷新”。但到底是谁 pop 回来的、是否真的需要刷新,代码里根本说不清楚。久而久之,每个控制器里都塞满了“可能被外部调用的方法”和“可能被返回触发的事件”。
这里有一个真实例子。某团队开发一个预约系统,用户从首页进入医生列表,从医生列表进入排班页,再从排班页选择时间后要回填到首页。为了完成“回填”,排班页直接拿到了首页控制器的引用,并在点击确认时调用homeVC.updateTime()。一旦首页改版,或者首页被放到了 Tab 控制器的第二个页签里,这种调用关系就全部失效。如果这里的跳转由 Coordinator 统一接管,页面只需向协调器回传“选择完成”事件,由协调器负责更新首页状态,就不会出现跨层引用问题。
1.3 Flow Coordinator 如何把混乱收敛成流程
Flow Coordinator 的核心动作只有两个:一是创建视图控制器并在合适的时机推入,二是监听控制器发出的事件并决定下一个动作。整个应用的页面流转被画成了一张有向图,而不只是一堆push调用。
比如登录流程,可以抽象成一个 LoginCoordinator。它知道第一步展示哪种登录方式,第二步是否要先去完成手机号验证,第三步成功后要去哪个页面;首页控制器完全不关心这些,它只需要在用户点击登录时,向协调器喊一声“我要登录”。反过来,登录流程也可以被个人中心、下单流程等多个入口复用,因为跳转代码只写在一个地方。
这套模式也不是银弹。如果项目只有两三个页面,或者还在原型验证阶段,强行上 Coordinator 会显得很臃肿。我在后面 3.4 和 4.4 会具体讲什么时候该用、什么时候不该用。现在先看一下核心概念,把它的职责边界讲清楚,否则后面代码很容易写走样。
2. 核心概念与架构拆解
2.1 协调器到底管什么
一个 Flow Coordinator 的职责可以用几个动作概括:创建、持有、监听、传递。它负责创建要被展示的视图控制器,持有导航栈的引用,监听页面发来的事件,必要时调用下一个协调器,或者把自己从父协调器里移除。
协议的最初版本可以非常简单:
protocol Coordinator: AnyObject { var navigationController: UINavigationController { get } func start() }为什么要有AnyObject约束?因为后面要持有子协调器并做移除操作,类约束可以避免值类型带来的拷贝语义,也让weak var用起来更顺手。start()是一个入口,协调器内部可以决定先展示哪个页面,也可以在这里创建第一个视图控制器。
实际项目中,我还会扩展一个finish()方法,用来通知父协调器“我这个流程结束了,可以清理我”。如果只靠start(),流程结束时父协调器很难知道该什么时候移除子节点。
protocol Coordinator: AnyObject { var navigationController: UINavigationController { get } var parentCoordinator: Coordinator? { get set } func start() func finish() }不过父协调器用协议类型,后续调用子类自己的方法会有些麻烦。更常用的做法是让父协调器持有子协调器的具体类型,并给子协调器添加一个弱引用回调。后面讲实际实现时会展示。
2.2 从 AppCoordinator 挂载根控制器
不管架构怎么分,App 启动阶段总要有一个根协调器,负责把第一个UINavigationController创建出来并交给窗口。这个角色通常叫 AppCoordinator 或 RootCoordinator。
使用 SceneDelegate 时挂载方式如下:
final class AppCoordinator: Coordinator { let navigationController: UINavigationController private var window: UIWindow? private var childCoordinators: [Coordinator] = [] init(window: UIWindow?) { self.window = window self.navigationController = UINavigationController() } func start() { guard let window else { return } window.rootViewController = navigationController window.makeKeyAndVisible() let homeCoordinator = HomeCoordinator(navigationController: navigationController) addChild(homeCoordinator) homeCoordinator.start() } private func addChild(_ coordinator: Coordinator) { childCoordinators.append(coordinator) coordinator.parentCoordinator = self } private func removeChild(_ coordinator: Coordinator) { childCoordinators.removeAll { $0 === coordinator } } }注意这里 childCoordinators 使用强持有数组,目的是保证子协调器在流程结束前不被释放。这一点非常关键,很多人一开始图省事,让 AppCoordinator 把一个子协调器存在局部变量里,一跑就发现页面 push 后协调器没了,事件也接不住。正确做法是保存到数组里,流程结束后再移除。
如果遇到 Swift 编译器对Coordinator协议的引用报错,常见原因是子协调器没有正确标识为final class。在纯 Swift 工程里,建议所有协调器类都用final修饰。
2.3 子 Coordinator 与流程抽象
子协调器解决的是“一条完整流程的独立性”。登录流程、注册流程、下单流程、个人中心里的复杂设置流程,都可以各自封装。父协调器只负责在按钮事件发生时创建子协调器,子协调器负责把自己那一串页面走完,最后通过回调或代理通知父协调器结果。
打个比方:AppCoordinator 是总导演,HomeCoordinator 是负责“首页这一个场景”的现场导演。现场导演有权在场景里切换镜头,但如果要切到另一个完全不同的场地,他会告诉总导演,由总导演决定是否开一个新片场。这样每个片场的剧本不会互相污染。
在实际工程里,我习惯这样管理子协调器:
final class HomeCoordinator: Coordinator { let navigationController: UINavigationController weak var parentCoordinator: Coordinator? private var childCoordinators: [Coordinator] = [] func start() { let homeVC = HomeViewController() homeVC.onLogin = { [weak self] in self?.startLoginFlow() } navigationController.setViewControllers([homeVC], animated: false) } private func startLoginFlow() { let loginCoordinator = LoginCoordinator(navigationController: navigationController) addChild(loginCoordinator) loginCoordinator.start() } // ... }首页控制器只暴露一个onLogin闭包。将来如果用户从首页进入设置,再进入登录,也只是在设置协调器里做类似的事。页面之间“对方是谁”的问题被彻底消除了。
2.4 和 Router、MVVM 的关系对比
有几种容易混淆的说法:Router、FlowController、Presenter 等。它们有重叠,但侧重不同。
| 角色 | 主要职责 | 与 Coordinator 的区别 |
|---|---|---|
| Router | 根据抽象目标返回可显示的控制器或执行跳转 | 更偏“查找目的地”,不管理流程 |
| Presenter | 负责把页面呈现在某个容器里 | 偏展示层,缺少事件闭环 |
| Flow Coordinator | 创建页面、推入页面、监听事件、管理子流程 | 具备完整流程状态,是导航的总调度 |
和 MVVM 的关系更简单:Coordinator 只管导航,不负责创建 ViewModel,也不负责网络请求。ViewModel 通常由页面自己创建,或者由一个轻量工厂创建。这样依赖方向是:页面指向自己的 ViewModel,Coordinator 指向页面。测试时,给 Coordinator 注入一个 mock 导航容器,就能验证它到底 push 了哪种控制器。
3. 实操:从零搭建一套可复用的流程协调器体系
3.1 目录结构和启动挂载
我建议按功能模块组织,而不是按 M、V、C 分层。推荐一个能直接用的目录:
App/ Coordinator/ Coordinator.swift AppCoordinator.swift Modules/ Home/ HomeViewController.swift HomeCoordinator.swift Login/ LoginViewController.swift LoginCoordinator.swift Detail/ DetailViewController.swift DetailCoordinator.swift Core/ Router/ NavigationRouter.swift上面用到了Router目录,如果你觉得多余可以先砍掉。最简情况下,协调器直接持有UINavigationController,不需要额外的 Router 抽象。
纯代码布局下,协调器创建控制器时直接用普通初始化方法,不需要关心 storyboard id 或 segue。如果你的项目还在用 Storyboard,也可以保留页面,但优先做到“控制器不主动发起 push”。
在 SceneDelegate 里挂载根协调器:
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { guard let windowScene = scene as? UIWindowScene else { return } let window = UIWindow(windowScene: windowScene) self.window = window let appCoordinator = AppCoordinator(window: window) self.appCoordinator = appCoordinator appCoordinator.start() }有的项目把 AppCoordinator 挂在 AppDelegate 里,也可以,关键是要有一个属性强持有它,否则start()一执行完协调器就被释放了。
3.2 基础协议怎么写最稳
这里给出一个在真实项目里跑得比较稳的协议写法:
protocol Coordinator: AnyObject { var navigationController: UINavigationController { get } func start() func addChild(_ coordinator: Coordinator) func removeChild(_ coordinator: Coordinator) }然后把默认实现放进扩展:
extension Coordinator { func addChild(_ coordinator: Coordinator) {} func removeChild(_ coordinator: Coordinator) {} }这只是一个基础骨架。实际每个协调器内部不一定要有一个 children 数组。比如 HomeCoordinator 启动登录流程时需要保存 LoginCoordinator,但登录流程结束后就必须移除。我通常还会加一个结束回调:
typealias FinishHandler = () -> Void protocol Coordinator: AnyObject { var navigationController: UINavigationController { get } var onFinish: FinishHandler? { get set } func start() }这样父协调器可以把子协调器的结束回调绑定到自己身上,子流程结束时自动从数组中移除。注意闭包要避免循环引用:父协调器持有子协调器,子协调器的onFinish若强引用父协调器,就可能泄漏。所以绑定的时候用弱引用。
3.3 首页流程:从 AppCoordinator 到 HomeCoordinator
AppCoordinator 启动后创建 HomeCoordinator,然后把它导航到自己的导航控制器的第一个位置。这里有个细节:如果直接使用navigationController.setViewControllers([homeVC], animated: false),你可以完整控制栈的初始结构,不依赖默认的 rootViewController。
final class HomeCoordinator: Coordinator { let navigationController: UINavigationController private var childCoordinators: [Coordinator] = [] private let dependencies: AppDependencies init(navigationController: UINavigationController, dependencies: AppDependencies) { self.navigationController = navigationController self.dependencies = dependencies } func start() { let homeVC = HomeViewController(dependencies: dependencies) homeVC.delegate = self navigationController.setViewControllers([homeVC], animated: false) } }为了让控制器不感知协调器,我这里让它持有 delegate。协议声明可能长这样:
protocol HomeViewControllerDelegate: AnyObject { func homeViewControllerDidTapLogin(_ controller: HomeViewController) func homeViewControllerDidSelectItem(_ controller: HomeViewController, itemID: String) }协调器实现 delegate 方法,在里面决定下一步。控制器没有导入任何其他控制器,这是我最看重的一点,因为当页面之间的跳转关系改动时,控制器文件经常一个字符都不用改。
3.4 登录子流程:参数回传与移除
登录流程是典型子流程。用户点登录,HomeCoordinator 创建 LoginCoordinator,保留强引用,并绑定结束回调。
private func startLoginFlow(from homeVC: HomeViewController) { let loginCoordinator = LoginCoordinator( navigationController: navigationController, configuration: LoginConfiguration() ) addChild(loginCoordinator) loginCoordinator.onFinish = { [weak self] in self?.removeChild(loginCoordinator) } loginCoordinator.start() }有人会问:既然导航栈已经 push 了登录页,那 LoginCoordinator 本身的管理为什么还要放在 HomeCoordinator 里?原因很简单:AppCoordinator 只关心最外层的场景,如果他在 Home 的上一级就亲自处理登录流程,那么当登录流程需要从三个入口发起时,AppCoordinator 会被三种场景的回调塞爆。子协调器就是为了局部收敛而存在的。
登录页内部的逻辑:
final class LoginCoordinator: Coordinator { let navigationController: UINavigationController var onFinish: FinishHandler? private let configuration: LoginConfiguration func start() { let loginVC = LoginViewController(mode: configuration.mode) loginVC.didLoginSuccess = { [weak self] user in self?.handleLoginSuccess(user) } navigationController.pushViewController(loginVC, animated: true) } private func handleLoginSuccess(_ user: User) { UserStore.shared.currentUser = user onFinish?() } }这里didLoginSuccess闭包里用了[weak self],避免协调器被页面闭包长期持有。页面本身不持有协调器,但页面内部的闭包可能被存储,所以不能忽略 weak。
如果业务复杂,比如登录后还要判断是否提现、是否实名认证,LoginCoordinator 内部可以再开子协调器,或者把这个流程拆成 LoginStep、VerifyStep、CompleteStep。真正复杂的流程,再考虑用简单的状态枚举管理当前处于哪一步。大部分项目不需要一上来就上状态机。
3.5 依赖注入与参数传递
Coordinator 模式会集中创建页面,因此页面依赖也必须通过协调器传入。我建议做一个轻量依赖容器,一个 structAppDependencies,包含网络层、持久化层、登录态服务、访问当前用户等。Coordinator 初始化时接收 dependencies 并传给页面:
struct AppDependencies { let api: APIClient let storage: Storage let session: SessionManager }这样测试时替换 dependencies 会很方便。不要到处使用单例 ServiceLocator,不然协调器虽然梳理了导航,但全局数据访问还是乱的。当然小项目里用单例也不是不行,只是你要清楚它会让测试变难。
依赖注入需要遵循一个粒度原则:不要让 Coordinator 拿着十几个参数去构造页面。如果页面需要的参数过多,优先建议页面内部通过一个组装器把依赖拼装好,再让 Coordinator 只关心页面类型和导航动作。这样能防止 Coordinator 文件膨胀成“上帝类”。
3.6 导航栈的返回行为处理
当一个页面被 push 到导航栈,用户可以用系统返回按钮或边缘返回手势把它 pop 掉。这一步如果交给 UIKit 默认行为,本身没什么问题,但协调器需要知道“流程已经自然结束了”。最典型的场景是:登录失败页面栈顶有一个“重新登录”按钮,用户点击返回手势退回首页,此时登录协调器的状态还停留在失败分支。如果不去同步,下一次点击登录会创建新的协调器,老的协调器却残留在内存里。
处理这块时,我最初的方案是在每个需要监听返回的 VC 里加一个onPop回调,在viewWillDisappear里判断isMovingFromParent再触发。这个方案能跑,但代码量分散。后来换成用UINavigationControllerDelegate统一处理,下面 4.3 会详细给出一种判断方法。
如果只是希望某个流程禁止被返回打断,也有办法:把导航控制器的interactivePopGestureRecognizer.isEnabled = false设成关闭。但通常不推荐,因为会影响用户操作习惯。我更倾向于在协调器里同步流程状态,而不是直接禁用手势。
4. 常见问题与排查技巧实录
4.1 子协调器内存泄漏怎么定位
这是 Coordinator 模式最容易翻车的地方。典型症状是:页面 pop 之后,deinit 没有打印,协调器也不会释放,App 内存越涨越高。
我排查时一般做三件事:
- 在
onFinish或removeChild里加一行日志,确认子协调器被移除; - 在每个 Coordinator 和 ViewController 里写 deinit 打印,用不同前缀区分;
- 用调试器的 Memory Graph 或 Instruments 的 Leaks 看互相持有关系。
常见循环引用是:AppCoordinator -> childCoordinators -> HomeCoordinator -> LoginCoordinator -> onFinish -> weak self。一般weak self能断开。如果哪里用了[unowned self],或者闭包里调用了没有弱化的属性,就会泄漏。
经验法则:所有从页面传给 Coordinator 的回调统一写
[weak self],除非你非常确定页面生命周期一定短于协调器,并且协调器不会反向持有页面太久。宁可多写几个[weak self],也不要为了省一行代码埋雷。
4.2 多次点击重复跳转的防抖
在协调器里发起跳转太顺手了,反而容易忽略入口防抖。用户快速双击按钮,两个异步任务同时发起,每个任务完成后都调用startLoginFlow,就会连续 push 两个登录页。
我的做法是在协调器里加一个状态标志:
private var isPresenting = false func startLoginFlow() { guard !isPresenting else { return } isPresenting = true let loginCoordinator = LoginCoordinator(navigationController: navigationController) addChild(loginCoordinator) loginCoordinator.onFinish = { [weak self] in self?.isPresenting = false self?.removeChild(loginCoordinator) } loginCoordinator.start() }然后在子流程结束或页面成功出现时重置。注意不要只依赖按钮 disabled,更可靠的是在协调器这一层做,因为同一个流程可能从多个入口进入,按钮状态未必能覆盖所有入口。
4.3 返回手势与返回按钮导致流程状态不同步
子协调器 push 了登录页,用户却通过边缘返回手势一路退回首页。导航栈确实回到了首页,但 LoginCoordinator 还留在 childCoordinators 数组里,下一次再点登录又会创建新的子协调器,老的却移除不了,内存里就存了一批僵尸协调器。
处理方式有两类。
第一个是让协调器监听导航控制器的didShow回调。当页面被移出导航栈时,如果它是某个子流程的尾页,就自动执行onFinish。这里需要判断transitionCoordinator,确保手势取消时不会错误移除。
extension LoginCoordinator: UINavigationControllerDelegate { func navigationController( _ navigationController: UINavigationController, didShow viewController: UIViewController, animated: Bool ) { guard let fromViewController = navigationController.transitionCoordinator? .viewController(forKey: .from) else { return } if navigationController.viewControllers.contains(fromViewController) == false { if fromViewController is LoginViewController { onFinish?() } } } }第二个是更简单的方案:把导航控制器的代理交给一个全局导航协调器统一处理,每次didShow都对比当前顶层控制器,再根据映射关系通知对应子协调器。这个方案初期代码多一点,但长期维护省心,特别是流程多的时候。
4.4 过度设计:不是所有页面都需要 Coordinator
如果一个页面只负责 present 一个弹窗,或者 push 一步就能结束,那直接在控制器里做也不会有大问题。强行每个页面都套 Coordinator,会让文件数量翻倍,新人接手时反而看不懂。
我给自己定的判定标准有三条:
- 这个流程是否被多个入口复用?是,就抽;
- 流程是否有分支,比如登录成功后根据用户类型去不同页面?是,就抽;
- 项目是否已经在这个模块上频繁改动,我是否要继续往里面堆导航代码?是,就抽。
如果三条都不满足,给控制器留一个简单的 delegate 回调,或者在调用现场直接 push 一个页面就够了。过度抽象其实也是一种技术债,协调器数量应该跟业务流程数量保持一致,而不是跟页面数量一致。
4.5 测试到底测什么
Coordinator 模式最大的测试红利是“导航决策可以独立测试”。注入一个UINavigationController,然后执行 start,再触发页面回调,断言调用过 push 方法且传入的控制器类型正确。
用 XCTest 写起来很直接:
final class HomeCoordinatorTests: XCTestCase { func testStartPushesHomeViewController() { let nav = UINavigationController() let coordinator = HomeCoordinator(navigationController: nav, dependencies: mockDeps) coordinator.start() XCTAssertTrue(nav.viewControllers.first is HomeViewController) } func testLoginTapOpensLoginFlow() { let nav = UINavigationController() let coordinator = HomeCoordinator(navigationController: nav, dependencies: mockDeps) coordinator.start() let homeVC = nav.viewControllers.first as? HomeViewController homeVC?.delegate?.homeViewControllerDidTapLogin(homeVC!) XCTAssertTrue(nav.viewControllers.last is LoginViewController) } }像这种测试,几乎不依赖真实页面状态,跑得非常快。重点不是覆盖所有 UI 分支,而是把“哪个事件触发哪个页面”这层规则锁死。如果后续有人把点击登录后的目标改成了注册页,这个测试会立刻变红,比在模拟器里手点一遍靠谱得多。
5. 让团队真正接受这套模式
5.1 从哪里开始重构
不要一次性推翻全部架构。最稳妥的做法是两个阶段。第一阶段,在现有项目里增加一个“流程层”,只把新功能的跳转放进去。第二阶段,挑一个被大量引用的旧模块,比如登录和设置,把里面散落的 push 逻辑收拢到对应协调器里。每完成一个模块,跑一遍回归测试,确认没有改变用户可感知的行为。
我自己第一次实践时犯过一个错误:把首页、购物车、订单、支付全流程同时改成 Coordinator,结果一周都在修导航栈错乱问题。后来我明白,协调器是逐步引入的,不是洪水式重构。你先在一个分支流程上用起来,等团队看到改动面积变小,自然愿意跟着做。
5.2 命名与协作规范
为了让团队不看文档也懂结构,我定了几个约定:
- 协调器统一以
Coordinator结尾; - 回调协议统一以
Delegate结尾; - 页面发事件用闭包或 delegate,但不允许直接访问其他控制器类型;
- 协调器之间只通过父协调器通信,兄弟协调器不许互相持有。
代码审查时重点看这几条。另外我会要求协调器的start()方法必须放在文件顶部,并且保持短小,最好只做三件事:创建页面、设置回调、推入栈。一旦发现协调器里开始写网络请求或者做复杂数据处理,就得停下来拆代码了。
5.3 和现有路由表怎么共存
有些项目本身已经有一个中心路由表,通过字符串或 URL 分发到控制器。Router 解决的是“根据标识找到目标页”,Coordinator 解决的是“目标页之间如何流转”。两者可以并存:路由表负责把 URL 翻译成某个流程的开始参数,Coordinator 负责真正执行流程。不要让 Coordinator 自己再写一套 URL 解析,那就重复了。
这里举个例子:收到某个推送,内容是“打开订单详情/订单号 123”。路由表负责找到 OrderDetailCoordinator,并把订单号作为初始化参数传进去;OrderDetailCoordinator 拿到参数后,自己决定是先展示详情还是先检查登录状态。这样路由表不需要知道登录流程的细节。
我自己实践下来的体会是,Flow Coordinators 不是某一个库或一个固定模板,而是一种让导航状态变得更显性化的思路。最开始引入时不要追求“每个流程都完美”,先在一个高频改动模块里把它跑通,把协调器当作业务代码的一部分去维护,慢慢你就能发现哪些环节该拆、哪些环节该并。最后再分享一个小技巧:给每个 Coordinator 的 start 方法加一行打点日志,上线后能直接从日志里还原用户完整的导航路径,排查跳转异常时会感谢这个习惯。