ArturoYi
iOS
UIKit 用导航控制器上的页面栈管跳转,SwiftUI 用 NavigationStack 的 path 管跳转。两边都收成一个 Router,目的地用枚举。

页面一多,跳转就散落在各个控制器里:这里 push,那里 present,返回数据和深层链接各写一套。我现在的习惯是把目的地收成枚举,跳转只经过一个 Router。UIKit 和 SwiftUI 的栈模型不一样,所以分成两套记。

环境按 Xcode 16、iOS 17 来写。SwiftUI 这段用 NavigationStack 和 @Observable,更早的系统对不上。

路由里只放能重建页面的轻量值,一般是 ID、枚举、短字符串。列表、图片、网络响应留在页面自己的数据层里取。

两边各管什么

UIKitSwiftUI
栈UINavigationController.viewControllersNavigationStack 的 path
目的地控制器实例Hashable 值
谁改栈Router 拿着导航控制器Router 改 path,栈手势也会写回 path
弹层present,单独一条栈sheet / fullScreenCover,单独一个状态
Tab每个 Tab 一个导航控制器每个 Tab 一个 NavigationStack
深层链接入口SceneDelegate根视图上的 onOpenURL

根页面留在栈底。path 和 push 上去的都是根页面之上的那些页。

UIKit

纯代码工程里,窗口和根控制器挂在 SceneDelegate。起步步骤在 创建基于 UIKit 的原生 iOS 项目。这里把根控制器换成导航控制器,再把导航控制器交给 Router。

UINavigationController 负责压栈返回,present 负责弹层,UITabBarController 负责 Tab。三件事分开,一个 Router 对外收口。

目的地

根页面是首页,不放进枚举。枚举只描述「压到首页上面」或「弹出来」的页面。

AppRoute.swift
import Foundation

enum AppRoute {
    case profile(userId: String)
    case settings
}

extension AppRoute {
    /// myapp://profile/42 、myapp://settings
    init?(url: URL) {
        guard url.scheme == "myapp" else { return nil }
        switch url.host {
        case "profile":
            guard let userId = url.pathComponents.dropFirst().first, !userId.isEmpty else {
                return nil
            }
            self = .profile(userId: userId)
        case "settings":
            self = .settings
        default:
            return nil
        }
    }
}

自定义 scheme 要在 target 的 Info → URL Types 里登记,对应 CFBundleURLTypes。通用链接走的是 NSUserActivity,解析放在 scene(_:continue:),解析完同样得到一个 AppRoute。

Router

控制器只通过弱引用调用 Router,自己不 push 别的控制器。Router 由 SceneDelegate 持有,导航控制器由窗口持有。

AppRouter.swift
import UIKit

final class AppRouter {
    private let navigationController: UINavigationController

    init(navigationController: UINavigationController) {
        self.navigationController = navigationController
    }

    func start() {
        navigationController.setViewControllers([makeHome()], animated: false)
    }

    func push(_ route: AppRoute) {
        navigationController.pushViewController(makeViewController(for: route), animated: true)
    }

    func pop() {
        navigationController.popViewController(animated: true)
    }

    func popToRoot() {
        navigationController.popToRootViewController(animated: true)
    }

    func present(_ route: AppRoute) {
        let sheet = UINavigationController(rootViewController: makeViewController(for: route))
        sheet.modalPresentationStyle = .pageSheet
        navigationController.present(sheet, animated: true)
    }

    func dismiss() {
        navigationController.dismiss(animated: true)
    }

    /// 冷启动用 resetStack,保证栈是 [首页, 目标页],返回能回到首页。
    func open(url: URL, resetStack: Bool) {
        guard let route = AppRoute(url: url) else { return }
        let target = makeViewController(for: route)
        if resetStack {
            navigationController.setViewControllers([makeHome(), target], animated: false)
        } else {
            navigationController.pushViewController(target, animated: true)
        }
    }

    private func makeHome() -> UIViewController {
        let viewController = HomeViewController()
        viewController.router = self
        return viewController
    }

    private func makeViewController(for route: AppRoute) -> UIViewController {
        let viewController: UIViewController
        switch route {
        case .profile(let userId):
            viewController = ProfileViewController(userId: userId)
        case .settings:
            viewController = SettingsViewController()
        }
        (viewController as? Routable)?.router = self
        return viewController
    }
}

protocol Routable: AnyObject {
    var router: AppRouter? { get set }
}

页面侧只留入口:

HomeViewController.swift
import UIKit

final class HomeViewController: UIViewController, Routable {
    weak var router: AppRouter?

