🐱 从 0 到 1:用 Swift 手搓一个 macOS 桌面宠物(附源码)

47 阅读4分钟

不写“Hello World”,写一只会跳舞的打工猫。


缘起

月薪喵是一个经典的互联网 meme——一只抱着金币跳舞的猫,配上 Disco 音乐,寓意「月薪翻倍」。

然而每次要看它,需要在浏览器打开一个网页?太不优雅了。

于是我用 纯 Swift + AppKit,把它做成了一个真正的 macOS 桌面悬浮宠物。没有 Dock 图标,永远浮在最上层,可以随便拖,菜单栏控制音乐和大小。

这篇分享完整的实现过程,包括让我头疼的坑。

技术栈: Swift 5、AppKit、DMG 打包

最终效果:

salary_cat.gif


一、窗口:让它「浮」起来

桌面宠物的第一关:一个无边框、透明背景、永远置顶的悬浮窗。

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,让用户拽窗口背景任意拖动。结果死活拖不动。

原因是 .nonactivatingPanelisMovableByWindowBackground 不兼容。当你用的是 nonactivatingPanel,系统就不会把背景上的鼠标事件传给你的窗口。

替代方案: 自己实现拖拽。


二、拖拽:自己造一个 DraggableView

思路很简单:在 ContentView 外面包一层自定义 NSView,拦截 mouseDown → mouseDragged → mouseUp。

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.GIFData,直接丢给 NSImage:

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 变化不同步。

解决: 缩放前暂停动画,改完尺寸再恢复。

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
    }
}

四、状态栏:菜单、滑块、音乐控制

image.png

桌面宠物不能有 Dock 图标(那是 App,不是宠物)。所以一切控制入口都放在状态栏。

// 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
}

菜单项动态更新音乐状态:

@objc func toggleMusic() {
    if musicPlayer.isPlaying {
        musicPlayer.pause()
        musicMenuItem.title = "🎵 Play Music"
    } else {
        musicPlayer.play()
        musicMenuItem.title = "⏸ Pause Music"
    }
}

Move to Corner 的实现也很简单——拿当前所在屏幕的四个角,逆时针轮换:

@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 签名

codesign --force --deep --sign - "月薪喵.app"

- 表示 ad-hoc 签名——可以运行,但不被 Gatekeeper 信任。

打包 DMG

hdiutil create -volname "月薪喵" -srcfolder "月薪喵.app" \
    -ov -format UDZO "月薪喵.dmg"

用户侧绕过 Gatekeeper

DMG 下载后打开会提示「无法验证开发者」,解决方式:

# 方式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

git clone <repo>
cd SalaryCatFloat
bash build.sh  # 一键编译 + 打包 DMG

也可以直接下载玩一玩 Releases


最后

这个项目看起来小,但把 NSWindow 层级、事件处理、动画、多屏适配、分发打包 都串了一遍。从一个 meme 到一个真正的桌面悬浮宠物,这也是写代码的乐趣所在。

祝看到这里的你也月薪翻倍!💰🐱


如果觉得有帮助,欢迎点赞收藏~