深链、程序化跳转和状态恢复经常被实现成三套代码:URL handler 直接设置几个 Boolean,按钮向某个 NavigationLink 注入 isActive,启动时又从磁盘恢复另一组选择值。它们短期都能工作,长期却会互相覆盖,因为三套入口没有共享“当前界面栈是什么”这一事实来源。

NavigationStack 的关键不是替换旧容器名称,而是把导航提升为数据。Apple 在 Understanding the navigation stack 中把 path 描述为栈当前展示的数据序列;WWDC22 的 SwiftUI navigation cookbook 进一步展示了通过修改 path 完成 push、pop、深链和恢复。本文把这些能力收束成一条管线:外部输入 → Route → 业务校验 → 原子提交 path

版本边界

NavigationStackNavigationSplitView 与 value-based NavigationLink 从 iOS 16、macOS 13 等对齐版本起稳定可用。Apple 的 迁移指南 建议单列堆栈从 NavigationView 转向 NavigationStack,并把程序化状态提升到容器 path。

本文以 Xcode 26.6 稳定 SDK 为基线。正确实现区域已通过 macOS Swift Package 与通用 iOS Simulator 编译,路由、深链、编解码等非视觉行为由 CanonicalSnippetTests 执行;下文的 Swift 数组 pattern 写法则被自动门禁确认会产生预期编译诊断,修正版另行编译并测试。Xcode 27 目前仍属 Beta;本文不依赖任何 iOS 27 导航新能力,因此 Beta 版本只记录为核验背景,不与稳定 API 混用。

首选强类型数组,而不是默认使用 NavigationPath

如果一个栈只需要一种路由枚举,[Route]NavigationPath 更容易编码、比较和测试。NavigationPath 的价值是容纳异构 Hashable 值;类型擦除也意味着可编码性要到运行时检查。Apple 的 NavigationPath 文档 说明,只有所有元素都遵循 Codable 时,codable 才会返回表示,否则为 nil

路由应该携带轻量、稳定的标识符,而不是完整可变模型:

enum Route: Hashable, Codable {
    case topic(Topic.ID)
    case article(Article.ID)
    case articleSettings(Article.ID)
}

@MainActor
@Observable
final class AppRouter {
    var path: [Route] = []

    func showArticle(_ id: Article.ID) {
        path.append(.article(id))
    }

    func popToRoot() {
        path.removeAll()
    }
}

模型详情来自 repository 或 store;path 只回答“展示哪个目的地”。这样数据库同步后不会从恢复数据带回过期标题和正文,也能在文章已删除时明确降级。Apple 的导航栈说明同样提示不要把模型当作 path 的运输载体,应保持元素轻量。

目的地映射必须处于稳定可见的层级

Value-based link 只把值加入 path;navigationDestination(for:) 负责把值映射为视图。它应放在 NavigationStack 能稳定发现的位置,不要放进 ListLazyVGrid 的每一个 cell。WWDC22 Session 明确说明:lazy 容器不会立即创建全部子视图,把 destination modifier 放在 cell 内可能导致容器尚未看到目的地,而且会重复注册。

struct LibraryRoot: View {
    @State private var router = AppRouter()
    let catalog: Catalog

    var body: some View {
        @Bindable var router = router

        NavigationStack(path: $router.path) {
            ArticleList(articles: catalog.articles)
                .navigationDestination(for: Route.self) { route in
                    destination(for: route)
                }
        }
    }

    @ViewBuilder
    private func destination(for route: Route) -> some View {
        switch route {
        case .topic(let id):
            TopicView(id: id)
        case .article(let id):
            ArticleView(id: id)
        case .articleSettings(let id):
            ArticleSettingsView(id: id)
        }
    }
}

这里 @State 拥有 router,局部 @Bindable 只生成 $router.path。目的地接收 ID,而不是假设模型永远存在;详情页仍需定义 loading、missing 和 failure 状态。

深链和恢复数据进入同一条解析、校验和提交管线

图 1:不同输入在提交前归一化为有效 Route 前缀;本站原创 1400×800 程序化 SVG。

