简介:这是一份面向SwiftUI与macOS开发者的CommandMenu源码示例,围绕“如何设置菜单工具栏”这一主题,展示了命令在不同平台上的差异化实现。示例通过主菜单、命令菜单与命令组的组织方式,说明如何在macOS顶部菜单栏中创建顶层菜单项,并用分隔符菜单项划分各命令分组,适合正在学习菜单栏开发或需要快速上手CommandMenu用法的读者。压缩包仅29KB,共14个文件,以Swift源码、plist配置、JSON数据及Xcode工程文件为主:Swift源文件承载菜单与界面逻辑,plist/json用于应用配置与资源定义,工程文件则保证可直接在Xcode中打开运行。目前已有246人学习下载,借助该工程可以理解CommandMenu与菜单栏结构的构建流程,也可以在此基础上修改命令组、验证工具栏与命令联动逻辑,是快速掌握macOS原生菜单开发的实用素材。
1. CommandMenu源码解决的是菜单栏应用里最烦的样板代码
上周有个朋友问我,他写的macOS菜单栏工具在启动后图标一闪就消失,查了半天是StatusItem被局部变量释放了。这种问题我踩过太多次,后来拿到CommandMenu这类源码当骨架,才算把菜单栏开发的混乱局面收拾干净。这个方案的本质很简单:把菜单栏应用里所有可点击的东西抽象成一条条命令,用一个模型数组统一维护,再递归生成NSMenu,挂在NSStatusItem上。它解决的是写菜单逻辑时最讨厌的样板代码问题——你不用每次新建菜单项都手写target-action,也不用为了加一个功能翻三个文件。
适合读这篇的人很清楚:想用Swift写一个常驻菜单栏的小工具,或者想把一个已有App改成菜单栏模式,又不想被NSMenu和NSEvent的回调绕晕。新手能照着把工程跑起来,熟手能直接拿这套结构改业务。下面我从源码结构、跑通最小工程、改造业务到踩坑排查,按我实际的开发顺序讲。
2. 先看懂CommandMenu的三个核心类:命令模型、构建器与状态栏管理
我接触过的CommandMenu类源码,通常都会拆成三个独立部分:命令模型、菜单构建器、状态栏管理器。我能理解很多人拿到源码第一件事就是找main函数,但这类东西没有main,入口藏在AppDelegate里。先把这三个类的关系搞清楚,后面改起来才不会抓瞎。
2.1 命令模型:为什么用闭包而不是target-action
最底层的设计决策是命令怎么表示。传统的AppKit写法是NSMenuItem加target和action,菜单项多了之后,每个action方法散落在各个类里,加一个功能要改好几个地方。CommandMenu的做法反过来了,用一个结构体把菜单项的标题、图标、动作放一起:
// Command.swift —— 把一条菜单动作收敛成一条命令 struct Command { let id: String // 点击后用来判断是哪条命令的唯一标识 let title: String // 菜单项显示的文字 let icon: String // SF Symbols 图标名,比如 "clock" let action: (() -> Void)? // 点击菜单项后要执行的闭包 let children: [Command]? // 有值就渲染成子菜单,没有就是普通菜单项 }这个模型里最值得琢磨的是action用了闭包而不是Selector。好处是命令的定义和执行逻辑写在一起,想加一个菜单项只需要往数组里追加一个Command,不用再去别处补一个方法。代价是闭包会捕获上下文,后面遇到循环引用或崩溃,多半要从这里查起。
实际使用中我给每个命令都写id,哪怕暂时用不上。因为菜单构建器在生成NSMenuItem时,不能把Swift闭包直接塞给target-action机制,需要一个中介对象来转发。这时id就是转发用的钥匙。
2.2 菜单构建器:把命令数组递归成NSMenu
命令模型定义好了,接下来要把Command数组转换成真实的NSMenu。这部分通常是一个独立方法或类,核心就是遍历命令数组,逐个生成NSMenuItem,遇到带children的命令就递归生成子菜单:
// MenuBuilder.swift —— 递归把 Command 转成 NSMenu final class MenuBuilder { static func build(from commands: [Command]) -> NSMenu { let menu = NSMenu() for command in commands { // 先建一个空壳菜单项,action 统一指向同一个转发方法 let item = NSMenuItem( title: command.title, action: #selector(CommandTarget.trigger(_:)), keyEquivalent: "" ) // 用 representedObject 带上命令 id,触发时再取出来派发 item.representedObject = command.id item.image = NSImage(systemSymbolName: command.icon, accessibilityDescription: nil) // 有子命令就递归生成子菜单 if let children = command.children, !children.isEmpty { item.submenu = build(from: children) } menu.addItem(item) } return menu } }这里最关键的一行是item.representedObject = command.id。AppKit的菜单系统要求菜单项必须有target和action才能点击,但我们可以让所有菜单项共用同一个target和同一个action方法,靠representedObject来区分到底点了谁。这样看似多了一步,实际省掉了几十个action方法的定义。
CommandTarget这个中介类负责接收菜单项的事件,取出id,再去CommandStore里找到对应的闭包执行。它内部维护一个字典,key是命令id,value是闭包。这样命令的注册和触发就解耦了。
2.3 StatusItem管理:图标、点击与生命周期
第三个核心类是状态栏管理,它负责创建NSStatusItem、设置图标、挂上菜单。这里有个新手最容易踩的坑:NSStatusItem不能放在局部变量里,一定要被强持有,否则创建完就被释放,图标一闪就消失。
// StatusItemManager.swift —— 负责状态栏图标的整个生命周期 final class StatusItemManager { private var statusItem: NSStatusItem? func install() { // variableLength 让图标宽度自适应,不要用 squareLength statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) statusItem?.button?.image = NSImage(systemSymbolName: "command", accessibilityDescription: "主菜单") statusItem?.button?.image?.isTemplate = true statusItem?.menu = MenuBuilder.build(from: CommandStore.shared.commands) } }关于withLength参数,我一般用NSStatusItem.variableLength。它会让图标按实际内容自适应宽度;如果强制squareLength,遇到文字图标会挤成一团。isTemplate = true很重要,系统会用这个标记自动适配浅色和深色模式下的图标颜色,后面避坑章节还会细说。
整个数据流是:CommandStore保存命令数组,MenuBuilder生成NSMenu,StatusItemManager挂到状态栏上。三者单向依赖,修改业务只需要动CommandStore,其他两个类基本不用碰。这就是CommandMenu这类源码最值钱的地方。
3. 把CommandMenu源码跑起来:最小Xcode工程与LSUIElement配置
理论清楚了,接下来就动手。这一章的目标是让读者拿到一份CommandMenu源码后,能在10分钟内看到菜单栏出现自己的图标。这里给的是我多次验证过的最小步骤,任何一步跳过都可能白忙活。
3.1 最小工程:新建Xcode项目与源码文件引入
先建一个macOS App项目,Interface选SwiftUI,语言选Swift,不需要勾选Core Data之类的附加能力。项目建好之后,把CommandMenu的核心文件——通常是Command.swift、MenuBuilder.swift、CommandStore.swift、StatusItemManager.swift这几个——直接拖进工程。
拖入时Xcode会弹出Choose options for adding these files对话框,这里有一个容易忽略的点:一定要勾选Copy items if needed,并且Target Membership里勾选当前App的target。如果不勾Copy,文件会以引用形式存在,源码文件一旦移动位置,下次编译直接报文件找不到;这个坑我栽过两次。
3.2 在App启动流程里初始化StatusItem与菜单
SwiftUI项目的入口通常是@main修饰的App结构体,但状态栏初始化要放在AppDelegate的applicationDidFinishLaunching里,时机最稳妥。用@NSApplicationDelegateAdaptor把AppDelegate接进来:
import SwiftUI @main struct CommandMenuApp: App { @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate var body: some Scene { Settings { // 设置面板先留着,后面挂SwiftUI视图用 EmptyView() } } }注意这里不能用WindowGroup,而是用Settings。原因很简单:CommandMenu做的应用是菜单栏工具,不应该在启动时打开主窗口。如果用了WindowGroup,App一启动就会弹出一个空窗口,还得手动关掉,体验很差。Settings场景不会主动创建窗口,正好符合菜单栏应用的预期。
AppDelegate里的初始化代码:
final class AppDelegate: NSObject, NSApplicationDelegate { private var statusItemManager: StatusItemManager? func applicationDidFinishLaunching(_ notification: Notification) { // 先注册业务命令,再安装状态栏 registerCommands() statusItemManager = StatusItemManager() statusItemManager?.install() } private func registerCommands() { CommandStore.shared.register( Command(id: "quit", title: "退出", icon: "power") { NSApp.terminate(nil) } ) } }statusItemManager必须声明为属性而不是局部变量,原因前面提过:局部变量在方法返回后就被释放,StatusItem相关联的菜单也会失效。这一步只要写成let manager = StatusItemManager()然后manager.install(),图标就会在启动后瞬间消失,这是CommandMenu跑不起来最常见的两个原因之一。
3.3 Info.plist的LSUIElement:菜单栏应用和普通App的分界线
很多人到这里发现:图标出现了,但Dock栏也有一个图标,切到其他App时Dock图标还在那占地方。这就是缺少关键配置:LSUIElement。
打开Info.plist,添加一个键叫Application is agent (UIElement),对应实际键名LSUIElement,设为YES。这会让App变成一个纯粹的Agent应用:不显示在Dock栏,不参与Cmd+Tab切换,只在菜单栏保留图标。
Info.plist 关键配置 键名 类型 值 Application is agent Boolean YESSetting这个键之后,重启App,Dock栏的图标就会消失。注意这里有一个小陷阱:修改Info.plist后要完全退出App再重新运行,有时候Xcode的增量编译不会重新读取plist,直接Run会出现配置不生效的假象。
提示:如果LSUIElement设为YES,App的窗口默认不获得焦点。后面挂设置面板时,要手动用
NSApp.activate把App激活到前台,否则弹出来的窗体会出现点不动、键盘不响应的怪异表现。
到这里,最小工程就跑通了:菜单栏出现图标,点击图标能看到一个退出菜单项。接下来才是真正有价值的改造。
4. 把CommandMenu改造成自己的工具:动态菜单、子菜单与设置面板
跑通之后,读者一定想把默认菜单换成自己的业务逻辑。这一章的处理方式是我在实际项目里沉淀出来的:先写静态命令,再做子菜单嵌套,最后上动态菜单和设置面板。难度逐级递增,但每一步都是独立的。
4.1 注册业务命令:几个高频菜单项的写法
先看注册普通菜单项的常见做法。假设你要做一个监控剪贴板的小工具,菜单里需要“手动复制当前条目”“清空历史”“打开历史文件夹”三个动作:
// 在 registerCommands 里追加业务命令 CommandStore.shared.register( Command(id: "copy-current", title: "复制当前条目", icon: "doc.on.doc") { ClipboardHistory.shared.copyCurrentItem() } ) CommandStore.shared.register( Command(id: "clear-history", title: "清空历史记录", icon: "trash") { ClipboardHistory.shared.clear() } ) CommandStore.shared.register( Command(id: "open-folder", title: "打开历史文件夹", icon: "folder") { let url = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first! NSWorkspace.shared.open(url) } )这里建议留意闭包执行时机。CommandStore把action闭包存起来,只在点击菜单后才触发。如果闭包内部用到了App的某些单例,要确保那些单例在菜单点击时还活着,不要在闭包里创建一次性对象然后立刻释放。
Command的icon字段用的是SF Symbols名字。用系统symbol的好处是自带深浅色适配,不需要额外准备两套图片。写错图标名不会崩,只是不显示图标,排查时可先看系统符号名是否正确。
4.2 子菜单嵌套:二级菜单、三级菜单的递归行为
菜单项多了之后,平铺会变得很长,一眼扫不完。CommandMenu的children字段就是为这个准备的。它天然支持任意层级的嵌套,因为MenuBuilder里用了递归。
以多看阅读类的菜单栏工具为例,把“导出”做成子菜单,下面挂不同格式:
let exportMenu = Command( id: "export", title: "导出笔记", icon: "square.and.arrow.up", action: nil, // 父菜单项不需要 action children: [ Command(id: "export-markdown", title: "导出为 Markdown", icon: "doc.text") { ExportService.shared.exportAsMarkdown() }, Command(id: "export-html", title: "导出为 HTML", icon: "doc.richtext") { ExportService.shared.exportAsHTML() } ] ) CommandStore.shared.register(exportMenu)这里给父命令的action传nil,表示点击“导出笔记”本身不做任何事,只展开子菜单。AppKit对带submenu的菜单项有个默认行为:点击父菜单项只显示子菜单,不会触发action,所以action传nil是安全的。
嵌套层级很深时,唯一的注意点是菜单构建的递归深度。实测三级以内的菜单没有任何问题,但超过四级会让用户在视觉上很难追踪鼠标路径,建议超过三级就考虑用扁平化设计。这不是技术限制,是交互习惯问题。
4.3 动态菜单:menuNeedsUpdate与勾选状态同步
静态菜单适合命令集合固定的工具,但大多数菜单栏应用需要根据运行状态动态调整菜单项。比如监控工具要随时显示当前状态,菜单项需要根据状态变化实时增删。这时候不能一次构建菜单,要用NSMenuDelegate的menuNeedsUpdate方法:
// 动态菜单代理:每次菜单点开前重建内容 final class DynamicMenuDelegate: NSObject, NSMenuDelegate { func menuNeedsUpdate(_ menu: NSMenu) { // 清掉旧菜单项,完全按当前状态重建 menu.removeAllItems() let status = MonitorService.shared.currentStatus() // 根据状态插入不同的菜单项 let stateItem = NSMenuItem(title: "状态:\(status)", action: nil, keyEquivalent: "") menu.addItem(stateItem) if status == .running { let pauseItem = NSMenuItem(title: "暂停监控", action: #selector(CommandTarget.trigger(_:)), keyEquivalent: "p") pauseItem.representedObject = "toggle-monitor" menu.addItem(pauseItem) } menu.addItem(.separator()) // 最后加一个退出项 } }menuNeedsUpdate的好处是菜单只在被点击时才计算,不会在后台频繁刷新。缺点是你需要手动管理清空和重建逻辑,漏掉removeAllItems会导致菜单项无限累积。
勾选状态同步也用类似思路。在重建菜单时,判断当前状态然后设置item.state = .on或.off:
item.state = MonitorService.shared.isPaused ? .on : .off有一个真实经验:不要试图在菜单已经显示之后再改菜单项的state。AppKit在菜单显示期间对菜单项的更新并不总是立即生效,偶尔会出现勾选标记不刷新的情况。可靠做法是在menuNeedsUpdate里一次性算好所有状态,菜单显示时整个重建。
4.4 设置面板:把SwiftUI视图挂进菜单的两种方式
CommandMenu要做成实用工具,通常需要一个设置界面。因为是用SwiftUI建的入口,最自然的做法是用NSHostingController把SwiftUI视图包起来,再放进NSPopover里从状态栏弹出来。
// 从状态栏按钮下方弹出 SwiftUI 设置面板 let popover = NSPopover() popover.contentViewController = NSHostingController( rootView: SettingsView() ) popover.behavior = .transient // 点击其他区域自动关闭 popover.show(relativeTo: statusItem.button!.bounds, of: statusItem.button!, preferredEdge: .minY)preferredEdge: .minY表示弹出位置在状态栏图标的下方。behavior = .transient表示用户点击菜单栏外部区域时自动关闭,这是菜单栏工具该有的交互。
这里有个容易忽略的点:菜单栏App因为LSUIElement=YES,App处于非激活状态,NSPopover弹出后可能无法正常响应键盘输入。解决办法是弹出前激活App:
NSApp.activate(ignoringOtherApps: true)如果不加这行,文本框点进去了但光标不闪,slider能拖动但键盘输入全部失灵,非常诡异,排查起来还很难想到是激活状态的问题。
5. CommandMenu常见问题与避坑:菜单栏不显示、图标发黑、点击崩溃的排查顺序
方案讲完,说说踩坑。这一章整理了开发CommandMenu类源码时最高频的五类问题,按排查顺序排列。我按“现象→原因→解决”的套路写,读者可以直接对照排查。
5.1 菜单栏图标不出现或一闪而过
现象:App运行后菜单栏没有任何图标,或者图标闪现一下立刻消失。查Dock栏发现App也没运行。
原因分两类:第一类是StatusItemManager被局部变量持有,方法返回后对象被释放;第二类是Info.plist里没设置LSUIElement,App可能确实启动了但以普通App方式运行,菜单栏图标压根没创建。
解决:先确认statusItemManager是AppDelegate的属性,而不是局部变量。再看applicationDidFinishLaunching有没有被调用,可以在方法里加一行print("did finish launch")确认启动流程。最后检查Info.plist的LSUIElement是否为YES。按这个顺序排查,大部分问题都在第一步解决。
5.2 图标在暗色模式下糊成一团
现象:菜单栏图标在浅色模式下正常,切到深色模式后变成一团黑色方块,或者边缘有白边。
原因:给NSImage设置了彩色图片,但没有开启template渲染模式。系统不知道这张图需要根据菜单栏颜色动态调整。
解决:在设置图片后加上image.isTemplate = true,让系统把图片当成模板图处理,自动适配当前菜单栏的深浅色。如果用了SF Symbols,还要确保创建图片时用的是NSImage(systemSymbolName:accessibilityDescription:)而不是从asset catalog加载的彩色图片。isTemplate = true这一行是CommandMenu菜单栏图标最容易漏的配置。
5.3 菜单项全是灰色点不动
现象:菜单能打开,菜单项文字能看到,但全部呈灰色,鼠标点击没有任何响应。
原因:NSMenu有一个很隐蔽的行为——当菜单项的target还没有确定时,系统默认把它视为无效并禁用。在CommandMenu的构建器里,如果你给菜单项设置了action但target是nil,就会触发这个机制。
解决:第一种办法是给所有菜单项统一设置target为CommandTarget的单例,这本来是CommandMenu的设计,但如果构建器漏写了就会踩坑。第二种办法是在菜单构建完成后设置menu.autoenablesItems = false。我建议两条都做:autoenablesItems关掉,同时确保target不为nil。只关autoenablesItems,菜单项可点了,但点击事件没人接收,问题会从“点不动”变成“点了没反应”。
5.4 点击菜单项秒退
现象:点击某个菜单项,App直接崩溃退出,控制台能看到EXC_BAD_ACCESS。
原因:命令闭包里捕获了已释放的对象,或者闭包形成了循环引用导致context在错误时机被释放。最常见的是在闭包里写了self.someMethod(),而self是一个已经被释放的ViewController。
解决:闭包内部使用弱引用捕获。如果CommandModel的闭包定义是@escaping (() -> Void)?,那么闭包里访问外部对象时,要用[weak self]的写法。如果CommandStore内部保存了所有命令闭包,还要注意不要把一个持有CommandStore的对象再捕获进闭包,极易循环引用。排查时用Xcode的Memory Graph Debugger看有没有循环,比人肉盯代码快得多。
5.5 下拉菜单偶尔空白
现象:菜单刚弹出来时是空白的,过一两秒才显示内容,或者有时完全空白只能收起再打开。
原因:在后台线程更新了菜单数据。AppKit的NSMenu不是线程安全的,在后台线程removeAllItems或addItem会造成竞态条件,轻则显示异常,重则崩溃。
解决:所有菜单构建和更新操作强制放到主线程执行。在menuNeedsUpdate里直接用DispatchQueue.main.async包一层,或者依赖CommandStore在注册命令时保证主线程调用。这个问题的诡异之处在于它不固定复现,只有当后台线程恰好和主线程竞争时才出问题,属于典型的玄学崩溃。定位到原因后,根治方法很简单——凡是碰菜单的代码,一律主线程。
6. 再进一步:给CommandMenu加全局热键与开机自启
菜单栏工具做到能用了,接下来两个高频需求是全局热键和开机自启。我给CommandMenu加上这两个功能的实现思路,都是真实项目里打磨过的写法。
6.1 全局热键弹出菜单
有些操作不想点图标,想按快捷键直接弹出菜单。用全局事件监听器:
// 全局监听 Command+空格 弹出菜单 NSEvent.addGlobalMonitorForEvents(matching: .keyDown) { event in let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) if event.keyCode == 49 && flags.contains(.command) { DispatchQueue.main.async { // 弹出菜单或执行默认命令 CommandStore.shared.execute(id: "default-action") } } }事件的回调在全局监听时不一定在主线程,所以要包装一层DispatchQueue.main.async,否则后面改动UI会触发崩溃。
这个功能有一个门槛:全局快捷键监听需要辅助功能权限。首次运行时,系统设置里要手动打开“辅助功能”给当前App授权。没有这个权限,监听器静默失效,连报错都没有,排查起来很费劲。
6.2 开机自启的两种写法
菜单栏工具最大的意义就是开机就在,开机自启几乎是刚需。macOS 13及以上版本推荐用SMAppService,这是系统提供的正规登录项API:
import ServiceManagement // 注册当前 App 为登录项 if #available(macOS 13.0, *) { do { try SMAppService.mainApp.register() } catch { // 注册失败会在沙盒或未签名情况下出现 print("SMAppService register failed: \(error)") } }旧版本系统仍然只能用SMLoginItemSetEnabled,它的参数是一个helper bundle的标识,需要额外配置。有条件的话建议把最低系统版本直接定到macOS 13以上,可以省掉旧API的一堆麻烦。
最后说一个我自己的习惯:每次拿到一份CommandMenu式的源码,我第一件事不是看它的示例命令,而是先把CommandStore、MenuBuilder、StatusItemManager三个文件通读一遍,确认它的闭包是否逃逸、target设置是否完整、状态栏引用是否强持有。这三个地方没问题,剩下的都是业务。这种骨架类源码的价值恰恰就在这里:菜单逻辑被收敛到几个固定位置,调试时不用满项目翻action方法。不过也别指望它解决一切问题,比如NSPopover在某些多显示器布局下的定位偏移、菜单栏图标在系统主题切换时的响应延迟,这些还是得自己处理。希望帮到你。
本文还有配套的精品资源,点击获取