WWDC Quick Look 💓 By SwiftGGTeam
App essentials in SwiftUI

App essentials in SwiftUI

观看原视频

Highlight

SwiftUI 2020 新增 AppSceneWindowGroupSceneStorageDocumentGroupSettingsCommands,让应用入口、多窗口、状态恢复、文档窗口、偏好设置和菜单命令都能用声明式代码组织。

核心内容

00:35)SwiftUI 第一年的重点是 View。开发者可以用声明式语法组合文本、图片、列表和导航,但一个完整 App 还要面对入口、窗口、平台生命周期、菜单、偏好设置和文档管理。这个 session 的切入点就是补上 View 外面的层级:App 拥有 Scene,Scene 承载 View,平台决定 Scene 如何显示。

03:57App 协议把入口写成一个带 @main 的类型。这个入口的 body 返回 Scene,Scene 再承载 View。最常见的 Scene 是 WindowGroup,它把同一份界面定义交给系统,让 iPadOS 和 macOS 可以按平台规则创建窗口、标签页和新实例。

07:48)多窗口带来的第一个实际问题是状态边界。BookClub 示例里,每个窗口选择的书都不同;修改一个窗口的选择,不会影响其他窗口。共享数据模型放在 App 层,窗口里的 View 状态保持独立,这就是 SwiftUI Scene 架构要表达的核心。

12:08)当平台负责创建和销毁 Scene 时,开发者还需要恢复每个窗口的局部状态。SceneStorage 用一个唯一 key 保存 View 状态,SwiftUI 会在合适的时机自动保存和恢复。后半段还给出三个扩展方向:DocumentGroup 管文档窗口,Settings 管 macOS 偏好设置,commands modifier 管菜单命令。

详细内容

App、Scene 和 View 的层级

03:57)示例 App 只有一个入口类型。BookClubApp 符合 App 协议,body 返回 WindowGroupWindowGroup 再创建 ReadingListViewer

@main
struct BookClubApp: App {
    @StateObject private var store = ReadingListStore()

    var body: some Scene {
        WindowGroup {
            ReadingListViewer(store: store)
        }
    }
}

struct ReadingListViewer: View {
    @ObservedObject var store: ReadingListStore

    var body: some View {
        NavigationView {
            List(store.books) { book in
                Text(book.title)
            }
            .navigationTitle("Currently Reading")
        }
    }
}

class ReadingListStore: ObservableObject {
    init() {}

    var books = [
        Book(title: "Book #1", author: "Author #1"),
        Book(title: "Book #2", author: "Author #2"),
        Book(title: "Book #3", author: "Author #3")
    ]
}

struct Book: Identifiable {
    let id = UUID()
    let title: String
    let author: String
}

关键点:

  • @mainBookClubApp 成为程序入口,session 明确提到这是 Swift 5.3 的新属性。
  • BookClubAppbody 返回 some Scene,因为 App 的内容由 Scene 组成。
  • WindowGroup 是主界面的 Scene 定义,系统会按平台能力创建窗口或标签页。
  • @StateObject 在 App 层拥有 ReadingListStoreReadingListViewer 通过 @ObservedObject 使用这份模型。
  • .navigationTitle("Currently Reading") 不只显示导航栏标题;session 演示它还会影响 App Switcher 和 macOS 窗口标题。

WindowGroup 的多窗口状态

10:21WindowGroup 的视图定义可以被平台实例化多次。macOS 和 iPadOS 上,用户通过菜单项、快捷键或多任务手势创建新窗口时,每个 Scene 都有自己的 View 状态。

@main
struct BookClubApp: App {
    @StateObject private var store = ReadingListStore()

    var body: some Scene {
        WindowGroup {
            ReadingListViewer(store: store)
        }
    }
}

struct ReadingListViewer: View {
    @ObservedObject var store: ReadingListStore

    var body: some View {
        NavigationView {
            List(store.books) { book in
                Text(book.title)
            }
            .navigationTitle("Currently Reading")
        }
    }
}

class ReadingListStore: ObservableObject {
    init() {}

    var books = [
        Book(title: "Book #1", author: "Author #1"),
        Book(title: "Book #2", author: "Author #2"),
        Book(title: "Book #3", author: "Author #3")
    ]
}