深链:先完整解析,再一次替换

假设 URL 形式为 websun://topics/swiftui/articles/observation。Handler 不应一边解析一边 append,因为中途失败会留下半条路径。先返回候选数组,再验证所有 ID,最后一次赋值:

struct DeepLinkParser {
    func routes(from url: URL) -> [Route]? {
        guard url.scheme == "websun" else { return nil }
        let parts = [url.host].compactMap { $0 }
            + Array(url.pathComponents.dropFirst())

        switch parts {
        case ["topics", let topicID]:
            return [.topic(topicID)]
        case ["topics", let topicID, "articles", let articleID]:
            return [.topic(topicID), .article(articleID)]
        default:
            return nil
        }
    }
}

extension AppRouter {
    func open(_ url: URL, parser: DeepLinkParser, catalog: Catalog) {
        guard let candidate = parser.routes(from: url) else { return }
        let valid = candidate.prefix { route in catalog.contains(route) }
        path = Array(valid)
    }
}

这里的策略是“保留最长有效前缀”:文章失效但专题存在时,用户至少到达专题;首段就无效则回到根。上面的 switch parts 用数组 pattern 压缩了意图,但 Swift 不接受这种数组 pattern;自动门禁断言该精确代码产生预期诊断,可编译的 canonical 修正版使用数量与索引/切片判定,并已通过测试。产品也可以选择展示专门的失效页,但必须明确。不要静默把任意坏 URL 变成看似成功的其他页面,否则分析和用户预期都会混乱。

如果 catalog 需要异步加载,先保存 pendingURL,等数据 ready 后执行同一个 open;不要先压入占位 route 再修补。冷启动同时存在 scene 恢复与外部深链时,应设优先级:显式深链高于旧恢复状态。恢复可以先解码,但收到待处理深链后最终 path 只提交深链结果。

恢复:保存界面坐标,不保存业务数据

@SceneStorage 是按 scene 保存的轻量状态,系统决定持久化时机;Apple 文档明确警告不要存大数据或敏感信息。多窗口应用应让每个 scene 保留自己的导航位置,而不是用全局 UserDefaults 让所有窗口争用一条 path。SceneStorage 文档 也说明,scene 被明确销毁时,对应数据会消失,它不是业务数据库。

强类型 [Route] 可直接编码:

struct LibraryScene: View {
    @SceneStorage("library.navigation") private var savedPath: Data?
    @State private var router = AppRouter()
    let catalog: Catalog

    var body: some View {
        @Bindable var router = router

        NavigationStack(path: $router.path) {
            ArticleList(articles: catalog.articles)
                .navigationDestination(for: Route.self) { route in
                    RouteDestination(route: route)
                }
        }
        .task {
            guard router.path.isEmpty, let savedPath else { return }
            let decoded = try? JSONDecoder().decode([Route].self, from: savedPath)
            router.path = validPrefix(of: decoded ?? [], in: catalog)
        }
        .onChange(of: router.path) { _, path in
            savedPath = try? JSONEncoder().encode(path)
        }
    }
}

恢复时必须重新验证。内容可能被删除,权限可能变化,应用版本可能不再认识旧 route。这里采用有效前缀,避免在深层目的地失效时连根页面也打不开。若 Route schema 会演进,应给持久化包裹显式版本号,并为旧版本写迁移;无法迁移时安全回根,而不是强制解码崩溃。

异构栈确实需要 NavigationPath 时,可以保存其 CodableRepresentation,但必须处理 path.codable == nil。相比之下,一个业务 Route 枚举常常已经足够表达异构页面,同时保留编译期 Codable 约束。

Split View 与 Stack 不应共享一条含糊状态

在 iPad 和 macOS 上,NavigationSplitView 的 sidebar selection 与 detail 内部 stack path 是两个维度。把它们都塞进一个 [Route] 会让折叠和展开时语义不清。更稳妥的导航模型包含 selectedTopicIDdetailPath,并在窄宽适配时定义映射规则。Apple 的 Bringing robust navigation structure 示例展示了 split、stack、selection 的组合。

