桌面端与移动端的差异

把 iPhone 上的 SwiftUI 代码直接搬到 Mac,通常会得到「能跑但不像 Mac 应用」的结果。差异集中在四件事上:窗口可以被自由缩放与并列排布、菜单栏是一等公民、指针交互要求更精细的悬停反馈、以及用户对键盘快捷键有很强的预期。

Mac 用户对「像不像原生应用」的敏感度远高于手机用户:菜单、快捷键、窗口恢复这些细节,直接决定应用是否被接受。
必须处理好的四件事
  • 窗口支持任意尺寸,布局用自适应而非固定比例。
  • 菜单常用操作同时出现在菜单与工具栏,并绑定快捷键。
  • 悬停用 .onHover 给可点击元素明确的视觉反馈。
  • 恢复关掉再打开时回到原位,尊重用户的窗口习惯。
macOS 桌面应用开发配图:抽象显示器屏幕内的窗口面板与应用图标
一块屏幕上并列多个窗口是常态,也是布局必须自适应的根本原因。

应用入口与 Scene 组织

macOS 应用从 App 协议开始,body 返回一个或多个 Scene。常见的组合是:WindowGroup 提供主窗口,Settings 提供偏好设置窗口,MenuBarExtra 提供状态栏入口。三者并列声明,框架会自动把它们接进菜单栏与 Dock,不需要手动管理生命周期。

@main
struct NotesApp: App {
    @State private var store = NoteStore()

    var body: some Scene {
        WindowGroup {
            NoteListView().environment(store)
        }
        .defaultSize(width: 900, height: 620)

        Settings { SettingsView().environment(store) }

        MenuBarExtra("快速记录", systemImage: "square.and.pencil") {
            QuickCaptureView().environment(store)
        }
    }
}

defaultSize 只影响首次打开的尺寸,不要指望它锁定窗口大小。真正的适配责任在布局本身——按比例分配空间,避免写死像素宽度,才能让用户在任意窗口尺寸下都获得可用的界面。

多窗口与窗口状态管理

当用户希望「同时打开两条笔记」时,需要用带值的 WindowGroup,让每个窗口对应一个数据标识;打开新窗口通过环境里的 openWindow 动作完成,这在工具栏按钮和菜单项里都很常用。

struct NoteListView: View {
    @Environment(\.openWindow) private var openWindow
    @Environment(NoteStore.self) private var store

    var body: some View {
        List(store.notes) { note in
            HStack {
                Text(note.title)
                Spacer()
                Button { openWindow(value: note.id) } label: {
                    Image(systemName: "macwindow.badge.plus")
                }
                .buttonStyle(.borderless)
                .help("在新窗口打开")
            }
        }
    }
}

窗口尺寸与位置由系统负责恢复,前提是窗口内容能表达自己的最小可用尺寸。给主内容加 frame(minWidth: 520, minHeight: 360) 是个稳妥的起点,能避免用户把窗口拖到无法使用的尺寸后无从恢复;多显示器环境下还要考虑窗口被拖到不同屏幕时的重排行为,布局不能假设屏幕宽度固定,否则外接显示器上会出现内容被压扁或被裁掉的情况。

菜单、命令与快捷键

桌面应用的功能应当能通过菜单栏触达。SwiftUI 用 Commands 在标准菜单(文件、编辑、显示)里插入自定义项,用 .keyboardShortcut 绑定快捷键。注意不要占用系统已在使用的组合键,比如 ⌘Q 与 ⌘W,那会直接抢走用户的操作习惯。

struct NoteCommands: Commands {
    let store: NoteStore

    var body: some Commands {
        CommandGroup(after: .newItem) {
            Button("新建笔记") { store.createNote() }
                .keyboardShortcut("n", modifiers: [.command, .shift])
        }
        CommandMenu("整理") {
            Button("按标题排序") { store.sortByTitle() }
                .keyboardShortcut("t", modifiers: [.command, .option])
        }
    }
}

把同一个操作同时放到工具栏按钮、菜单项和快捷键上,是 Mac 应用的通行做法;三处共用同一个模型方法,行为就不会出现不一致,用户用哪种方式触发都是同样的结果。

沙盒、权限与分发方式

macOS 应用默认运行在 App Sandbox 中,只能访问用户明确授权的目录。读写用户选择的文件要用 NSOpenPanel 或者安全书签(security-scoped bookmark)来保留访问权限,直接在代码里拼绝对路径在沙盒下必然失败,这也是从命令行工具迁移过来的代码最常见的报错来源。

三条分发路径
  • Mac App Store必须开启沙盒,审核规则与 iOS 类似,自动更新由商店负责。
  • 独立分发可以不沙盒,但需要开发者 ID 签名并通过公证,否则会被 Gatekeeper 拦下。
  • 企业内部分发适合内部工具,配企业证书与 MDM 部署。

走独立分发时,流程是:用开发者 ID 证书签名 → 用 notarytool 提交公证 → 将公证票据装订到应用包 → 打成 DMG 分发。少任何一步,用户首次打开都会看到「无法验证开发者」的提示,而这类体验问题很难靠文案弥补。