不写"Hello World",写一只会跳舞的打工猫。
缘起
月薪喵是一个经典的互联网 meme------一只抱着金币跳舞的猫,配上 Disco 音乐,寓意「月薪翻倍」。
然而每次要看它,需要在浏览器打开一个网页?太不优雅了。
于是我用 纯 Swift + AppKit,把它做成了一个真正的 macOS 桌面悬浮宠物。没有 Dock 图标,永远浮在最上层,可以随便拖,菜单栏控制音乐和大小。
这篇分享完整的实现过程,包括让我头疼的坑。
技术栈: Swift 5、AppKit、DMG 打包
最终效果:

一、窗口:让它「浮」起来
桌面宠物的第一关:一个无边框、透明背景、永远置顶的悬浮窗。
php
class FloatingPanel: NSPanel {
init() {
super.init(
contentRect: NSRect(x: 0, y: 0, width: 240, height: 240),
styleMask: [.borderless, .nonactivatingPanel],
backing: .buffered,
defer: false
)
self.level = .floating // 永远在最上层
self.collectionBehavior = [.canJoinAllSpaces, .stationary] // 跨桌面
self.isOpaque = false
self.backgroundColor = .clear // 透明背景
self.hasShadow = false
self.isMovableByWindowBackground = false // 👈 这里踩坑了
}
}
这里选 NSPanel 而非 NSWindow,因为它本身就是「辅助面板」的语义------不抢焦点,不进入窗口切换列表。
🔴 坑 1:isMovableByWindowBackground 不生效
最自然的想法是直接 isMovableByWindowBackground = true,让用户拽窗口背景任意拖动。结果死活拖不动。
原因是 .nonactivatingPanel 和 isMovableByWindowBackground 不兼容。当你用的是 nonactivatingPanel,系统就不会把背景上的鼠标事件传给你的窗口。
替代方案: 自己实现拖拽。
二、拖拽:自己造一个 DraggableView
思路很简单:在 ContentView 外面包一层自定义 NSView,拦截 mouseDown → mouseDragged → mouseUp。
swift
class DraggableView: NSView {
var dragging = false
var dragStart: NSPoint = .zero
override func mouseDown(with event: NSEvent) {
dragging = true
dragStart = event.locationInWindow
}
override func mouseDragged(with event: NSEvent) {
guard dragging, let window else { return }
let current = event.locationInWindow
let deltaX = current.x - dragStart.x
let deltaY = current.y - dragStart.y
var origin = window.frame.origin
origin.x += deltaX
origin.y += deltaY
// 👇 多屏边界 clamp
let union = NSScreen.screens.reduce(NSRect.zero) {
$0.union($1.visibleFrame)
}
let w = window.frame.width
let h = window.frame.height
origin.x = max(union.minX - w + 30, min(origin.x, union.maxX - 30))
origin.y = max(union.minY - h + 30, min(origin.y, union.maxY - 30))
window.setFrameOrigin(origin)
}
override func mouseUp(with event: NSEvent) {
dragging = false
}
}
关键点:
visibleFrame不是frame------visibleFrame 排除了 Dock 和菜单栏区域- 多屏:
NSScreen.screens.reduce取所有屏幕的并集
三、GIF 动画:播放、暂停、缩放
只要拿到 cat.GIF 的 Data,直接丢给 NSImage:
swift
class AnimatedGifView: NSImageView {
func loadGif(named name: String) {
guard let data = NSDataAsset(name: name)?.data ??
try? Data(contentsOf: Bundle.main.url(forResource: name,
withExtension: "gif")!) else { return }
let image = NSImage(data: data)
self.image = image
self.animates = true // NSImageView 原生支持 GIF 播放
}
}
🔴 坑 2:缩放时动画「漂移」
做大小滑块的时候,直接改 frame 同时让 GIF 继续播放------猫的位置会对不上。因为 NSImage 的逐帧渲染和 frame 变化不同步。
解决: 缩放前暂停动画,改完尺寸再恢复。
arduino
func resize(to scale: CGFloat) {
let wasAnimating = gifView.animates
gifView.animates = false
// 先改窗口大小
let size = baseSize * scale
window?.setContentSize(NSSize(width: size, height: size))
// 再更新 gifView frame
gifView.setFrameSize(NSSize(width: size, height: size))
gifView.image?.size = NSSize(width: size, height: size)
if wasAnimating {
gifView.animates = true
}
}
四、状态栏:菜单、滑块、音乐控制

