最近给 Argos 做了一个桌面上的“灵动卡”:它不属于主窗口,也不是菜单栏 popover,而是一个常驻桌面的轻量信息面板,用来展示当天 Claude Code 和 Codex 的 Token 用量。

视觉上它应该接近系统桌面小组件,有一点毛玻璃、有圆角、有环境透色;交互上它又必须像一个普通 Mac app surface,可以拖动、可以关闭、可以从设置里重新打开。问题也出在这里:我们很容易把“看起来像系统小组件”和“实现方式也像系统小组件”混成一件事。

这次的真实反馈很直接:用户点击打开灵动卡,Argos 直接崩溃。

Argos 崩溃弹窗

一句话总结

macOS 桌面浮层的稳定性不能只看 SwiftUI 视图本身,真正危险的是 AppKit window level、NSPanel 行为、系统材质、透明圆角和启动持久化一起叠加后的系统边界。

这不是一个普通 SwiftUI bug

最开始我也倾向于按普通 UI bug 排查:是不是某个 @EnvironmentObject 没注入?是不是 TimelineView 刷新太频繁?是不是某个统计数据为空导致 view body 崩了?

但反馈里有两个信号很关键:

现象初步判断
只有部分机器崩不是纯业务数据问题,更像系统组合差异
重装也无法恢复可能和持久化设置有关,应用启动后自动走到崩溃路径
打开灵动卡才崩主窗口、菜单栏、数据读取不是第一怀疑对象
同类机器不一定复现芯片型号只是表象,系统窗口栈和显示环境也会影响

这类问题最麻烦的地方是:你的本机不崩,不代表代码没问题。macOS 的 window server、桌面组件层级、Stage Manager、多屏、刘海屏、透明材质都可能让同一段代码走到不同系统路径。

真正危险的是 window level

灵动卡一开始为了“悬浮在桌面上方”,用了更高的窗口层级。视觉上这很合理:它不应该被普通窗口轻易盖住。

但 AppKit 的窗口层级不是一个单纯的 z-index。不同 level 背后隐含了不同的系统行为:

层级选择优点风险
.normal最稳定,接近普通窗口可能被其他窗口盖住
.floating更符合悬浮卡片直觉更容易进入特殊窗口路径
desktop widget 附近视觉接近系统小组件系统版本、桌面状态、多屏下差异最大

最后我把层级选择暴露到了设置里:默认用最稳的普通窗口,用户可以自己切到浮动或桌面组件风格。这个决策不够“产品洁癖”,但足够工程务实。

1
2
3
4
5
6
7
8
9
10
11
switch windowLevel {
case .normal:
panel.isFloatingPanel = false
panel.level = .normal
case .floating:
panel.isFloatingPanel = true
panel.level = .floating
case .desktopWidget:
panel.isFloatingPanel = false
panel.level = NSWindow.Level(rawValue: Int(CGWindowLevelForKey(.desktopIconWindow)) + 1)
}

这个代码片段的重点不是 API 本身,而是思路:把“视觉偏好”从“稳定默认值”里拆出来。默认路径必须保守,高风险路径可以给高级用户选择。

透明圆角 panel 不是只画一个 RoundedRectangle

第二个坑是透明圆角。

SwiftUI 里画一个 RoundedRectangle 很简单,但 NSPanel 本身仍然是一个 AppKit window。如果只处理 SwiftUI 背景,不处理 NSHostingView 和 window content view,系统可能仍然按矩形 frame 计算阴影、命中区域和合成边界。

这就是为什么透明 panel 要同时处理两层:

要处理什么
SwiftUI viewclipShape、背景、边框、视觉层次
AppKit hosting/containerclear background、cornerRadius、continuous corner、masksToBounds

尤其是透明窗口,最好不要依赖 AppKit 默认 shadow。默认 shadow 是按 window frame 投影的,frame 是矩形,圆角外就可能出现多出来的直角、脏边或奇怪边界。

玻璃质感也要分“安全”和“不安全”