    private func openProfile(userId: String) {
        router?.push(.profile(userId: userId))
    }
}

这套写法常见的名字是 Coordinator。这里收成一个 Router,是为了和后面的 SwiftUI 对照。模块变多时,可以按 Tab 或业务流程再拆几个子 Router,每个子 Router 拿自己的导航控制器。

挂到窗口上

SceneDelegate.swift
import UIKit

class SceneDelegate: UIResponder, UIWindowSceneDelegate {
    var window: UIWindow?
    private var router: AppRouter?

    func scene(
        _ scene: UIScene,
        willConnectTo session: UISceneSession,
        options connectionOptions: UIScene.ConnectionOptions
    ) {
        guard let windowScene = scene as? UIWindowScene else { return }
        let navigationController = UINavigationController()
        let router = AppRouter(navigationController: navigationController)
        router.start()
        if let url = connectionOptions.urlContexts.first?.url {
            router.open(url: url, resetStack: true)
        }
        window = UIWindow(windowScene: windowScene)
        window?.rootViewController = navigationController
        window?.makeKeyAndVisible()
        self.router = router
    }

    func scene(_ scene: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) {
        guard let url = URLContexts.first?.url else { return }
        router?.open(url: url, resetStack: false)
    }
}

willConnectTo 只在场景建立时进来,App 已经在前台时再点链接,走 openURLContexts。

Tab

每个 Tab 各自一条导航栈,返回和深层链接才不会串。

SceneDelegate.swift
let homeNav = UINavigationController()
let profileNav = UINavigationController()
let homeRouter = AppRouter(navigationController: homeNav)
let profileRouter = AppRouter(navigationController: profileNav)
homeRouter.start()
profileRouter.start()

let tabs = UITabBarController()
homeNav.tabBarItem = UITabBarItem(title: "首页", image: UIImage(systemName: "house"), tag: 0)
profileNav.tabBarItem = UITabBarItem(title: "我的", image: UIImage(systemName: "person"), tag: 1)
tabs.viewControllers = [homeNav, profileNav]
window?.rootViewController = tabs

深层链接先选中对应 Tab,再调用那个 Tab 的 Router。

返回时带数据

pop 把页面卸掉,参数留在上一个页面。需要回传时,给上一个页面一个闭包或委托,在 Router.push 创建下一页时接上。路由枚举里放不了闭包。

使用时注意

  • 跳转放在按钮和 Router 里。viewDidLoad 里 push 时,转场经常还没准备好。
  • 弹层自己要再压栈时,present 的是一个新的 UINavigationController,它和底下那条栈互不影响。
  • router 用 weak。控制器被导航栈持有,再强引用 Router,而 Router 又持有导航控制器,会成环。

SwiftUI

SwiftUI 的栈是一份数据。path 里有什么,导航栈上就有什么;用户点返回或侧滑,系统会把 path 改回去。

根页面直接写在 NavigationStack 里。path 从空数组开始,只追加根页面之上的路由。

目的地

AppRoute 负责压栈,SheetRoute 负责弹层。两条通道各有各的状态,避免把弹层也追加进 path。

AppRoute.swift
import Foundation

enum AppRoute: Hashable {
    case profile(userId: String)
    case settings
}

enum SheetRoute: Identifiable {
    case login
    case composer

    var id: Self { self }
}

extension AppRoute {
    /// myapp://profile/42 、myapp://settings
    init?(url: URL) {
        guard url.scheme == "myapp" else { return nil }
        switch url.host {
        case "profile":
            guard let userId = url.pathComponents.dropFirst().first, !userId.isEmpty else {
                return nil
            }
            self = .profile(userId: userId)
        case "settings":
            self = .settings
        default:
            return nil
        }
    }
}

Router

AppRouter.swift
import Observation

@Observable
final class AppRouter {
    var path: [AppRoute] = []
    var sheet: SheetRoute?

    func push(_ route: AppRoute) {
        path.append(route)
    }

    func pop() {
        guard !path.isEmpty else { return }
        path.removeLast()
    }

    func popToRoot() {
        path.removeAll()
    }

    func present(_ route: SheetRoute) {
        sheet = route
    }

    func open(url: URL) {
        guard let route = AppRoute(url: url) else { return }
        path.append(route)
    }
}

冷启动时 path 还是空的,append 之后栈就是「首页 + 目标页」,返回回到首页。App 已经打开时再进链接,是在当前 path 后面追加。

接到界面上

navigationDestination(for:) 注册在这个 NavigationStack 的根内容上,一种路由类型注册一次。