深链仍然经过同一管线,只是归一化结果是完整 NavigationState:专题写入 selection,文章写入 detail path。保存和恢复也应把两者作为一个一致快照编码,避免只恢复详情却没有对应 sidebar 选择。

Sheet、popover 和 full-screen cover 与 push stack 有不同的关闭手势、尺寸和恢复语义。把 .compose 塞进 Route 数组后再让 destination 内部弹 sheet,会产生“栈里有一个页面,但屏幕上又覆盖一个页面”的双重状态。更清楚的模型是让导航快照分别保存 path 与可选 presentation

struct NavigationState: Codable, Equatable {
    var path: [Route] = []
    var presentation: Presentation?
}

enum Presentation: Codable, Equatable, Identifiable {
    case articleSettings(Article.ID)
    case share(Article.ID)

    var id: String { String(describing: self) }
}

深链解析器可以同时产出两部分,例如先定位文章,再打开设置 sheet;提交仍是一次完整 state 替换。Dismiss 只清空 presentation,不应意外 pop path。是否恢复 modal 要按产品判断:编辑草稿也许值得恢复,一次性分享面板通常不值得。这个策略必须写进编码 schema,而不是让所有临时界面自动持久化。

给持久化路由加版本,并让迁移保持纯函数

Route 枚举的 case 名和关联值一旦编码,就形成了本地数据格式。重命名 case、改变 ID 类型或拆分页面后,旧 scene 数据可能无法解码。可在外层保存版本:

struct StoredNavigation: Codable {
    var version: Int
    var state: NavigationState
}

解码流程先识别版本,再调用 migrateV1ToV2 之类的纯函数,最终仍进入统一校验管线。迁移只转换路由结构,不查询网络;业务存在性由随后 catalog validation 负责。无法识别的未来版本应丢弃并回根,不能用 try! 假定永远兼容。

URL、恢复与用户操作的竞态需要明确优先级

冷启动时 catalog 加载、scene restore 与 onOpenURL 可能交错。推荐维护一个短暂启动阶段:先读取恢复候选;若收到外部 URL,则记录为更高优先级候选;数据可用后只对胜出候选执行一次 parse、validate、commit。用户已经主动点击后,迟到的恢复任务不得覆盖当前 path,因此恢复提交前还要确认 router 仍处于初始空状态。

这些规则可以用 reducer 表达并做确定性测试。重点不是引入某个架构框架,而是禁止多个异步回调直接写 path。所有入口都提交导航意图,单一协调器决定顺序,才能避免“偶尔启动后跳回旧页面”这类难以复现的问题。

失败模式

  • 用多个 isPresented Boolean 表示互斥页面,组合后出现不可能状态。
  • path 存完整模型,恢复出过期内容,或模型 Hashable 因可变字段改变而失稳。
  • 把 destination modifier 放在 lazy cell,目的地偶发不可发现。
  • URL 解析过程中逐项 append,失败后留下半条栈。
  • 解码成功就直接恢复,不核验 ID、权限和 schema 版本。
  • 用全局存储保存多窗口导航,让一个窗口覆盖另一个窗口。
  • 把 scene restoration 当可靠数据库;系统并不保证何时持久化,而且 scene 删除后数据可消失。

可验证的测试矩阵

解析器是纯函数,应覆盖未知 scheme、缺段、多余段、非法编码和有效路径。路由校验覆盖全部存在、中段失效、首段失效。恢复覆盖空数据、损坏 JSON、旧版本和删除内容。UI 测试则验证点击 link 会产生预期 path、Back 会移除尾项、显式深链覆盖恢复状态。

本文的可编译实现已在 macOS 与通用 iOS Simulator 目标上通过构建,多项纯导航行为也已自动测试;当前证据不包含 iPhone 单列、iPad split、多窗口与冷启动 URL 的完整 UI 矩阵。发布前仍应在目标稳定 SDK 和设备上验证这些场景。真正的完成标准不是“能跳到详情”,而是所有导航入口都生成同一种可检查状态,失败时也能确定地回到可用页面。

Sources