在 SwiftUI App 中持有共享的可观察状态,然后将同一个实例传入两个场景。将每个窗口的草稿保留在其根视图中。这样,菜单栏控件可以更新所有窗口,同时每个窗口仍保留各自尚未完成的文本。

本教程将为一个简单的 Mac 标签编辑器构建这种结构:多个窗口显示同一个共享标签,每个窗口都有独立的草稿,菜单栏面板可以编辑共享标签或打开另一个窗口。所有示例文本均属于此应用,并使用虚构标签。

确定哪些更改应传播到所有窗口

先明确每个值应有的行为。编辑共享标签时,它应在所有地方更新。尚未完成的草稿则只应在有人输入的那个窗口中更改。这些要求决定了各个值应放在哪里。

Apple 文档说明,WindowGroup 创建的每个窗口,其视图层级内部实例化的状态都各自独立。相比之下,Apple 关于模型数据的指南展示了如何在顶层 App 中持有可观察引用,并将其传入视图层级。结合这两种模式,就能得出以下所有权方案。WindowGroup、Managing model data in your app。

值 本例中的所有者 预期行为
共享标签 App,使用带有 @Observable 模型的 @State 所有窗口和菜单栏面板都编辑同一个值
尚未完成的草稿 每个窗口的根视图,使用 @State 输入内容仅保留在该窗口中
菜单栏控件 接收 App 持有的模型 更改会传达到该实例的所有使用者
打开窗口的操作 从菜单栏视图的环境中读取 打开指定组中的一个窗口

关键在于实例由谁持有。分别创建两个对象,即使它们属于同一个类,也不会让内容自动共享。同样,在多个窗口中放入指向同一个 App 持有对象的引用,也不会为该对象创建独立副本。因此,本例只在 App 层级创建一次共享模型。

添加场景前先设定 macOS 基线

使用 macOS SwiftUI App 项目,将部署目标设为 macOS 14 或更高版本,并使用支持 Observation 的 Xcode 工具链,例如 Xcode 15 或更高版本。SwiftUI 从 macOS 14 开始集成 Observation。MenuBarExtra 及其窗口样式从 macOS 13 起可用,因此本实现的基线由 Observation 决定。Managing model data in your app、MenuBarExtra、window。

菜单栏面板使用 .menuBarExtraStyle(.window),因为其中包含文本字段和自定义布局。Apple 将这种样式描述为类似弹出框的窗口,其中控件的样式与普通窗口中的控件相似。它与编辑器的 WindowGroup 是两个独立场景。window。

本例还明确将模型和 UI 类型隔离到 @MainActor。Apple 将主 actor 定义为使用与主派发队列等效的执行器。在这里,所有更改都由 UI 控件发起,不需要后台任务。MainActor。

构建共享模型和各窗口独立的草稿

用以下完整的单文件示例替换生成的 App 入口点。确保目标中只保留一个 @main 声明。示例包含导入、模型、视图和初始文本。我们依据文档中的 API 检查了该示例,但由于此环境中没有 Swift 工具链,无法编译。

import SwiftUI
import Observation

@MainActor
@Observable
final class LabelStore {
    var sharedLabel = "Sample Alpha"
}

@main
@MainActor
struct LabelScopeApp: App {
    @State private var store = LabelStore()

    var body: some Scene {
        WindowGroup("Label Editor", id: "label-editor") {
            EditorWindow()
                .environment(store)
        }

        MenuBarExtra("Label Controls", systemImage: "square.grid.2x2") {
            LabelMenuPanel()
                .environment(store)
        }
        .menuBarExtraStyle(.window)
    }
}

@MainActor
struct EditorWindow: View {
    @Environment(LabelStore.self) private var store
    @State private var draft = "Sample Draft"

    var body: some View {
        VStack(alignment: .leading, spacing: 16) {
            SharedLabelControls()

            Divider()

            TextField("This window's draft", text: $draft)
                .textFieldStyle(.roundedBorder)

            Button("Use Draft as Shared Label") {
                store.sharedLabel = draft
            }

            Text("Publishing the draft keeps its local text intact.")
                .font(.caption)
        }
        .padding(20)
        .frame(minWidth: 420, minHeight: 220)
    }
}

@MainActor
struct SharedLabelControls: View {
    @Environment(LabelStore.self) private var store

    var body: some View {
        @Bindable var editableStore = store

        VStack(alignment: .leading, spacing: 8) {
            Text("Shared label: \(store.sharedLabel)")

            TextField("Shared label", text: $editableStore.sharedLabel)
                .textFieldStyle(.roundedBorder)
        }
    }
}

@MainActor
struct LabelMenuPanel: View {
    @Environment(\.openWindow) private var openWindow

    var body: some View {
        VStack(alignment: .leading, spacing: 16) {
            SharedLabelControls()

            Button("Open Another Editor") {
                openWindow(id: "label-editor")
            }
        }
        .padding(16)
        .frame(width: 320)
    }
}

这里有两处显式的环境注入:一处用于窗口内容,另一处用于菜单栏内容。两者都会接收 store。不要认为附加到某个窗口内容的环境修饰符会为同级场景提供依赖项。Apple 文档说明环境共享发生在视图层级中;这两处注入明确指定了每个层级的依赖项。Managing model data in your app。

在控件需要绑定时使用 @Bindable

SharedLabelControls 通过 @Environment(LabelStore.self) 读取模型。在其主体中,局部 @Bindable 变量会提供 TextField 所需的投影绑定。它引用的是现有可观察对象,不会创建另一个存储。Apple 文档说明了这种从环境获取模型再创建绑定的具体模式。Bindable。

