菜单栏应用是macOS平台上最具特色的应用形态之一,诸如iStat Menus、Bartender等知名工具都常驻于此。相比普通窗口应用,菜单栏应用占用屏幕空间极小,又能随时快速交互,非常适合做成状态监控、快捷工具类产品。本文将以Swift语言为基础,使用原生的AppKit框架,从零实现一个菜单栏应用,并在其基础上实现深色模式切换事件的监听,让应用界面能够随系统外观自动变化。

一、构建菜单栏应用的基础:NSStatusItem
AppKit中负责菜单栏图标的类是NSStatusBar和NSStatusItem。NSStatusBar是一个系统级的单例,通过它的statusItem(withLength:)方法可以申请一个菜单栏位置,返回的NSStatusItem对象就是应用在菜单栏中的载体。需要特别注意:必须使用强引用持有这个对象,否则它会被释放,图标随之消失。
NSStatusItem提供了一个NSStatusBarButton类型的button属性,可以像操作普通按钮一样设置标题或图标。图标通常使用NSImage(systemSymbolName:accessibilityDescription:)加载SF Symbols,这是苹果官方提供的矢量图标库,能自动适配深色模式,比自带的PNG资源更省心。下面是一段在AppDelegate中创建菜单栏项的完整示例:
import Cocoa
@main
class AppDelegate: NSObject, NSApplicationDelegate {
// 必须强引用,否则菜单栏图标会被释放
private var statusItem: NSStatusItem!
func applicationDidFinishLaunching(_ notification: Notification) {
// 创建固定长度的状态项
statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.squareLength)
// 设置SF Symbols图标
if let button = statusItem.button {
button.image = NSImage(systemSymbolName: "moon.circle",
accessibilityDescription: "主题指示")
}
buildMenu()
}
private func buildMenu() {
let menu = NSMenu()
menu.addItem(NSMenuItem(title: "关于本应用",
action: #selector(NSApplication.orderFrontStandardAboutPanel(_:)),
keyEquivalent: ""))
menu.addItem(.separator())
menu.addItem(NSMenuItem(title: "退出",
action: #selector(NSApplication.terminate(_:)),
keyEquivalent: "q"))
statusItem.menu = menu
}
}给NSStatusItem设置menu属性后,点击菜单栏图标就会弹出下拉菜单,系统会自动处理高亮和关闭逻辑,无需手动管理。如果不使用菜单而是想监听点击事件,则应保持menu为nil,并通过button.action和button.target绑定回调,这两种交互方式不可混用,设置menu之后action将不再触发。
二、隐藏Dock图标,让应用成为纯菜单栏应用
默认情况下,macOS应用启动后会在Dock中显示图标,但对于菜单栏工具来说这是多余的。隐藏Dock图标最简单的办法是在Info.plist(或Xcode中Target的Information Property List设置)中添加一个键值:Application is agent (UI element),对应的键名为LSUIElement,类型为Boolean,值设为YES。
设置之后应用启动时既不会出现在Dock,也不会出现在应用切换器(Command+Tab)中,只在菜单栏留一个小图标,这正是状态类工具的标准形态。如果希望应用能在前台显示窗口时又出现在Dock,可以不在Info.plist中设置,而是在代码中动态调用:
import AppKit // 恢复为普通应用(显示Dock图标) NSApp.setActivationPolicy(.regular) // 隐藏Dock图标,变成纯菜单栏应用 NSApp.setActivationPolicy(.accessory) // 更彻底:连菜单栏都不参与,仅后台运行 NSApp.setActivationPolicy(.prohibited)
动态切换激活策略在某些需要临时弹出面板的场景很实用,例如点击菜单栏图标后弹出一个NSPopover面板,弹出时切换为regular,关闭时切回accessory。不过要注意频繁切换策略可能导致菜单栏焦点异常,多数情况下直接在Info.plist中配置LSUIElement是最稳妥的做法。
三、监听系统深色模式切换事件的两种方案
macOS从Mojave开始引入系统级深色模式,应用需要能实时感知用户在系统设置中的外观切换并做出响应。判断当前外观的第一步是读取NSApp.appearance或更可靠的NSApp.effectiveAppearance。前者是应用自己设置的外观,后者是实际生效的外观(会回退到系统设置),因此判断深浅色应始终使用effectiveAppearance。
判断的具体做法是调用bestMatch(from:)方法,传入候选外观名称数组,系统会返回匹配度最高的那一个。不要用字符串直接比较name.rawValue是否等于dark,因为macOS还提供了高对比度外观等变体,bestMatch能正确处理这些情况。监听切换事件有两种主流方案,先看第一种,通过KVO监听effectiveAppearance:
import Cocoa
extension NSAppearance.Name {
/// 判断当前生效外观是否为深色
var isDark: Bool {
if let matched = NSApp.effectiveAppearance
.bestMatch(from: [.darkAqua, .aqua]) {
return matched == .darkAqua
}
return false
}
}
class ThemeObserver: NSObject {
private var observation: NSKeyValueObservation?
override init() {
super.init()
// KVO监听整个NSApplication的生效外观
observation = NSApplication.shared.observe(
\.effectiveAppearance,
options: [.new, .initial]
) { [weak self] _, change in
let isDark = change.newValue?.name.isDark ?? false
self?.applyTheme(isDark: isDark)
}
}
private func applyTheme(isDark: Bool) {
print("当前模式:\(isDark ? "深色" : "浅色")")
// 在这里更新菜单栏图标或自定义视图配色
if let button = (NSApp.delegate as? AppDelegate)?.statusItem.button {
button.image = NSImage(
systemSymbolName: isDark ? "moon.circle.fill" : "sun.max.circle",
accessibilityDescription: nil)
}
}
}第二种方案是利用DistributedNotificationCenter监听系统广播的外观切换通知。这种方式更底层,即便应用窗口尚未完全初始化也能收到事件,兼容一些需要跨进程感知外观变化的场景:
import Cocoa
class DistributedThemeObserver {
private let center = DistributedNotificationCenter.default()
func startObserving() {
// 苹果内部通知,深色模式切换时系统会广播
center.addObserver(
self,
selector: #selector(interfaceThemeChanged),
name: NSNotification.Name("AppleInterfaceThemeChangedNotification"),
object: nil)
}
@objc private func interfaceThemeChanged() {
// 通知不带具体值,需要自行读取UserDefaults确认当前主题
let isDark = UserDefaults.standard.string(forKey: "AppleInterfaceStyle") == "Dark"
print("外观已切换,深色模式:\(isDark)")
}
deinit {
center.removeObserver(self)
}
}两种方案对比:KVO方式是官方推荐的标准做法,类型安全、代码现代(使用Swift Key Path),且能感知应用级外观覆盖(例如用户在Info.plist或代码中单独为应用设置了外观);分布式通知方式胜在通知时机更早、跨进程可用,但通知名称属于内部约定,读取AppleInterfaceStyle这个UserDefaults键值也并非公开API文档中的正式接口,存在小概率变动风险。实际项目中建议优先采用KVO方案,分布式通知作为补充。
四、深色模式适配的常见坑与最佳实践
第一个常见的坑是自定义绘制颜色时写死了具体色值。解决方式是尽量使用系统语义化颜色,例如NSColor.controlTextColor、NSColor.windowBackgroundColor,它们会在深浅模式下自动变化。确实需要自定义颜色时,应使用Asset Catalog中的颜色资源,并在Xcode的Appearances选项中选择Any, Dark,分别提供两套色值。
第二个坑是模板图标没有启用。菜单栏图标设置后如果出现一个黑色方块或显示异常,多半是没有调用image.isTemplate = true。设置为模板图后,系统会自动根据菜单栏的深浅背景渲染图标的黑白颜色,这与SF Symbols的默认行为一致,是菜单栏图标的标准实践。
if let button = statusItem.button {
let image = NSImage(named: "custom-icon")
image?.isTemplate = true // 关键:让系统自动适配深浅色
button.image = image
}第三个坑是KVO回调线程问题。effectiveAppearance的变化回调一般发生在主线程,但如果应用中存在其他后台组件触发了外观刷新,保险起见可以在回调中用DispatchQueue.main.async包裹UI更新逻辑。此外,如果使用SwiftUI与AppKit混编,也可以在SwiftUI视图层用@Environment(\.colorScheme)获取外观,但在纯AppKit的菜单栏应用中,KVO监听NSApp.effectiveAppearance仍是最直接可靠的方式。掌握这些要点后,你就可以构建出一个外观随系统自动切换、交互规范的原生菜单栏应用了。