最近给 Argos 做了一个桌面上的“灵动卡”:它不属于主窗口,也不是菜单栏 popover,而是一个常驻桌面的轻量信息面板,用来展示当天 Claude Code 和 Codex 的 Token 用量。
视觉上它应该接近系统桌面小组件,有一点毛玻璃、有圆角、有环境透色;交互上它又必须像一个普通 Mac app surface,可以拖动、可以关闭、可以从设置里重新打开。问题也出在这里:我们很容易把“看起来像系统小组件”和“实现方式也像系统小组件”混成一件事。
这次的真实反馈很直接:用户点击打开灵动卡,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 | switch windowLevel { |
这个代码片段的重点不是 API 本身,而是思路:把“视觉偏好”从“稳定默认值”里拆出来。默认路径必须保守,高风险路径可以给高级用户选择。
透明圆角 panel 不是只画一个 RoundedRectangle
第二个坑是透明圆角。
SwiftUI 里画一个 RoundedRectangle 很简单,但 NSPanel 本身仍然是一个 AppKit window。如果只处理 SwiftUI 背景,不处理 NSHostingView 和 window content view,系统可能仍然按矩形 frame 计算阴影、命中区域和合成边界。
这就是为什么透明 panel 要同时处理两层:
| 层 | 要处理什么 |
|---|---|
| SwiftUI view | clipShape、背景、边框、视觉层次 |
| AppKit hosting/container | clear background、cornerRadius、continuous corner、masksToBounds |
尤其是透明窗口,最好不要依赖 AppKit 默认 shadow。默认 shadow 是按 window frame 投影的,frame 是矩形,圆角外就可能出现多出来的直角、脏边或奇怪边界。
玻璃质感也要分“安全”和“不安全”
桌面小组件风格很容易让人想到系统 material,比如 .ultraThinMaterial、NSVisualEffectView、behindWindow、underWindowBackground。
但这次排查里,我对灵动卡做了一个取舍:先不用 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 上的桌面浮层,我会按这个顺序判断:
- 默认层级先保守:普通窗口优先,浮动和桌面组件风格作为可选项。
- 透明圆角同时处理 SwiftUI 和 AppKit,不只画背景。
- 系统 material 先少用,等稳定性验证后再加。
- 持久化状态要配合故障恢复路径,否则一个开关就可能把用户锁死在崩溃循环里。
- 对“我本机不崩”的结论保持怀疑,多看系统版本、显示环境和历史设置。
要不要用 / 我的判断框架
如果你只是做一个普通工具窗口,不建议一上来就追系统小组件质感。先把窗口层级、圆角、拖动、恢复、关闭这些基础行为做稳。
如果你确实要做桌面小组件式 surface,可以上玻璃质感,但默认路径要克制。我的建议是:默认普通层级 + 安全 blur + 可选高级层级。等收集到足够多真实设备反馈后,再决定要不要把更像系统小组件的层级设为默认。
这次踩坑对我的提醒是:macOS 原生体验不是“看起来像原生”就够了,它还要求你尊重 AppKit 的历史包袱和窗口系统边界。尤其是 Agent 工具这种常驻型应用,稳定性永远比一个更漂亮的默认层级更重要。








