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

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

原因是 .nonactivatingPanelisMovableByWindowBackground 不兼容。当你用的是 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.GIFData,直接丢给 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 到一个真正的桌面悬浮宠物,这也是写代码的乐趣所在。

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


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

相关推荐
iOS开发上架哦3 小时前
Jenkins 自动上传 IPA 到 App Store 把发布步骤融入 CI/CD
后端·ios
Mac技巧大咖4 小时前
macOS 27 或成 Intel Mac 分水岭:老款 Mac 用户升级前要注意什么?
macos·macos 27
ZJPRENO4 小时前
2026 苹果 WWDC 完整总结
ios
上天_去_做颗惺星 EVE_BLUE6 小时前
【新 Linux 服务器上手全攻略】系统巡检、存储规划与开发环境初始化
linux·运维·服务器·ubuntu·macos·centos
REDcker6 小时前
WWDC2026系统更新综述
macos·ios·开发者·apple·wwdc·ipados·wwdc2026
星星电灯猴7 小时前
全面解决Charles抓取HTTPS请求响应中文乱码问题的方法与技巧
后端·ios
人月神话-Lee7 小时前
【WWDC】Core AI:iOS 端侧大模型新纪元
人工智能·ios·ai·swift·wwdc·core ai
Sammyyyyy7 小时前
2026 Mac 本地大模型部署深度解析与混合架构指南
数据库·人工智能·macos·ai·架构·servbay
亚林瓜子7 小时前
mac自动启动位置
macos