
SwiftUI + macOS 26 导致的诡异 Bug 修复记录
记录一个 macOS 26 上 SwiftUI 设计问题导致的诡异问题
TL;DR
如果你的 macOS App 同时满足下面三个条件:
- 使用 SwiftUI 的
MenuBarExtra作为唯一可视入口 - 启动时调用
NSApp.setActivationPolicy(.accessory)隐藏 Dock 图标 - 主
WindowScene 在某些状态下不会自动显示
那么当用户在「系统设置 → 控制中心 → 菜单栏」里关掉你 App 的开关后,整个 App 进程会启动失败、立即终止,且不会留下任何 crash log,连 Sentry 也捕获不到。
解决方案:放弃 MenuBarExtra,在 AppDelegate 里手动管理 NSStatusItem,并在 isVisible == false 时回退到 .regular 显示 Dock 图标兜底。
一份诡异的崩溃报告
OrbitRing 是我的 macOS 圆环启动器,常驻菜单栏,按下触发键弹出圆环面板。一位用户反馈:
自从我在系统设置里关闭了 OrbitRing 的菜单栏图标,整个 App 就再也打不开了。双击 Finder 没反应,按触发键也没反应,活动监视器里查不到进程。
第一反应是 crash,但 Sentry 没有任何记录,本地的 ~/Library/Logs/DiagnosticReports/ 也是空的。
更让人困惑的是:同样是 menu-bar-only 的 Typeless 在系统级隐藏菜单栏图标后照常运行——没有图标、没有 Dock 图标,但触发键能正常激活窗口,进程稳稳活着。
也就是说:这事可以做到,是我做错了什么。
一个被否决的假设
最初我猜:进程其实活着,只是没有任何可见 UI。这个假设很顺:
MenuBarExtra被系统隐藏了,但 SwiftUI App 仍在跑AppDelegate没实现applicationShouldHandleReopen(_:hasVisibleWindows:),所以双击 Finder 不会再唤起任何窗口- 用户感觉「打不开」,其实进程还在
按这条假设的修复方法是加一个 reopen handler,让用户能重新激活窗口。
但用户立刻给出反例:
活动监视器里 OrbitRing 进程完全不存在。
这一句话就把假设否决了。进程不在,意味着它根本没有活下来——不是没 UI,是真的死了。问题需要重新建模。
SwiftUI MenuBarExtra 的隐性契约
回到代码。OrbitRing 的 Scene 树极简:
而 AppDelegate 启动时立刻:
关键观察:SwiftUI 的 MenuBarExtra 把「菜单栏图标存在」绑进了 Scene 生命周期。它没有提供「图标隐藏但 Scene 仍存活」这种语义,不像传统 NSStatusItem 暴露的 isVisible 属性。
macOS 26 引入的「允许在菜单栏显示」开关,本质上是系统级强制设置 NSStatusItem.isVisible = false:
实现方式系统隐藏后的行为手动 NSStatusItem对象创建成功,只是不显示。AppDelegate 完好无损,进程继续跑SwiftUI MenuBarExtra底层 NSStatusItem 创建被系统拒绝/不可见,Scene 进入「无法显示」状态
而在 OrbitRing 这种特定组合下:
条件状态MenuBarExtra Scene无法挂载Window Sceneonboarding 已完成,启动时不会自动显示setActivationPolicy(.accessory)立刻撤掉 Dock 图标
SwiftUI App 找不到任何有效 Scene 来维持生命周期,进程被快速回收。这条路径既不是 abort() 也不是 signal,是框架内部判定「无可显示 Scene」后的优雅退出,所以 Sentry 抓不到,本地 crash log 也没有。
副作用很微妙:因为是优雅退出,用户甚至感觉不到 crash 的差异——只觉得「双击没反应」。
而 Typeless 没踩这个雷,是因为它在 AppDelegate 里用 NSStatusBar.system.statusItem(...) 手动创建。NSStatusItem 对象本身的存活完全不依赖 isVisible,进程因此安全。
迁移到手动 NSStatusItem + 可见性 fallback
修复路径只有一条:放弃 MenuBarExtra。具体做四件事。
1. 在 AppDelegate 持有 NSStatusItem
2. 把 SwiftUI 菜单按钮翻译成 NSMenu
原来 MenuBarExtraContent 里的每个 SwiftUI Button 翻译成一个 NSMenuItem,配 @objc 响应方法。本地化字符串通过项目已有的 LocalizedString(_:) 全局函数获取,可以直接复用 Localizable.xcstrings 里的翻译键,无需重新翻译。
3. 从 SwiftUI Scene 树移除 MenuBarExtra
4. 检测 isVisible 并 fallback
启动完成后稍延时(经验值 0.5s,让系统完成可见性判定),检查 statusItem.isVisible,若为 false:自动改回 .regular 显示 Dock 图标,并发一条本地通知告诉用户为什么 Dock 图标突然出现,以及怎么恢复。
这一步保证了即使用户把菜单栏开关关掉,至少有 Dock 图标作为兜底入口,加上一条通知告诉他原因和恢复方式,避免再次走进「打不开」的死胡同。
经验总结
- SwiftUI 抽象的代价:
MenuBarExtra是漂亮的声明式 API,但它把「图标可见」绑进了 Scene 生命周期。一旦系统介入这个可见性,整个 Scene 都会失效。AppKit 的NSStatusItem给了你isVisible这个出口,SwiftUI 没有。在做 menu-bar-only 应用时,AppKit 这条老路反而更稳。 - menu-bar-only App 必须留 fallback 入口:哪怕你认为图标永远会在,也要假设它有一天会消失。Dock 图标、全局快捷键、URL Scheme,至少备一个,避免用户走进死胡同。
- 没有 crash log 不等于没崩溃:当 SwiftUI 框架自己决定 App 该不该活,它会走非异常路径退出。Sentry 之类的工具抓不到这种「静默死亡」,只能靠现象推理。如果一个 App 启动后进程不存在但又没有 crash log,怀疑方向应该指向「框架层主动终止」而不是「代码崩溃」。
- macOS 26 的菜单栏管控是动态的:用户开关一次重启应用即可触发。如果你的 macOS App 用了 SwiftUI 的
MenuBarExtra,请在 macOS 26 及以上的系统里手动跑一遍这条路径——很大概率你也踩了同样的坑。