struct Book: Identifiable {
    let id = UUID()
    let title: String
    let author: String
}

关键点:

  • store 是共享模型,每个窗口都能读取同一份书单。
  • ReadingListViewer 是窗口内容的模板,平台需要新窗口时会创建新的 Scene 实例。
  • View 状态属于各自的 Scene,session 用“不同窗口选中不同书”演示了这个边界。
  • WindowGroup 在 macOS 上会提供 File 菜单里的新建窗口入口,并支持标准 Command-N 快捷键。

SceneStorage 恢复每个窗口的选择

12:07)平台管理 Scene 生命周期后,本地 View 状态需要恢复机制。示例把选中的书保存为字符串,再转换成 Binding<UUID?> 交给 NavigationLink

@main
struct BookClubApp: App {
    @StateObject private var store = ReadingListStore()

    var body: some Scene {
        WindowGroup {
            ReadingListViewer(store: store)
        }
    }
}

struct ReadingListViewer: View {
    @ObservedObject var store: ReadingListStore
    @SceneStorage("selectedItem") private var selectedItem: String?

    var selectedID: Binding<UUID?> {
        Binding<UUID?>(
            get: { selectedItem.flatMap { UUID(uuidString: $0) } },
            set: { selectedItem = $0?.uuidString }
        )
    }

    var body: some View {
        NavigationView {
            List(store.books) { book in
                NavigationLink(
                    destination: Text(book.title),
                    tag: book.id,
                    selection: selectedID
                ) {
                    Text(book.title)
                }
            }
            .navigationTitle("Currently Reading")
        }
    }
}

class ReadingListStore: ObservableObject {
    init() {}

    var books = [
        Book(title: "Book #1", author: "Author #1"),
        Book(title: "Book #2", author: "Author #2"),
        Book(title: "Book #3", author: "Author #3")
    ]
}

struct Book: Identifiable {
    let id = UUID()
    let title: String
    let author: String
}

关键点:

  • @SceneStorage("selectedItem") 用唯一 key 标识要保存的状态。
  • selectedItem 存字符串,因为 SceneStorage 保存的是可恢复的简单值。
  • selectedID 把字符串转换为 UUID,再包装成 Binding
  • NavigationLink 使用 tagselection 绑定选中项,恢复后能回到对应书籍。
  • SwiftUI 负责在合适时机保存和恢复这个状态,开发者不用手写窗口生命周期逻辑。

DocumentGroup 处理文档窗口

12:59)文档类 App 可以把根 Scene 换成 DocumentGroup。它负责打开、编辑和保存文档 Scene,示例用 ShapeDocument 展示 FileDocument 的读写入口。

import SwiftUI
import UniformTypeIdentifiers

@main
struct ShapeEditApp: App {
    var body: some Scene {
        DocumentGroup(newDocument: ShapeDocument()) { file in
            DocumentView(document: file.$document)
        }
    }
}

struct DocumentView: View {
    @Binding var document: ShapeDocument

    var body: some View {
        Text(document.title)
            .frame(width: 300, height: 200)
    }
}

struct ShapeDocument: Codable {
    var title: String = "Untitled"
}

extension UTType {
    static let shapeEditDocument =
        UTType(exportedAs: "com.example.ShapeEdit.shapes")
}

extension ShapeDocument: FileDocument {
    static var readableContentTypes: [UTType] { [.shapeEditDocument] }

    init(fileWrapper: FileWrapper, contentType: UTType) throws {
        let data = fileWrapper.regularFileContents!
        self = try JSONDecoder().decode(Self.self, from: data)
    }

    func write(to fileWrapper: inout FileWrapper, contentType: UTType) throws {
        let data = try JSONEncoder().encode(self)
        fileWrapper = FileWrapper(regularFileWithContents: data)
    }
}

关键点:

  • DocumentGroup(newDocument:) 定义新文档的默认值。
  • file.$documentDocumentView 一个文档绑定,界面可以直接编辑文档内容。
  • ShapeDocument 符合 Codable,示例用 JSONDecoderJSONEncoder 完成读写。
  • UTType(exportedAs:) 声明自定义文档类型。
  • FileDocumentreadableContentTypes、初始化方法和 write 方法构成文档读写边界。