AppRoot.swift
import SwiftUI

struct AppRoot: View {
    @State private var router = AppRouter()

    var body: some View {
        NavigationStack(path: $router.path) {
            HomeView()
                .navigationDestination(for: AppRoute.self) { route in
                    switch route {
                    case .profile(let userId):
                        ProfileView(userId: userId)
                    case .settings:
                        SettingsView()
                    }
                }
        }
        .environment(router)
        .sheet(item: $router.sheet) { route in
            NavigationStack {
                switch route {
                case .login:
                    LoginView()
                case .composer:
                    ComposerView()
                }
            }
        }
        .onOpenURL { url in
            router.open(url: url)
        }
    }
}

@State 配 @Observable 之后,$router.path 能交给 NavigationStack。侧滑返回时,系统改的是这份 path。

页面里用环境拿到同一个 Router:

HomeView.swift
import SwiftUI

struct HomeView: View {
    @Environment(AppRouter.self) private var router

    var body: some View {
        List {
            NavigationLink(value: AppRoute.profile(userId: "42")) {
                Text("个人主页")
            }
            Button("登录") {
                router.present(.login)
            }
        }
        .navigationTitle("首页")
    }
}

NavigationLink(value:) 和 router.push 写入同一条 path。声明式链接和命令式跳转可以并存。

弹层里的 NavigationStack 是新的一条栈。要在弹层里继续压页面,就给这层单独一个 path,别写进底下那条。

Tab

每个 Tab 一个 NavigationStack、一个 AppRouter。切走再切回来,各自的栈还在。

AppTabs.swift
import SwiftUI

struct AppTabs: View {
    @State private var homeRouter = AppRouter()
    @State private var profileRouter = AppRouter()

    var body: some View {
        TabView {
            NavigationStack(path: $homeRouter.path) {
                HomeView()
                    .navigationDestination(for: AppRoute.self) { destination($0) }
            }
            .environment(homeRouter)
            .tabItem { Label("首页", systemImage: "house") }

            NavigationStack(path: $profileRouter.path) {
                ProfileView(userId: "me")
                    .navigationDestination(for: AppRoute.self) { destination($0) }
            }
            .environment(profileRouter)
            .tabItem { Label("我的", systemImage: "person") }
        }
        .onOpenURL { url in
            homeRouter.open(url: url)
        }
    }

    @ViewBuilder
    private func destination(_ route: AppRoute) -> some View {
        switch route {
        case .profile(let userId):
            ProfileView(userId: userId)
        case .settings:
            SettingsView()
        }
    }
}

链接落在哪个 Tab,就调用哪个 Router。上面的例子把链接都送进首页这条栈。

返回时带数据

AppRoute 要 Hashable,闭包放不进去。下一页改完数据,写入两边都能看到的模型,再 pop。上一页从那个模型读结果。

使用时注意

  • 目的地页面自己再包一层 NavigationStack,会变成套娃栈,返回行为会乱。栈只包在根上;弹层要导航时,包在弹层内容外面。
  • navigationDestination(for:) 按类型注册。同一种 AppRoute 在同一棵栈上注册一次。
  • 路由值保持 Hashable 且尽量小。大对象放不进导航状态,进程被系统杀掉再恢复时也带不走。

对照着记

同一条产品流程,两边的落点是对齐的。

操作UIKitSwiftUI
打开个人主页router.push(.profile(userId: id))router.push(.profile(userId: id)) 或 NavigationLink(value:)
回到上一页router.pop()router.pop(),或用户侧滑
回到根router.popToRoot()path.removeAll()
弹层router.present(.settings),外面再套导航控制器router.present(.login),走 sheet
冷启动进指定页setViewControllers([首页, 目标])根是首页,path = [目标]
回传结果闭包或委托共享的模型,再 pop

UIKit 的栈里放的是控制器对象,所以冷启动要把首页放进数组第 0 位。SwiftUI 的首页写在 NavigationStack 的内容位置,path 里只有它上面的页。

混在一个工程里

老页面是 UIKit、新页面是 SwiftUI 时,路由的边界划在容器上。

  • SwiftUI 页面嵌进 UIKit:用 UIHostingController 包一层,仍由 UIKit 的 Router 负责 push 和 present。
  • UIKit 页面嵌进 SwiftUI:用 UIViewControllerRepresentable。这一段内部的压栈留在那个控制器自己的导航控制器里,SwiftUI 的 path 只记录「进入了这块 UIKit」。

两条栈各改各的。一块内容同时被 path 和 viewControllers 推动时,返回顺序会对不上。

Copyright © 2026