桌面宠物不能有 Dock 图标(那是 App,不是宠物)。所以一切控制入口都放在状态栏。
ini
// Info.plist 里:
// LSUIElement = YES → 隐藏 Dock 图标
let statusItem = NSStatusBar.system.statusItem(
withLength: NSStatusItem.squareLength
)
// 提取 GIF 第一帧做菜单栏图标
if let data = try? Data(contentsOf: gifURL),
let image = NSImage(data: data) {
let frame0 = NSImage(size: NSSize(width: 18, height: 18))
image.representations.first?.let bitmap = ... // 取第一帧
statusItem.button?.image = frame0
}
菜单项动态更新音乐状态:
ini
@objc func toggleMusic() {
if musicPlayer.isPlaying {
musicPlayer.pause()
musicMenuItem.title = "🎵 Play Music"
} else {
musicPlayer.play()
musicMenuItem.title = "⏸ Pause Music"
}
}
Move to Corner 的实现也很简单------拿当前所在屏幕的四个角,逆时针轮换:
swift
@objc func moveToCorner() {
let screen = window.screen ?? NSScreen.main!
let visible = screen.visibleFrame
let size = window.frame.size
let corners: [(CGFloat, CGFloat)] = [
(visible.maxX - size.width - 20, visible.maxY - size.height - 40), // 右下
(visible.maxX - size.width - 20, visible.minY + 40), // 右上
(visible.minX + 20, visible.minY + 40), // 左上
(visible.minX + 20, visible.maxY - size.height - 40), // 左下
]
let corner = corners[cornerIndex % 4]
window.setFrameOrigin(NSPoint(x: corner.0, y: corner.1))
cornerIndex += 1
}
五、分发:没 $99 也能分发 DMG
我没有 Apple Developer ID($99/年),但这不能阻止我把 app 分享给朋友。
Ad-hoc 签名
css
codesign --force --deep --sign - "月薪喵.app"
- 表示 ad-hoc 签名------可以运行,但不被 Gatekeeper 信任。
打包 DMG
lua
hdiutil create -volname "月薪喵" -srcfolder "月薪喵.app" \
-ov -format UDZO "月薪喵.dmg"
用户侧绕过 Gatekeeper
DMG 下载后打开会提示「无法验证开发者」,解决方式:
bash
# 方式1:命令一行
xattr -cr /Applications/月薪喵.app
# 方式2:右键 → 打开 → 确认
完整的一键 build 脚本也放在仓库里了,从编译到 DMG 全程自动化。
六、吐槽 & 踩坑总结
| 问题 | 现象 | 解法 |
|---|---|---|
| movableByWindowBackground | 拖不动 | 和 nonactivatingPanel 冲突,改用自定义 DraggableView |
| 多屏拖动越界 | 拖到另一个屏幕边缘就卡住 | union 所有屏幕 visibleFrame |
| GIF 缩放漂移 | 缩放时帧位置错乱 | 缩放前暂停 animates,设完恢复 |
| 状态栏图标太小 | emoji 在状态栏模糊 | 从 GIF 提取第一帧做 NSImage |
| 无签名分发 | 用户打开提示损坏 | Ad-hoc + DMG + xattr -cr |
七、源码
👉 GitHub: Tr2e/SalaryCatFloat
bash
git clone <repo>
cd SalaryCatFloat
bash build.sh # 一键编译 + 打包 DMG
也可以直接下载玩一玩 Release 1.0
最后
这个项目看起来小,但把 NSWindow 层级、事件处理、动画、多屏适配、分发打包 都串了一遍。从一个 meme 到一个真正的桌面悬浮宠物,这也是写代码的乐趣所在。
祝看到这里的你也月薪翻倍!💰🐱
如果觉得有帮助,欢迎点赞收藏~