关键词:SwiftUI、NavigationStack、NavigationPath、具名路由、GetX、async/await、CheckedContinuation
一、需求来源
「开发多 Tab 的 SwiftUI 应用时遇到一个痛点:跳转到下一页、等用户操作完、再拿回结果------这条最简单的链路,官方 API 表达不了。」
NavigationStack 的 path 是单向的,pop 之后发起方拿不到任何回传数据,只能接全局状态:
swift
struct DetailView: View {
@EnvironmentObject var appState: AppState
var body: some View {
Button("选择完成") {
appState.selectedID = currentID // 返回值塞进全局状态
appState.didFinishDetail = true // 上一页自己去观察
dismiss() // 关闭还得自己调
}
}
}
struct ListView: View {
@EnvironmentObject var appState: AppState
var body: some View {
Button("打开详情") {
showDetail = true // push 完代码就结束了
}
.onChange(of: appState.didFinishDetail) { _ in
// 业务逻辑被拆成两半,还得记得在合适的地方重置标志位
if appState.didFinishDetail, let id = appState.selectedID {
handle(id)
appState.didFinishDetail = false
}
}
}
}
- 返回值靠全局状态中转 :
selectedID/didFinishDetail这类临时字段堆在AppState里,生命周期失控。 - 调用链路断裂 :无法像 Flutter 那样
await Navigator.push(...),业务逻辑被拆成两半。 - 路由名散落各处:改一个路径要全局替换,也没有地方校验路由是否存在。
- 多 Tab 各有独立栈:系统侧滑返回时还要同步自己的快照,手写容易漏。
需求很朴素:跳转能 await,返回值原路传回,路由表可校验,多 Tab 栈各自独立。
二、使用示例
1. 启动注入路由表(推荐)
包内不含任何业务路由名,全部由业务侧注入:
swift
import Navigator
import SwiftUI
@main
struct MyApp: App {
init() {
Get.setup(
tabCount: 3,
initialTab: 0,
containsRoute: { AppRouter.contains($0) },
preventsDuplicate: { AppRouter.preventDuplicates(for: $0) },
titleProvider: { AppRouter.page(for: $0).title },
unknownRoute: "/unknown" // 必填,且须自己也注册进 containsRoute
)
}
}
2. 挂载根视图
swift
struct RootView: View {
@State private var tab = 0
var body: some View {
TabView(selection: $tab) {
ForEach(0..<AppTab.count, id: \.self) { index in
NavigationStack(path: Get.shared.pathBinding(for: index)) {
AppTab.page(at: index).build(RouteSettings(name: "/tab\(index)"))
.navigatorDestination { settings in
AppRouter.destination(settings)
}
}
.tag(index)
}
}
.environmentObject(Get.shared) // 必须注入
}
}
3. await 拿返回值(核心)
swift
// 发起方:挂起直到目标页 pop
Task {
let result = await Get.toNamed("/detail", args: ["id": 1])
if let ok = result?["ok"] as? Bool, ok {
print("用户确认了选择")
}
}
swift
// 目标页:返回时带上结果,用 @Environment(\.currentRoute) 取本页参数
struct DetailView: View {
@Environment(\.currentRoute) private var route
var body: some View {
Text("id = \(route?.args?["id"] ?? 0)")
.onTapGesture {
Get.back(result: ["ok": true])
}
}
}
返回值约定:
- 正常
pop(result:)/back(result:)→ 拿到该 result - 系统侧滑、或未带 result 返回 → 拿
nil - 防重跳过 → 直接
nil,不抛错
4. 四种跳转变体
swift
// 替换当前页;传进去的 result 交给「被替换的那一页」
_ = await Get.offNamed("/home", result: ["replaced": true])
// 清空当前 Tab 整栈再 push
_ = await Get.offAllNamed("/login", result: ["cleared": true])
// 回退到指定页(该页保留);先退掉的页面 await 得 nil
Get.until({ $0 == "/home" }, result: ["from": "settings"])
// 一次退多层,result 只给栈顶那一页
Get.back(count: 2, result: ["ok": true])
5. 直接用引擎(细粒度控制)
swift
let nav = Get.shared
_ = await nav.pushNamed("/detail", args: ["id": 1])
_ = await nav.pushReplacementNamed("/other", args: [:], result: ["bye": true])
// 先 popUntil 再 push
_ = await nav.pushNamedAndRemoveUntil(
"/checkout",
{ $0 == "/cart" },
args: ["sku": "A1"],
result: ["cleared": true]
)
nav.popUntil({ $0.hasPrefix("/tab") }, result: nil)
nav.pop(count: 1, result: ["ok": true])
6. 路由监听与 Tab 监听
切 Tab 不会触发路由监听 ,两者是分开的。这是 1.0.0 明确下来的行为,也是新增 onTabChanged 的原因:
swift
NavigationStack(path: Get.shared.pathBinding(for: tab)) {
RootView()
.navigatorDestination { AppRouter.destination($0) }
.onRouteChanged { from, to in // 只在本页路由变化时触发
print("\(from?.name ?? "root") → \(to?.name ?? "root")")
}
}
.onTabChanged { from, to in // Tab 切换独立通知
print("tab \(from) → \(to)")
}
全局监听拿 token 手动注销,一个 token 同时覆盖两类:
swift
let id = Get.addListener { from, to in /* 路由变化 */ }
let tabID = Get.addTabListener { from, to in /* Tab 切换 */ }
Get.removeListener(id) // 两类监听都能用同一个方法注销
Get.removeListener(tabID)
7. 状态只读查询
swift
Get.routeName // 当前焦点路由名
Get.routeNamePre // 上一次跳转前的路由名
Get.pageRouteNames // 当前 Tab 路由名栈(自底向顶)
Get.shared.canPop // 当前 Tab 能否 pop
Get.shared.currentArgs // 当前 Tab 栈顶参数
Get.shared.isStackEmpty(for: 1)
Get.shared.finishPendingWaits() // 结束所有未决 await(重建引擎前必调)
Get.debug = true // 打开包内路由日志
Get.reset() // 先结束未决 await,再清空引擎
8. 自定义导航栏
swift
content
.navigationBarCustom(
titleColor: .white,
backgroundColor: .black,
hideBack: false,
leading: { Button("菜单") { /* ... */ } },
trailing: { Image(systemName: "ellipsis") }
)
标题传 nil 时自动回落环境里那个 Navigator 的 titleProvider:
swift
// setup 时注入一次即可
Get.setup(/* ... */, titleProvider: { AppRouter.page(for: $0).title }, unknownRoute: "/unknown")
// 之后 navigationBarCustom 无需再传 title
content.navigationBarCustom(titleColor: .white)
三、源码讲解
1. 核心:用 Continuation 把 push 变成 await
swift
@MainActor
public final class RouteSettings: Hashable {
public let name: String
public var args: [String: Any]?
private var continuation: CheckedContinuation<RouteResultBox, Never>?
private var pendingResult: RouteResultBox? // 先 complete 后 wait 时暂存
private var hasCompleted = false
public func waitForResult() async -> [String: Any]? {
if hasCompleted { // 已结束,直接返回暂存结果
let boxed = pendingResult
pendingResult = nil
return boxed?.value
}
return await withCheckedContinuation { cont in
if hasCompleted { // 双重检查:闭包执行前可能已完成
let boxed = pendingResult
pendingResult = nil
cont.resume(returning: boxed ?? RouteResultBox(nil))
return
}
if let old = continuation { // 重复 wait,释放旧的避免挂死
continuation = nil
old.resume(returning: RouteResultBox(nil))
}
continuation = cont
}.value
}
public func complete(with result: [String: Any]? = nil) {
guard !hasCompleted else { return } // 幂等
hasCompleted = true
let boxed = RouteResultBox(result)
if let cont = continuation {
continuation = nil
cont.resume(returning: boxed)
} else {
pendingResult = boxed // continuation 还没来,先存
}
}
}
坑一:complete 可能早于 waitForResult。 页面在 onAppear 里立刻 pop(result:) 时,发起方的 await 还没挂起 ------ 不做暂存这个结果就丢了,await 永远挂死。pendingResult + hasCompleted 就是为这个时序准备的。
坑二:continuation 闭包内要再查一次 hasCompleted。 从函数入口到闭包真正执行之间有挂起点,状态可能已变。这是最容易漏的二次检查。
坑三:[String: Any] 不是 Sendable。 Swift 6 下直接塞进 continuation 会编译报错,用盒子包一层:
swift
private final class RouteResultBox: @unchecked Sendable {
let value: [String: Any]?
init(_ value: [String: Any]?) { self.value = value }
}
用 @unchecked 是合理的:构造后不可变,且所有访问都在 @MainActor 上。
2. push:先解析目标再入栈
swift
@discardableResult
public func pushNamed(_ name: String, args: [String: Any] = [:]) async -> [String: Any]? {
guard let settings = appendRoute(name, args: args) else { return nil }
return await settings.waitForResult()
}
swift
private func appendRoute(_ name: String, args: [String: Any]) -> RouteSettings? {
guard let target = resolveTarget(name: name, args: args) else { return nil }
if preventsDuplicate?(target.name) == true,
currentSettings?.name == target.name {
// unknown 回退的特殊处理:原目标不同则仍允许 push,以便刷新参数展示
let sameIntended: Bool = {
guard target.name == unknownRoute else { return true }
let prev = currentSettings?.args?[NavigatorArgKey.intendedRoute] as? String
let next = target.args[NavigatorArgKey.intendedRoute] as? String
return prev == next
}()
if sameIntended {
dlog("preventDuplicates skip: \(target.name)")
return nil
}
}
let settings = RouteSettings(name: target.name, args: target.args.isEmpty ? nil : target.args)
// ... 同步 routeTabs / pathTabs,再 notifyListeners
return settings
}
易忽略 :preventsDuplicate 在 unknownRoute 上有例外。跳到 /a 和 /b 两个不存在的路由时都会回退到 /unknown,按常规防重第二次会被跳过,用户觉得「点了没反应」。所以额外比较 intendedRoute,只有原目标也相同才真跳过。
3. 路由解析与回退
swift
private func resolveTarget(name: String, args: [String: Any]) -> (name: String, args: [String: Any])? {
if containsRoute(name) {
return (name, args)
}
let fallback = unknownRoute
guard containsRoute(fallback) else {
dlog("⚠️ Route not found (unknownRoute not registered): \(name) → \(fallback)")
return nil // 兜底路由也没注册,无法落地
}
var merged = args
merged[NavigatorArgKey.intendedRoute] = name // 原目标名写进 args
return (fallback, merged)
}
回退时把原目标写进 args["intendedRoute"],未知页就能提示「你要找的 /nope 不存在」,而不是白屏。
4. offNamed 的预检:别让非法名先毁栈
swift
@discardableResult
public func pushReplacementNamed(
_ name: String,
args: [String: Any] = [:],
result: [String: Any]? = nil
) async -> [String: Any]? {
// 先确认最终能落地(含 unknown 回退),避免非法名先毁栈
guard resolveTarget(name: name, args: args) != nil else { return nil }
if canPop { pop(result: result) }
return await pushNamed(name, args: args)
}
坑 :若按直觉写「先 pop 再 push」,当目标不存在且没有可用 unknownRoute 时,当前页已被 pop、新页又 push 不进来,用户直接掉到根页面。这个 guard 保证栈要么完整替换,要么完全不动。
5. 多 Tab:两份数据必须同步
swift
@Published public private(set) var pathTabs: [NavigationPath] // 给 SwiftUI
private var routeTabs: [[RouteSettings]] // 自己的快照
NavigationPath 是不透明类型,读不出路由名,所以必须自己再存一份 RouteSettings,两者深度始终一致。
切 Tab 时焦点路由同步为栈顶,并通知 Tab 监听(不走路由回调):
swift
@Published public var selectedTab: Int {
didSet {
guard oldValue != selectedTab else { return }
routePre = route
route = currentSettings
notifyTabListeners(from: oldValue, to: selectedTab) // 只通知 tab listener
}
}
对应地,通知路由的 notifyListeners 里没有切 Tab 的调用路径:
swift
private func notifyTabListeners(from: Int, to: Int) {
let snapshot = tabListeners // 快照:防止回调里 removeListener
for item in snapshot {
item.handler(from, to)
}
dlog("tab: \(from) → \(to), route: \(route?.name ?? "root")")
}
这个分离是刻意的 :切 Tab 并没有真的发生路由跳转(route 只是切到另一个 Tab 的栈顶),如果塞进 onRouteChanged,业务侧会收到大量「假跳转」。测试 testSelectedTabNotifiesTabListenersNotRoute 专门锁定了这条语义:切 Tab 后路由回调数组长度不变。
6. 系统侧滑:path 变了但 routeTabs 不知道
swift
public func pathBinding(for tab: Int) -> Binding<NavigationPath> {
Binding(
get: { self.pathTabs.indices.contains(tab) ? self.pathTabs[tab] : NavigationPath() },
set: { newValue in
guard self.pathTabs.indices.contains(tab) else { return }
var tabs = self.pathTabs
tabs[tab] = newValue
self.pathTabs = tabs
self.syncStacks(withPathCount: newValue.count, tab: tab) // 系统侧滑从这里进来
}
)
}
private func syncStacks(withPathCount pathCount: Int, tab: Int) {
guard routeTabs.indices.contains(tab) else { return }
let routeCount = routeTabs[tab].count
#if DEBUG
if pathCount > routeCount {
assertionFailure("NavigationPath grew without named API ...")
}
#endif
guard routeCount > pathCount else { return }
let removed = Array(routeTabs[tab].suffix(from: pathCount))
for settings in removed {
settings.complete(with: nil) // 没带 result,await 得 nil
}
routeTabs[tab] = Array(routeTabs[tab].prefix(pathCount))
notifyListeners(from: removed.last, to: routeTabs[tab].last)
}
坑 :侧滑返回时必须 complete(with: nil)。否则发起方的 await 永远等不到结果,那个 Task 一直挂着 ------ 最隐蔽的泄漏。
#if DEBUG 的断言也是防呆:path 深度超过 routeTabs 说明有人绕过具名 API 直接 append,两条记录的对应关系已不可信,开发期直接失败好过线上路由错位。
7. 页面级监听:为什么不能简单用 onDisappear
两类监听共用同一个 StackBoundListenerModifier,差异只在注册时做什么:
swift
public func onRouteChanged(_ handler: @escaping RouteChangedHandler) -> some View {
modifier(
StackBoundListenerModifier { navigator, mySettings, box in
let id = navigator.addListener { from, to in
handler(from, to)
if let mySettings, from === mySettings { // 自己出栈了,注销
navigator.removeListener(id)
box.id = nil
}
}
box.id = id
}
)
}
public func onTabChanged(_ handler: @escaping TabChangedHandler) -> some View {
modifier(
StackBoundListenerModifier { navigator, _, box in
box.id = navigator.addTabListener(handler)
}
)
}
注销逻辑在 modifier 里统一处理:
swift
private func unregisterIfRouteGone() {
guard let id = box.id else { return }
let stillOnStack: Bool
if let settings = currentRoute {
stillOnStack = navigator.pageRoutes.contains { $0 === settings }
} else {
stillOnStack = !navigator.pageRoutes.isEmpty
}
guard !stillOnStack else { return } // 还在栈里(只是被盖住),保留监听
navigator.removeListener(id)
box.id = nil
}
这是整份代码最精妙的一处。 页面被新页盖住时会触发 onDisappear,若在那里直接注销,那么上层页返回触发路由变化时,这个页面已经收不到通知 ------ 但它明明还活在栈里。
所以注销条件改成「自己真的出栈了吗」,用对象身份判断。RouteSettings 的相等性就是按标识定义的:
swift
public nonisolated static func == (lhs: RouteSettings, rhs: RouteSettings) -> Bool {
lhs === rhs
}
public nonisolated func hash(into hasher: inout Hasher) {
hasher.combine(ObjectIdentifier(self))
}
这样同名但不同次 push 的页面不会混淆 ------ /detail 打开两次是两个独立实例、两条独立 await、两次独立返回值。
8. 监听器用 Token 而非闭包
Swift 闭包不可比较,无法「移除这个闭包」,所以监听 API 返回 token。同一个 token 类型同时服务于路由监听和 Tab 监听 ,removeListener 会两边都清理:
swift
public struct RouteListenerID: Hashable, Sendable {
fileprivate let uuid = UUID()
}
@discardableResult
public func addListener(_ handler: @escaping RouteChangedHandler) -> RouteListenerID {
let id = RouteListenerID()
listeners.append((id, handler))
return id
}
@discardableResult
public func addTabListener(_ handler: @escaping TabChangedHandler) -> RouteListenerID {
let id = RouteListenerID()
tabListeners.append((id, handler))
return id
}
public func removeListener(_ id: RouteListenerID) {
listeners.removeAll { $0.id == id } // 两类都清,调用方不用记 id 属于哪边
tabListeners.removeAll { $0.id == id }
}
分发前要先快照:
swift
private func notifyListeners(from: RouteSettings?, to: RouteSettings?) {
routePre = from
route = to
let snapshot = listeners // 防止回调里 removeListener 导致遍历崩溃
for item in snapshot {
item.handler(from, to)
}
}
易忽略 :onRouteChanged 的回调里就可能调 removeListener(页面出栈时)。直接遍历原数组边遍历边删会触发未定义行为,这一行快照是必需的防御,不是多余复制。
9. finishPendingWaits:重建引擎前的收尾
Get.setup 在 tabCount 变化时会重建引擎。此时栈里那些 RouteSettings 身上还挂着没人 resume 的 continuation ------ 直接丢掉就是泄漏,对应的 await 永远不返回:
swift
public func finishPendingWaits() {
for settings in routeTabs.joined() {
settings.complete(with: nil) // 全部以 nil 结束,await 得以返回
}
}
public static func reset() {
engine?.finishPendingWaits() // reset 同样先收尾
engine = nil
}
setup / reset 两条路径都调了它。这是容易被忽略的边界 :测试和 Preview 里频繁 reset(),如果不先结束等待,那些挂起的 Task 会一直留到进程结束。
10. 日志重构:带锁的 formatter 缓存
包内日志受 Navigator.debug 控制(默认 false)。DateFormatter 创建开销不小,重构后按「locale + 格式」缓存,并用 NSLock 保护:
swift
private enum DLogDate {
private static let lock = NSLock()
nonisolated(unsafe) private static var cache: [String: DateFormatter] = [:]
static func formatter(
locale: Locale = .autoupdatingCurrent,
dateFormat: String = "yyyy-MM-dd HH:mm:ss.SSS"
) -> DateFormatter {
let key = locale.identifier + "\0" + dateFormat
lock.lock()
defer { lock.unlock() }
if let cached = cache[key] { return cached }
let formatter = DateFormatter()
formatter.locale = locale
formatter.dateFormat = dateFormat
cache[key] = formatter
return formatter
}
}
易忽略 :DateFormatter 不是线程安全的,共享实例必须加锁;nonisolated(unsafe) 是在告诉 Swift 6 编译器「这个可变全局状态由我自己保证同步」------ 配合显式的 NSLock 使用是成立的。
四、总结
Navigator 做的事很朴素:在每个入栈的 RouteSettings 上挂一个 CheckedContinuation,push 时 await,pop 时 resume,就把单向的 NavigationPath 变成了能拿回返回值的双向通道。
- await 式跳转拿到返回值 :
let result = await Get.toNamed(...)让「跳转 → 等待 → 处理结果」写在一处,不再需要全局状态中转临时字段。 - 路由表与引擎解耦 :包内不含业务路由名,全靠
containsRoute/titleProvider/unknownRoute注入,天然可测。 - 路由与 Tab 监听分离 :
onRouteChanged只管路由变化,切 Tab 走onTabChanged,业务侧不会被「假跳转」淹没。 - 多 Tab 栈独立且同步 :
pathTabs与routeTabs双轨并行,系统侧滑改 path 时自动补齐并complete(nil),不留挂起的 await。 - 校验优先于破坏 :
offNamed/offAllNamed先验证目标可落地再动栈,避免非法路由名把当前页「就地销毁」。
三个容易踩的坑 :complete 可能早于 waitForResult,必须有 pendingResult 暂存否则 await 挂死;页面被新页盖住时 onDisappear 触发但不能注销监听,要以「是否真的出栈」为准;遍历监听器前必须快照,因为回调里可能正在移除自己。