ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

用CommandMenu源码打造macOS菜单栏工具:从NSStatusItem到动态菜单的完整实践

用CommandMenu源码打造macOS菜单栏工具:从NSStatusItem到动态菜单的完整实践 简介这是一份面向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是命令idvalue是闭包。这样命令的注册和触发就解耦了。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生成NSMenuStatusItemManager挂到状态栏上。三者单向依赖修改业务只需要动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做的应用是菜单栏工具不应该在启动时打开主窗口。如果用了WindowGroupApp一启动就会弹出一个空窗口还得手动关掉体验很差。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栏不参与CmdTab切换只在菜单栏保留图标。Info.plist 关键配置 键名 类型 值 Application is agent Boolean YESSetting这个键之后重启AppDock栏的图标就会消失。注意这里有一个小陷阱修改Info.plist后要完全退出App再重新运行有时候Xcode的增量编译不会重新读取plist直接Run会出现配置不生效的假象。提示如果LSUIElement设为YESApp的窗口默认不获得焦点。后面挂设置面板时要手动用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或.offitem.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因为LSUIElementYESApp处于非激活状态NSPopover弹出后可能无法正常响应键盘输入。解决办法是弹出前激活AppNSApp.activate(ignoringOtherApps: true)如果不加这行文本框点进去了但光标不闪slider能拖动但键盘输入全部失灵非常诡异排查起来还很难想到是激活状态的问题。5. CommandMenu常见问题与避坑菜单栏不显示、图标发黑、点击崩溃的排查顺序方案讲完说说踩坑。这一章整理了开发CommandMenu类源码时最高频的五类问题按排查顺序排列。我按“现象→原因→解决”的套路写读者可以直接对照排查。5.1 菜单栏图标不出现或一闪而过现象App运行后菜单栏没有任何图标或者图标闪现一下立刻消失。查Dock栏发现App也没运行。原因分两类第一类是StatusItemManager被局部变量持有方法返回后对象被释放第二类是Info.plist里没设置LSUIElementApp可能确实启动了但以普通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这是系统提供的正规登录项APIimport 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在某些多显示器布局下的定位偏移、菜单栏图标在系统主题切换时的响应延迟这些还是得自己处理。希望帮到你。本文还有配套的精品资源点击获取
返回列表