Settings 和 Commands 补齐桌面 App 体验

13:27)macOS 常见的偏好设置窗口可以写成 Settings Scene。session 说明它会自动设置 Preferences 命令,并给窗口正确的样式。

@main
struct BookClubApp: App {
    @StateObject private var store = ReadingListStore()

    @SceneBuilder var body: some Scene {
        WindowGroup {
            ReadingListViewer(store: store)
        }

    #if os(macOS)
        Settings {
            BookClubSettingsView()
        }
    #endif
    }
}

struct BookClubSettingsView: View {
    var body: some View {
        Text("Add your settings UI here.")
            .padding()
    }
}

关键点:

  • @SceneBuilder 让 App 的 body 同时声明多个 Scene。
  • WindowGroup 仍然是主界面。
  • Settings 只在 macOS 分支下出现。
  • BookClubSettingsView 是偏好设置窗口的内容。

14:07)菜单命令使用 Commands 类型封装,再通过 commands modifier 加到 Scene 上。示例里的命令会根据当前焦点启用或禁用。

struct BookCommands: Commands {
    @FocusedBinding(\.selectedBook) private var selectedBook: Book?

    var body: some Commands {
        CommandMenu("Book") {
            Section {
                Button("Update Progress...", action: updateProgress)
                    .keyboardShortcut("u")
                Button("Mark Completed", action: markCompleted)
                    .keyboardShortcut("C")
            }
            .disabled(selectedBook == nil)
        }
    }

    private func updateProgress() {
        selectedBook?.updateProgress()
    }
    private func markCompleted() {
        selectedBook?.markCompleted()
    }
}

关键点:

  • BookCommands 符合 Commands,菜单逻辑可以独立成类型。
  • CommandMenu("Book") 创建一个自定义菜单。
  • Button 可以绑定 action 和键盘快捷键。
  • .disabled(selectedBook == nil) 根据焦点状态禁用整组命令。
  • @FocusedBinding(\.selectedBook) 让命令面向当前用户焦点,session 将它类比为 AppKit 或 UIKit 的 responder chain。

核心启发

  • 做什么:把 SwiftUI App 的入口改成 @main + App 协议。 为什么值得做:session 展示的 BookClubApp 用一个类型同时声明入口、共享模型和主 Scene,启动代码更贴近界面结构。 怎么开始:先创建 struct YourApp: App,在 body 里放 WindowGroup,把原来第一屏 View 作为 WindowGroup 的内容。

  • 做什么:给 iPadOS 和 macOS 用户增加多窗口能力。 为什么值得做WindowGroup 会按平台规则创建多个窗口或标签页,BookClub 示例证明每个窗口可以保留独立选择状态。 怎么开始:把主界面放进 WindowGroup,共享数据放在 App 层的 @StateObject,窗口内选择、滚动或导航状态留在 View 层。

  • 做什么:用 SceneStorage 恢复每个窗口的局部状态。 为什么值得做:session 明确说 Scene 生命周期由平台管理,SceneStorage 用 key 自动保存和恢复 View 状态。 怎么开始:为需要恢复的简单值加 @SceneStorage("key"),必要时像示例一样在字符串和业务 ID 之间做绑定转换。

  • 做什么:为文档类工具改用 DocumentGroup为什么值得做:ShapeEdit 示例把文档创建、打开、编辑和保存放进 Scene 定义,文档读写集中到 FileDocument怎么开始:定义一个符合 FileDocument 的文档类型,声明 UTType,再用 DocumentGroup(newDocument:) 创建根 Scene。

  • 做什么:给 macOS 版本补上偏好设置窗口和菜单命令。 为什么值得做:session 里的 SettingsCommands 让偏好设置、菜单项、快捷键和焦点状态都沿用 SwiftUI 的声明式模型。 怎么开始:在 @SceneBuilder 里加 Settings Scene,再创建符合 Commands 的类型,用 CommandMenu@FocusedBinding 组织命令。

关联 Session

评论

GitHub Issues · utterances