macOS 的状态栏应用看似轻量,真正实现时却有不少细节会直接决定它是否稳定。创建一个常驻顶部的菜单栏图标,核心依赖 AppKit 里的 NSStatusBar 和 NSStatusItem:前者代表系统菜单栏,后者是应用程序在菜单栏中占用的一个位置。只要掌握了这两个类的引用关系和菜单绑定方式,就能完成一个最小可用的状态栏工具。

第一步是向系统菜单栏申请一个状态项,并让它在应用运行期间始终保持有效。很多示例会把状态项写在局部变量里,导致方法返回后引用被释放,图标一闪而过甚至完全不显示。正确做法是把 NSStatusItem 提升为 AppDelegate 的实例属性,由应用生命周期持有。
一、创建菜单栏常驻图标:NSStatusBar 与 NSStatusItem
NSStatusBar.system 返回系统菜单栏的单例对象,调用 statusItem(withLength:) 即可创建一个状态项。长度参数可以传入 NSStatusItem.squareLength 来让系统根据当前菜单栏高度自动计算正方形尺寸,也可以传入 NSStatusItem.variableLength 配合文字标题动态伸缩。下面是一个典型初始化代码。
import Cocoa
@main
final class AppDelegate: NSObject, NSApplicationDelegate {
private var statusItem: NSStatusItem?
private var menu: NSMenu?
func applicationDidFinishLaunching(_ notification: Notification) {
statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.squareLength)
if let button = statusItem?.button {
button.image = NSImage(systemSymbolName: "menubar.arrow.up.rectangle",
accessibilityDescription: "菜单栏工具")
button.image?.isTemplate = true
}
buildMenu()
}
}
这里将 statusItem 声明为属性,并且使用可选类型初始化后再赋值。之所以不能只靠局部变量,是因为系统菜单栏不会自动复制状态项,NSStatusItem 对象一旦被释放,对应图标也会消失。如果使用 Swift 的 @main 启动入口,AppDelegate 本身会被应用持有,因此把状态项放在 AppDelegate 中是最直接的方式。
图标的显示还涉及一个容易忽略的点:菜单栏图标需要适配深色模式。将 NSImage 的 isTemplate 设为 true 后,系统会只使用图片的 alpha 通道,并根据菜单栏当前明暗状态自动着色。否则在深色菜单栏下,深色线条图标可能完全看不见。对于没有系统符号可用的情况,建议准备 PDF 矢量资源或 1x/2x 的 PNG,并勾选 Assets 中的 Template 渲染模式。
二、下拉菜单构建与交互绑定
状态项本身只负责占据菜单栏位置,用户点击后要展示什么样的交互,主要取决于 NSMenu 的配置。最简单的做法是构建好菜单后,直接赋值给 statusItem.menu。系统会在用户左键点击状态项按钮时自动弹出该菜单,不需要再手动处理鼠标事件。
private func buildMenu() {
let mainMenu = NSMenu()
let openItem = NSMenuItem(title: "打开主面板",
action: #selector(openMainWindow),
keyEquivalent: "O")
openItem.target = self
mainMenu.addItem(openItem)
mainMenu.addItem(NSMenuItem.separator())
let quitItem = NSMenuItem(title: "退出",
action: #selector(NSApplication.terminate(_:)),
keyEquivalent: "Q")
quitItem.target = NSApp
mainMenu.addItem(quitItem)
menu = mainMenu
statusItem?.menu = mainMenu
}
@objc private func openMainWindow() {
NSApp.setActivationPolicy(.regular)
NSApp.activate(ignoringOtherApps: true)
}
上面的代码创建了两个菜单项和一个分隔线。退出项直接把 target 设置为 NSApp,复用系统已有的 terminate(_:) 方法;打开项则使用自定义的 openMainWindow,在需要展示窗口时把应用从辅助模式切换回普通模式,这样窗口可以正常前置并被用户操作。
如果希望左键单击、右键单击或按住 Option 键时出现不同菜单,直接使用 statusItem.menu 就不够灵活了。此时可以清空 statusItem.menu,改为给按钮设置 action,并在 action 中调用 popUpMenu 或手动切换不同菜单。这种方案还能在弹出菜单前动态插入最近使用项目、播放状态等内容。
三、去除 Dock 图标并保持后台常驻
菜单栏工具通常希望只出现在顶部状态栏,不占用 Dock 空间。macOS 提供了辅助应用机制来实现这一点。在 AppDelegate 启动阶段调用 NSApp.setActivationPolicy(.accessory),应用就会从常规模式切换为辅助模式,Dock 图标消失,同时主窗口也不会被强制展示。
func applicationDidFinishLaunching(_ notification: Notification) {
NSApp.setActivationPolicy(.accessory)
// 状态项初始化代码
}
另一种等价方式是在 Info.plist 中增加 LSUIElement 键并设为 true。两种方式选其一即可,但如果应用后续需要打开设置窗口或主界面,可以在 openMainWindow 中临时切换回 .regular,关闭窗口后再切回 .accessory。这个切换过程不会影响菜单栏图标的显示,只会控制 Dock 图标和窗口激活策略。
菜单栏应用还要考虑退出路径。如果用户关闭了所有窗口,应用是否继续运行?上面的实现默认一直驻留,除非用户点击退出菜单项。这种设计适合剪贴板管理、网络监控、番茄钟等需要长时间在后台的工具。如果业务上要求关闭窗口即退出,可以额外实现 applicationShouldTerminateAfterLastWindowClosed,但在菜单栏工具中一般返回 false,保持在后台运行。
四、模板图标、动态标题与 Popover 扩展
菜单栏图标不是设置一次就万事大吉。对于会变化的状态,比如当前网速、未读数量或同步进度,更适合使用文字标题。可以通过 statusItem.button?.title 更新文字,并在创建状态项时传入 NSStatusItem.variableLength,让菜单栏根据文本宽度自动调整占用空间。
func updateCount(_ count: Int) {
guard let button = statusItem?.button else { return }
if count == 0 {
button.title = ""
button.image = NSImage(systemSymbolName: "tray",
accessibilityDescription: "无新消息")
button.image?.isTemplate = true
} else {
button.image = nil
button.title = "\(count)"
button.font = NSFont.menuBarFont(ofSize: 0)
}
}
如果需要展示更丰富的内容,下拉菜单的体积就成了限制。此时可以使用 NSPopover 代替 NSMenu:点击状态项按钮后弹出浮层,里面放置 NSViewController 管理的完整视图。这种交互常见于天气、日历和系统监控类应用。实现时仍然要把 NSPopover 存为属性,并在点击按钮时调用 show(relativeTo:of:preferredEdge:),同时处理屏幕边界和用户点击外部区域的自动关闭。
无论是菜单还是 Popover,本质上都是给 NSStatusItem 增加一个可交互的界面。保持状态项、菜单或 Popover 的强引用,设置 isTemplate,并选择合适的激活策略,这几个点组合起来就能形成一个稳定、符合 macOS 使用习惯的常驻菜单栏应用。
macOS Status BarNSStatusItemSwift AppKit修改时间:2026-09-24 01:37:03