桌面小组件风格很容易让人想到系统 material,比如 .ultraThinMaterialNSVisualEffectViewbehindWindowunderWindowBackground

但这次排查里,我对灵动卡做了一个取舍:先不用 AppKit 的 NSVisualEffectView 做底层材质,而是用 SwiftUI 的轻量 blur、半透明 tint、渐变 stroke 和局部 glow 模拟“雾白玻璃”。

原因很简单:灵动卡本身已经是一个透明 NSPanel,再叠系统 material 和特殊 window level,变量太多。为了排查和发布稳定性,先减少系统合成路径。

方案质感稳定性当前选择
NSVisualEffectView 强系统材质中低暂缓
SwiftUI blur + tint中高采用
纯实色卡片最高不采用

这也是我现在对 macOS 视觉实现的一个判断:能用系统能力当然好,但一旦 surface 是透明浮窗、跨桌面、多屏、可拖动,还要启动自动恢复,就不能盲目堆 material。

持久化是体验,也是事故放大器

还有一个容易忽略的点:灵动卡是否应该持久化打开状态。

从产品体验看,答案是应该。用户打开了桌面卡片,下次启动自然还应该在。默认也应该打开,否则用户可能根本不知道这个能力存在。

但从事故恢复看,持久化会放大问题:如果“打开灵动卡”本身会崩,那么应用每次启动自动恢复灵动卡,就会变成“打开应用即崩”,用户连进设置关闭它的机会都没有。

所以这里有一个典型工程取舍:

策略体验故障恢复
不持久化,启动默认关闭
持久化,启动默认打开
持久化 + 稳定默认层级较好可接受

最后我选择恢复持久化和默认打开,但把默认 window level 降到普通窗口,并把高层级选择放到设置里。这不是绝对安全,但比“默认走最高风险路径”合理得多。

为什么我的机器不崩,用户机器会崩

这个问题很值得单独说。

两个 Mac 看起来“除了芯片都一样”,并不代表窗口系统环境一样。至少这些因素都可能不同:

差异点可能影响
芯片/显卡路径WindowServer 合成、透明 blur、外接屏策略
系统小版本AppKit / SwiftUI bridge 行为变化
Stage Manager / 桌面组件状态桌面层级和特殊 window level 行为
多屏与刘海屏默认定位、visibleFrame、notch 区域
辅助显示工具BetterDisplay、HiDPI、虚拟屏可能改变 screen frame
历史设置上一次保存的 window level、位置、visible 状态

所以这类 bug 不能只问“为什么我不复现”。更有效的问题是:哪些系统路径只有对方会走到?哪些设置会让用户每次启动都自动进入危险路径?

我的判断框架

以后再做 macOS 上的桌面浮层,我会按这个顺序判断:

  1. 默认层级先保守:普通窗口优先,浮动和桌面组件风格作为可选项。
  2. 透明圆角同时处理 SwiftUI 和 AppKit,不只画背景。
  3. 系统 material 先少用,等稳定性验证后再加。
  4. 持久化状态要配合故障恢复路径,否则一个开关就可能把用户锁死在崩溃循环里。
  5. 对“我本机不崩”的结论保持怀疑,多看系统版本、显示环境和历史设置。

要不要用 / 我的判断框架

如果你只是做一个普通工具窗口,不建议一上来就追系统小组件质感。先把窗口层级、圆角、拖动、恢复、关闭这些基础行为做稳。

如果你确实要做桌面小组件式 surface,可以上玻璃质感,但默认路径要克制。我的建议是:默认普通层级 + 安全 blur + 可选高级层级。等收集到足够多真实设备反馈后,再决定要不要把更像系统小组件的层级设为默认。

这次踩坑对我的提醒是:macOS 原生体验不是“看起来像原生”就够了,它还要求你尊重 AppKit 的历史包袱和窗口系统边界。尤其是 Agent 工具这种常驻型应用,稳定性永远比一个更漂亮的默认层级更重要。