返回博客

macOS 菜单栏 App 重复启动留下幽灵图标,LSUIElement 单实例守卫

写一个 macOS 菜单栏工具,调试阶段每次在 Xcode 按 ⌘R,菜单栏里就多出一个图标。跑几次之后,右上角一排全是同一个图标的复制品。Activity Monitor 打开一看,进程列表里同名进程已经堆了四五个。

直觉上以为新启动会"顶替"旧的,但 macOS 根本没做这件事。

现象

  • Xcode ⌘R 重新运行,不停调试器,菜单栏图标数量线性增长
  • 关闭 Xcode 后图标全部消失(所有进程随 Xcode session 终止)
  • 从 Finder 直接双击 .app 再次启动,同样新增图标而非替换
  • 每个幽灵图标都可以点击,弹出各自进程的 Popover,互不干扰

根因

这是 LSUIElement = YES 的固有机制差异,不是 bug。

普通有 Dock 图标的 macOS App,系统通过 NSApplicationDelegate.applicationShouldHandleReopen 保证单实例语义——再次启动会唤起已有实例。但 LSUIElement 应用没有 Dock 图标、没有主窗口,macOS 对它没有任何"已经在跑了,不要再开"的判断,每次都当作全新进程启动。

NSStatusItem 的生命周期绑定到它所属进程,进程不退出,图标就一直挂在菜单栏里。旧进程既不会被系统主动终止,也收不到调试器停止的干净信号——LSUIElement 进程本来就游离在 Xcode run loop 的管控边界之外。

坑:LSUIElement 应用不享有系统单实例保护

普通 Dock App 靠 applicationShouldHandleReopen 实现单实例。
LSUIElement = YES 的菜单栏 App 完全没有这套机制——macOS 把每次启动都视为独立进程,旧进程和旧 NSStatusItem 原地留存。

正解

applicationDidFinishLaunching最开头,用 NSRunningApplication 枚举所有共享同一 bundleIdentifier 的运行中进程,排除自身 PID,对其余全部调用 forceTerminate(),再去创建 NSStatusItem

import AppKit
 
final class AppDelegate: NSObject, NSApplicationDelegate {
 
    func applicationDidFinishLaunching(_ notification: Notification) {
        terminateOlderInstances()   // ← 必须在 configureStatusItem() 之前
        configureStatusItem()
        configurePopover()
    }
 
    private func terminateOlderInstances() {
        let myBundleID = Bundle.main.bundleIdentifier ?? ""
        guard !myBundleID.isEmpty else { return }
        let myPID = ProcessInfo.processInfo.processIdentifier
        NSRunningApplication
            .runningApplications(withBundleIdentifier: myBundleID)
            .filter { $0.processIdentifier != myPID }
            .forEach { $0.forceTerminate() }
    }
}

几个关键点:

  • runningApplications(withBundleIdentifier:) 返回结果包含自身,必须用 PID 过滤
  • forceTerminate() 而非 terminate():旧实例可能卡住无法响应正常退出信号,forceTerminate() 确保干掉
  • 顺序不能颠倒:先清场再挂图标,否则新图标和幽灵图标短暂共存
正解:启动时主动清场旧实例

applicationDidFinishLaunching 第一行调用 terminateOlderInstances(),用 Bundle ID 找到所有同名进程,排除自身 PID 后全部 forceTerminate(),然后再安装 NSStatusItem。菜单栏始终只有一个图标。

注意:forceTerminate 不触发 applicationWillTerminate

forceTerminate() 的旧进程不会走正常的 applicationWillTerminate 回调。如果应用有需要持久化的状态,要在运行中实时存盘,不能靠"退出时保存"。

一句话外卖

LSUIElement 菜单栏应用没有系统级单实例保护,必须在 applicationDidFinishLaunching 自己用 NSRunningApplication 清场旧进程,再挂 NSStatusItem