草稿字段则使用 $draft,因为它的值已经由窗口视图的 @State 持有。按钮会将当前草稿复制到共享标签中。这是一个显式操作:之后共享标签的更改不会重写任一窗口的草稿。这就是本示例所选的编辑语义。

精确匹配窗口组标识符

菜单栏按钮从自身环境中获取 openWindow,并传入 "label-editor" 调用它,与组声明中的标识符一致。Apple 文档说明了如何使用这种基于标识符的方法从组中创建窗口。该按钮用于打开另一个编辑器,因此本示例不承诺查找并复用某个特定的现有窗口。WindowGroup。

通过两个窗口的操作序列检查作用域

以下操作序列用于检查示例的预期行为。无需额外插桩,即可区分共享模型实例和独立草稿存储。

  1. 启动应用。第一个编辑器显示共享标签 Sample Alpha 和草稿 Sample Draft。
  2. 打开菜单栏面板,然后选择 Open Another Editor。
  3. 将第一个窗口的草稿更改为 Draft One。第二个窗口的草稿保持不变。
  4. 在菜单栏面板中,将共享标签更改为 Sample Beta。两个编辑器窗口都应显示该共享标签,同时保留各自不同的草稿值。
  5. 在第一个窗口中,选择 Use Draft as Shared Label。两个窗口和菜单栏面板中的共享标签都变为 Draft One。第二个窗口的草稿仍为 Sample Draft。

此序列检查三种不同路径:本地输入、菜单栏编辑,以及显式地将本地值复制到共享值。如果本地输入更改了另一个窗口的草稿,请检查草稿是否被移入 App 持有的存储中。如果菜单栏的更改没有传到窗口,请检查菜单栏层级中是否又创建了一个 LabelStore()。

现象 首先检查
窗口和菜单栏中的标签不一致 是否分别初始化了模型,而非使用同一个共享实例
所有草稿一起更改 草稿存储是否放在了共享模型中
视图读取模型时失败 该层级中是否缺少 .environment(store)
编辑器意外重置 所属视图的身份是否发生变化,或模型是否被替换
无法打开另一个编辑器 声明的场景标识符与请求的标识符是否不一致

环境缺失会导致一个有文档说明的结果:按类型读取非可选的可观察对象时,假定某个祖先视图已提供它;Apple 指出,如果对象缺失,就会引发异常。预览和其他入口也要提供此依赖项。Managing model data in your app。

此示例将所有内容保存在内存中,没有实现保存或恢复。因此,关闭窗口会丢弃其尚未完成的草稿,退出应用会丢弃共享标签。如果草稿必须在这些操作后保留,请先明确单独的持久化需求,再扩展此示例。

将聚焦命令与共享菜单栏控件分开处理

共享标签无需选择活动窗口:所有使用者都接收同一个模型。编辑当前聚焦窗口草稿的操作则有不同要求,因为它必须确定具体窗口。

对于标准应用菜单命令,Apple 文档说明可使用 focusedSceneValue 发布由窗口持有的模型,并在 Commands 实现中通过 @FocusedValue 使用它。其示例会在没有聚焦模型时禁用命令。对于目标随活动场景变化的操作,这是受支持的处理方式。Building and customizing the menu bar with SwiftUI。

不要从该命令示例推断:打开 MenuBarExtra 后,它就能可靠地引用此前活动的编辑器。本文的面板有意操作 App 级状态。如果面板必须针对某个编辑器,请添加显式的目标选择设计,例如维护 App 持有的编辑器标识符列表,而不要假定应该由哪个窗口接收操作。

仅在编辑器需要时才添加 AppKit 或 TextKit

此标签编辑器只需要 SwiftUI 控件。如果需求扩展到自定义 AppKit 文本视图,Apple 支持使用 NSViewRepresentable 在 SwiftUI 层级中创建、更新和拆除 NSView。Apple 还说明可以使用协调器,将代理和 target-action 消息转发到 SwiftUI。NSViewRepresentable。

添加桥接时保留既定的所有权方案:让每个编辑器的视图实例和编辑状态归属于其窗口,仅在需要共享时才通过 App 模型传递共享内容。协调器应通过绑定或模型操作转发编辑。作为实现上的防护措施,在替换文本视图内容前先比较传入内容,避免无关的共享更新反复重置正在进行的编辑。

对于 TextKit 2 实现,Apple 指出 textLayoutManager 是现代的布局引擎访问入口。Apple 的 TextKit 说明指出,在 TextKit 2 文本视图上访问旧版 layoutManager 可能触发兼容模式,将该视图切换到 TextKit 1,且不会自动切回。不要仅仅为了查看文本视图而使用这个旧属性。TextKit、What’s new in TextKit and text views。

如果必须支持 macOS 13,保留相同的所有权安排,但使用旧的观察方式:ObservableObject 搭配 @Published、用于持有对象的 @StateObject、.environmentObject,以及用于接收对象的 @EnvironmentObject 属性。Apple 的迁移指南说明了这些机制与新版 Observation 方案的对应关系。更换观察系统时,仍应保留预期的共享边界。Migrating from the Observable Object protocol to the Observable macro。

根据用户对界面的预期来决定所有权:共享标签由 App 持有一个实例,每个尚未完成的草稿由各自窗口持有,并通过显式操作连接二者。每当菜单栏面板或编辑器扩展时,都使用双窗口操作序列进行回归检查。

来源