编程 ·

自制 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,换不同素材可能需要重新调参

一个周末做完是合理预期,但要接受大概率会在某个坑里卡半天。

整个项目最值得学的不是代码,是排查思路,遇到「什么都看不见」先确认窗口存在,遇到「改了没效果」先确认在改正确的文件。这两条通用于所有开发场景。