自制 macOS 透明浮窗桌宠:926 帧、10 个坑、全踩了一遍
Swift + AppKit + rembg AI 抠图,从零做 macOS 桌宠的完整踩坑记录。10 个坑,每个有现象、根因、解法。
上周末我的桌面右下角多了一个小东西。
她平时低头写日记,鼠标靠近她抬头看你,点一下她挑眉停住等你离开,打开指定 App 她会顿笔惊喜然后疯狂敲键盘。透明背景,无边框,始终在其他窗口上面。
这是我自己做的,叫 AliceDesktopPet。从想法到跑起来两个小时,然后又花了一整天填坑。
下面是真实过程,10 个坑一个没漏。
一、项目概览
技术栈: Swift 6 + AppKit + Python(rembg + Pillow)+ ffmpeg
最终成果: 926 帧 RGBA PNG 序列,8 个动画状态,状态机驱动交互
平台: macOS 26 + Xcode 16
核心特性一览:
| 特性 | 描述 |
|---|---|
| 透明浮窗 | 无边框、无阴影、始终置顶、跨所有 Space |
| 动画状态 | 8 个状态,状态机控制转换,非法转换静默忽略 |
| AI 抠图 | rembg U2-Net 语义分割,处理白色角色在白色背景的场景 |
| 可配置 | config.json 控制 fps、大小、文件夹名、监听 App Bundle ID |
| 帧播放 | 路径懒加载,按帧读取,不预加载全部图片 |
8 个动画状态:
idle_writing → hover_intro → hover_loop
↓ 点击
clicked(停尾帧)
↓ 鼠标离开
mouse_away → idle_writing
idle_writing + App 启动 → excited_surprise → excited_typing → excited_look(循环)
二、实现过程
2.1 素材处理流水线
先说结论:ffmpeg colorkey 方案走不通,必须上 AI 抠图。
原始素材是 8 段 MP4,背景是棋盘格(导出软件把透明区域可视化成棋盘格)。第一反应是用 colorkey 滤镜:
ffmpeg -i input.mp4 -vf "colorkey=color=white:similarity=0.1:blend=0.03" output.mp4
结果,Alice 的白色外套、白色鞋子、白色马克杯全消失了。similarity=0.1 覆盖了所有白色元素。调小阈值,棋盘格的灰色又扣不掉。死路。
转 rembg,基于 U2-Net 神经网络,做语义分割,理解「哪里是角色哪里是背景」,不按颜色判断:
from rembg import remove, new_session
session = new_session('u2netp')
with open(frame_path, 'rb') as f:
result = remove(f.read(), session=session)
效果完全符合预期,白色外套和白色背景分得清楚。
2.2 透明浮窗渲染
先说结论:NSImageView 在透明窗口里不可用,必须自定义 draw()。
基础配置三行,文档上都有:
window.backgroundColor = .clear
window.isOpaque = false
window.level = .floating
加上 NSImageView,设图片,运行,什么都看不见。鼠标交互有(说明窗口存在),视觉上完全空白。
解法是绕过 NSImageView,自定义 NSView:
private class PetCanvas: NSView {
var image: NSImage? { didSet { needsDisplay = true } }
override var isOpaque: Bool { false }
override func draw(_ dirtyRect: NSRect) {
guard let ctx = NSGraphicsContext.current?.cgContext else { return }
ctx.clear(bounds)
image?.draw(in: bounds, from: .zero, operation: .sourceOver, fraction: 1.0)
}
}
2.3 状态机设计
8 个状态,只允许显式声明的转换,非法转换静默忽略:
func isAllowed(from: PetState, to: PetState) -> Bool {
switch (from, to) {
case (.idleWriting, .hoverIntro): return true
case (.clicked, .idleWriting): return true
default: return false
}
}
多个事件同时触发时(鼠标离开 + App 启动同时到来),非法转换被过滤,状态机不会跑飞。
2.4 可配置化
所有硬编码值抽到 config.json:
{
"fps": 24,
"defaultSize": 220,
"watchedAppBundleID": "com.yourcompany.yourapp",
"folders": {
"idleWriting": "idle_writing",
"hoverIntro": "hover_intro"
}
}
换素材的人只需要准备好 PNG 序列,改 config.json,不用动 Swift 代码。
三、踩坑实录
10 个坑,按踩到的顺序排列。
坑 1:NSImageView 在透明窗口里不渲染
现象: 窗口有鼠标交互,但视觉上完全空白,看不到任何图像。
根因: NSImageView 内部走 layer-backed rendering,其 layer 的合成方式在透明窗口的 backing store(RGBA 格式)里对不上,渲染链断掉。
解法: 自定义 NSView 子类,重写 draw(),用 ctx.clear(bounds) 清透明底,再 .sourceOver 合成图片。绕过 NSImageView 的渲染路径。
坑 2:@main 无 NIB 不接 delegate
现象: 编译通过,运行,applicationDidFinishLaunching 永远不被调用,App 活着但什么都不发生。
根因: @main 在没有 NIB 或 Storyboard 的项目里,不会自动把 AppDelegate 设为 NSApplication 的 delegate。这个连接本来是 Storyboard 负责的,删掉 Storyboard 这根线就断了。
解法: 移除 @main,新建 main.swift(必须小写):
import AppKit
let app = NSApplication.shared
let delegate = AppDelegate()
app.delegate = delegate
app.run()
坑 3:改了错误路径的副本
现象: 改代码,重新编译,运行,行为完全没变化。
根因: 我一直在改 /Users/amandawang/AliceDesktopPet/ 下的文件,但 .xcodeproj 实际在 /Users/amandawang/Desktop/alicepet/AliceDesktopPet/。两个目录,两份文件。
解法: 先 find ~ -name "*.xcodeproj" 确认项目位置,再把编辑器指向那个路径。
坑 4:colorkey 把角色白色身体扣掉
现象: 运行 colorkey 后,Alice 的白色外套、鞋子、马克杯全变透明。
根因: 角色和背景同色(都是白色系),按颜色判断无法区分。
解法: 放弃 colorkey,改用 rembg AI 语义分割,理解「角色」和「背景」的概念。
坑 5:rembg 单帧 42 秒
现象: 跑了 5 帧发现速度不对,算了一下 926 帧要跑约 10 小时。
根因: 两个问题叠加,一是用了 u2net 大模型,二是每帧重新加载模型。
| 方式 | 速度 |
|---|---|
| u2net + 每帧重载模型 | 42 秒/帧 |
| u2net + session 复用 | ~7 秒/帧 |
| u2netp + session 复用 | 0.3 秒/帧 |
解法: 换 u2netp(轻量模型,卡通角色够用),在循环外初始化一次 session 复用。926 帧总耗时从 10 小时降到 7 分钟。
坑 6:角色半透明幽灵感
现象: 抠图完成,角色身体边缘半透明,像幽灵浮在桌面上。
根因: rembg 输出的 alpha 通道大量像素停在 240-254,不是 255(完全不透明)。
解法: Pillow 后处理,阈值修正:
new_a = a.point(lambda p:
0 if p < 30 else
255 if p >= 200 else
int((p - 30) / 170 * 255)
)
大于 200 强制推到 255,小于 30 归零,中间线性过渡。
坑 7:不同视频段色调不一
现象: excited_look 段的桌子颜色明显偏冷偏暗,和其他动画段色调不统一,切换时跳变。
根因: 素材来自不同时间录制的视频,台灯色温不同。
解法: RGB 均值/标准差颜色迁移,把 excited 段的颜色分布对齐到 idle_writing 首帧:
def color_match(img_rgba, ref_stat):
# 把源图的均值和标准差拉伸到参考帧的分布
new_r = (r - src_mean[0]) * ref_std[0] / src_std[0] + ref_mean[0]
# g, b 同理
坑 8:预加载所有帧启动卡死
现象: App 启动后无响应,几十秒后系统弹出「应用程序没有响应」。
根因: 第一版在 play() 时预加载所有帧图片。idle_writing 有 241 帧,每帧 1440×1440 RGBA = 8.3MB,241 帧 ≈ 2GB,在主线程同步加载。
解法: 改为路径懒加载,只收集文件路径,Timer 每帧按需读取:
func tick() {
imageView.image = NSImage(contentsOf: framePaths[currentFrame])
currentFrame += 1
}
macOS 文件系统缓存 + NSImage 内部缓存自然保留最近用过的帧,运行流畅。
坑 9:Animations 用黄色 group,PNG 路径找不到
现象: 动画帧全空,角色不显示,没有报错。
根因: 往 Xcode 拖 Animations 文件夹时选了「Create groups」(黄色文件夹),编译时把所有 PNG 平铺进 Resources 根目录,子文件夹层级丢失。代码找 Animations/idle_writing/ 空手而归。
解法: 重新拖,选「Create folder references」(蓝色文件夹),保留目录层级。
坑 10:素材帧数截错
现象: excited_look 动画只有开头过渡帧,没有主体循环内容,切换过去立刻结束。
根因: 处理视频时截取范围搞错,只保留了前 30 帧过渡动画,170 帧主体内容被截掉了。
解法: 重新处理,保留全部 170 帧,同时对这 170 帧做颜色对齐(坑 7 的延续)。
四、适合谁做这个项目
✅ 有 Swift 基础,想入门 AppKit 原生开发的 ✅ 对 macOS 渲染体系感兴趣,想搞清楚透明窗口怎么工作的 ✅ 有自己的 2D 角色素材,想做个专属桌宠的 ✅ 愿意花一个周末踩坑、不追求一次跑通的
❌ 没写过 Swift,期望无痛上手的 ❌ 需要稳定生产环境,不能容忍调试期 bug 的 ❌ 只是想要个桌宠但不在意自己做的(直接下现成 App 更省事) ❌ 素材是复杂实拍视频(AI 抠图对复杂场景效果不稳定)
五、总结
满分 10 分,自评 7 分。
加分项:
- AI 抠图流水线效果超出预期,0.3 秒/帧的速度实用
- 状态机设计干净,事件竞争场景没出现状态错乱
- config.json 可配置化,别人接入成本低
- 透明浮窗渲染方案稳定,没有后遗症
扣分项:
- 踩了 10 个坑,其中坑 2(@main 问题)和坑 3(路径副本)纯属基础知识缺失,不该踩
- 没有做内存上限控制,帧缓存策略完全依赖系统,长时间运行内存行为未测试
- 色调统一是 hack,换不同素材可能需要重新调参
一个周末做完是合理预期,但要接受大概率会在某个坑里卡半天。
整个项目最值得学的不是代码,是排查思路,遇到「什么都看不见」先确认窗口存在,遇到「改了没效果」先确认在改正确的文件。这两条通用于所有开发场景。