页面一多,跳转就散落在各个控制器里:这里 push,那里 present,返回数据和深层链接各写一套。我现在的习惯是把目的地收成枚举,跳转只经过一个 Router。UIKit 和 SwiftUI 的栈模型不一样,所以分成两套记。
环境按 Xcode 16、iOS 17 来写。SwiftUI 这段用 NavigationStack 和 @Observable,更早的系统对不上。
路由里只放能重建页面的轻量值,一般是 ID、枚举、短字符串。列表、图片、网络响应留在页面自己的数据层里取。
两边各管什么
| UIKit | SwiftUI | |
|---|---|---|
| 栈 | UINavigationController.viewControllers | NavigationStack 的 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 对外收口。
目的地
根页面是首页,不放进枚举。枚举只描述「压到首页上面」或「弹出来」的页面。
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 持有,导航控制器由窗口持有。
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 }
}
页面侧只留入口:
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 拿自己的导航控制器。
挂到窗口上
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 各自一条导航栈,返回和深层链接才不会串。
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。
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
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 的根内容上,一种路由类型注册一次。
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:
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。切走再切回来,各自的栈还在。
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且尽量小。大对象放不进导航状态,进程被系统杀掉再恢复时也带不走。
对照着记
同一条产品流程,两边的落点是对齐的。
| 操作 | UIKit | SwiftUI |
|---|---|---|
| 打开个人主页 | 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 推动时,返回顺序会对不上。