深层链接经常被当成“收到 URL 后跳转页面”,但可靠的导航还要处理冷启动、已打开应用、返回路径和场景恢复。NavigationStack 的优势是导航状态可以表达为普通数据。只要路由模型稳定,URL、列表点击和恢复流程就能写入同一条路径。

路由是领域数据

先定义应用真正支持的目的地,而不是把任意字符串塞进路径。Hashable 让路由可用于导航,Codable 让它可以恢复。

enum Route: Hashable, Codable {
    case library
    case book(id: Int)
    case settings
}

页面使用 [Route] 作为类型明确的路径。与保存视图或闭包相比,这个数组容易记录、测试和重建。

struct RootView: View {
    @State private var path: [Route] = []

    var body: some View {
        NavigationStack(path: $path) {
            LibraryView(openBook: { id in
                path.append(.book(id: id))
            })
            .navigationDestination(for: Route.self) { route in
                switch route {
                case .library:
                    LibraryView(openBook: { path.append(.book(id: $0)) })
                case .book(let id):
                    BookView(bookID: id)
                case .settings:
                    SettingsView()
                }
            }
        }
    }
}

根页面通常不需要也放进路径;数组描述的是从根页面继续压入的层级。这样返回按钮移除最后一个元素时,行为与数据模型一致。

URL 解析与导航分开

URL 是不可信输入。解析器应先验证 scheme、目的地和标识符,再返回领域路由。视图只消费解析结果。

func routes(for url: URL) -> [Route]? {
    guard url.scheme == "reader" else { return nil }

    switch url.host {
    case "book":
        guard let value = url.pathComponents.dropFirst().first,
              let id = Int(value) else { return nil }
        return [.book(id: id)]
    case "settings":
        return [.settings]
    default:
        return nil
    }
}

.onOpenURL 中决定替换还是追加路径。外部深层链接通常应该替换路径,使结果不依赖用户此前停留的位置;应用内部点击则适合 append

.onOpenURL { url in
    guard let destination = routes(for: url) else { return }
    path = destination
}

如果目标需要登录,不要先压入受保护页面再立即弹回。先把路由保存为 pending intent,完成认证后再提交路径,用户会看到更稳定的过渡。

多窗口应用还要避免把路径保存在全局单例中。每个 scene 应拥有自己的导航状态和恢复数据,否则在一个窗口打开图书可能改变另一个窗口的返回栈。共享的是账户和资料库等领域数据,不是页面路径。外部 URL 被系统交给某个 scene 后,由该 scene 的协调器解析并提交路由;如果产品要求新建窗口,则把经过验证的领域标识交给窗口创建流程。

通用链接还应保留安全降级。服务器路径可能已经过期,应用版本也可能不认识新目的地。此时显示稳定的根页面和可理解提示,比构造半条路径或停留空白页面更好。日志可以记录被拒绝的路由类型,但不要把 URL 中可能包含的令牌或私人查询参数写入分析系统。

恢复的是意图,不是旧界面

可以把路由编码进 SceneStorage,但恢复前仍要验证数据。一本书可能已经删除,某个功能也可能在新版本中下线。恢复流程应允许把无效路径截断到最近仍可显示的位置。

func encode(_ path: [Route]) -> String? {
    try? JSONEncoder().encode(path).base64EncodedString()
}

func decode(_ value: String) -> [Route]? {
    guard let data = Data(base64Encoded: value) else { return nil }
    return try? JSONDecoder().decode([Route].self, from: data)
}

路由枚举会成为持久化格式,因此不要随意重命名关联值或删除 case。需要演进时,可以使用带版本号的存储结构,或者在解码失败时安全回到根页面。

最后,为解析器写表驱动测试:合法 URL、错误 scheme、缺少 ID、非数字 ID 和未知目的地都应有明确结果。导航 UI 测试只需要覆盖少量关键路径。把 URL 语法、领域路由和 SwiftUI 呈现分开之后,深层链接不再是散落在页面里的条件分支,而是可恢复、可验证的应用状态。