Bring multiple windows to your SwiftUI app
Swift & UI 进阶 20m

为 SwiftUI 应用添加多窗口支持

Bring multiple windows to your SwiftUI app

2022年6月6日

在 Apple 官方观看视频

一句话判断

SwiftUI 在 iOS 16 和 macOS Ventura 终于补上了多窗口管理这块拼图,但 API 设计仍然偏保守——想精细控制窗口生命周期,还是得回退到 AppKit/UIKit。

这场 Session 讲了什么

SwiftUI 自诞生起在窗口管理上就存在明显短板:你没法方便地打开新窗口、管理多个窗口实例、或者为不同场景定义不同类型的窗口。iOS 16 和 macOS Ventura 通过增强 WindowGroup 和引入新的 Window scene 类型,大幅改善了这一局面。

WindowGroup 现在支持通过 openWindow environment action 打开新窗口。你可以在按钮的 action 中调用 openWindow(id:)openWindow(value:) 来创建新窗口实例。配合 @BindingonOpenURL,多窗口间的数据传递也变得更加清晰。WindowGroupfor 参数可以接受 Codable 类型,系统会根据传入的值自动创建对应的窗口实例。

全新的 Window scene 类型用于创建单例窗口——比如设置界面、关于页面。和 WindowGroup 不同,Window 最多只有一个实例存在,再次调用 openWindow 不会创建新窗口,而是把已有的带到前台。这个行为和 macOS 上的 PreferencesWindow 需求完全吻合。iPadOS 上,多窗口通过 Stage Manager 管理,SwiftUI 的多窗口 API 和 Stage Manager 的整合是自动的。

值得深挖的点

WindowGroup 的数据驱动窗口创建

WindowGroup(for: Item.self) 配合 openWindow(value:) 是这次最有意思的 API。它的工作方式是:你定义一个 Codable 类型作为窗口的数据源,系统根据这个值创建窗口。当用户通过 openWindow(value: item) 触发时,SwiftUI 会查找匹配的 WindowGroup,用传入的 item 作为上下文渲染内容。这就意味着你可以用不同的数据打开同一类型的窗口——比如用不同的文档 ID 打开编辑器窗口。系统还负责处理窗口的去重:如果已经有窗口持有相同的值,它会激活那个窗口而不是创建新的。

Window vs WindowGroup 的选择逻辑

Window 还是 WindowGroup 取决于你想要的语义。WindowGroup 用于可以存在多个实例的场景(文档编辑器、浏览器标签),Window 用于全局唯一的场景(设置、关于)。但有一个容易踩的坑:Window 不支持 commands 修饰符来自定义菜单栏。如果你需要在设置窗口里有特定的菜单项,还是得用 WindowGroup 然后手动控制只显示一个实例。另外,Window 目前在 iPadOS 上不会出现在 Stage Manager 的窗口列表里,它的行为更像一个 sheet。

代码片段

使用 WindowGroup 和 openWindow 创建多窗口

@main
struct MyApp: App {
    var body: some Scene {
        // 主窗口组:可以打开多个实例
        WindowGroup {
            ContentView()
        }

        // 数据驱动的编辑器窗口
        WindowGroup(for: Document.ID.self) { $documentID in
            DocumentEditorView(documentID: $documentID)
        }

        // 单例设置窗口
        Window("设置", id: "settings") {
            SettingsView()
        }
    }
}

// 在视图中打开新窗口
struct ContentView: View {
    @Environment(\.openWindow) private var openWindow

    var body: some View {
        List(documents) { doc in
            Button(doc.title) {
                // 根据文档 ID 打开对应的编辑窗口
                openWindow(value: doc.id)
            }
        }
        Button("打开设置") {
            openWindow(id: "settings") // 打开单例窗口
        }
    }
}

处理窗口间的数据同步

// 使用 FetchedResults 或 @ObservableObject 在多窗口间共享数据
class DocumentStore: ObservableObject {
    @Published var documents: [Document] = []
}

struct DocumentEditorView: View {
    @Binding var documentID: Document.ID?
    @EnvironmentObject var store: DocumentStore

    var body: some View {
        if let id = documentID,
           let doc = store.documents.first(where: { $0.id == id }) {
            // 编辑文档内容
            TextEditor(text: .constant(doc.content))
        }
    }
}

自定义 Window 的键盘快捷键

@main
struct MyApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
        .commands {
            CommandGroup(replacing: .newItem) {
                Button("新建文档") {
                    // 通过快捷键触发打开新窗口
                }
                .keyboardShortcut("n", modifiers: .command)
            }

            CommandMenu("窗口") {
                Button("设置") {
                    openWindow(id: "settings")
                }
                .keyboardShortcut(",", modifiers: .command)
            }
        }

        Window("设置", id: "settings") {
            SettingsView()
        }
    }
}

最佳实践

WindowGroupfor 参数设计好数据模型。这个 Codable 类型应该包含足够的信息让窗口独立渲染——不要只传一个 ID 然后在窗口内部再查询数据库。理想的做法是传入所有必要的展示数据,或者确保 @EnvironmentObject 在新窗口中可用。

macOS 上测试多窗口时,注意 UserDefaults 和文件系统操作在多窗口间的竞争条件。如果两个窗口同时修改同一个文件,SwiftUI 不会帮你做合并——你需要自己用 NSFileCoordinator 或其他同步机制处理。

iPadOS 上多窗口需要用户在 Settings 中开启 Stage Manager(iPadOS 16.0 仅支持 M1 芯片的 iPad,16.1 扩展到了更多机型)。调用 openWindow 时如果 Stage Manager 不可用,系统会降级为全屏展示。你的代码不需要做特殊处理,但设计上要考虑这个降级行为。

还有什么值得关注

  • dismissWindow environment action 可以关闭指定的窗口,配合 openWindow 形成完整的窗口管理闭环。
  • handlesExternalEvents 修饰符可以控制 WindowGroup 是否响应外部 URL 请求。
  • macOS 上 WindowGroup 支持通过 defaultSize 设置窗口的初始尺寸,避免首次打开时尺寸不对的问题。
WWDC 2022