Swift 进阶&封装:仿 Get 给SwiftUI 添加路由导航能力

关键词:SwiftUI、NavigationStack、NavigationPath、具名路由、GetX、async/await、CheckedContinuation

一、需求来源

「开发多 Tab 的 SwiftUI 应用时遇到一个痛点:跳转到下一页、等用户操作完、再拿回结果------这条最简单的链路,官方 API 表达不了。」

NavigationStackpath 是单向的,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
            }
        }
    }
}
  1. 返回值靠全局状态中转selectedID / didFinishDetail 这类临时字段堆在 AppState 里,生命周期失控。
  2. 调用链路断裂 :无法像 Flutter 那样 await Navigator.push(...),业务逻辑被拆成两半。
  3. 路由名散落各处:改一个路径要全局替换,也没有地方校验路由是否存在。
  4. 多 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 时自动回落环境里那个 NavigatortitleProvider

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
}

易忽略preventsDuplicateunknownRoute 上有例外。跳到 /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.setuptabCount 变化时会重建引擎。此时栈里那些 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 栈独立且同步pathTabsrouteTabs 双轨并行,系统侧滑改 path 时自动补齐并 complete(nil),不留挂起的 await。
  • 校验优先于破坏offNamed / offAllNamed 先验证目标可落地再动栈,避免非法路由名把当前页「就地销毁」。

三个容易踩的坑complete 可能早于 waitForResult,必须有 pendingResult 暂存否则 await 挂死;页面被新页盖住时 onDisappear 触发但不能注销监听,要以「是否真的出栈」为准;遍历监听器前必须快照,因为回调里可能正在移除自己。


本文源码参考

相关推荐
2501_915918418 小时前
使用 VS Code 写 Swift 做 iOS 开发,插件能实现哪些功能,还有哪些短板
ide·vscode·ios·objective-c·个人开发·swift·敏捷流程
HouWan1 天前
Swift 6.4 发布:iOS开发者值得关注的新特性
ios·swift·apple
00后程序员张1 天前
Xcode vs KXApp,体积差几十G,选轻量方案还是完整工具链?
ide·vscode·ios·objective-c·个人开发·swift·敏捷流程
东坡肘子3 天前
iPhone Duo 带来的机遇与挑战 -- 肘子的 Swift 周报 #153
人工智能·swiftui·swift
闲云自留地5 天前
云平台存储管理员:Cinder 创建挂载卷 + Swift 分布式存储原理
分布式·wpf·swift
2501_915909067 天前
SwiftUI 和 UIKit 怎么选?两代 UI 框架的适用范围
vscode·ios·objective-c·个人开发·swift·敏捷流程
2501_915918417 天前
在Windows10上使用VSCode和Code Runner搭建Swift开发环境详细步骤
ide·vscode·ios·objective-c·个人开发·swift·敏捷流程
用户468104557248 天前
Swift 异步与并发
swift
安当加密03018 天前
PCI DSS 4.0与SWIFT CSP双合规:银行收单与跨境支付HSM部署实战
swift·hsm·密钥管理·pci dss·双合规