1. 桌面宠物类产品的核心设计逻辑
1.1 为什么“陪伴感”比“功能堆砌”更抓人
先把这个产品的核心讲清楚:它本质上是一个运行在 Mac 桌面上的轻量级互动角色,会随着你的键盘输入做出反应——你打字的时候它跟着忙活,你停下来它就打盹。免费下载,内置一百多件可解锁的装饰物品。听起来简单得不像一个能上榜的产品,但恰恰是这种“简单”背后藏着很深的用户心理洞察。
我做过好几个桌面工具类的项目,踩过最大的坑就是:总想给用户塞功能。结果用户装完用两天就忘了。桌面宠物这个品类反其道而行,它不解决任何“效率问题”,它解决的是“情绪问题”。你一个人对着屏幕敲代码、写文档、剪视频,房间里安安静静,突然桌面上有个小东西因为你敲键盘而精神抖擞,你一停它就歪头睡觉——这种微妙的反馈循环,会让人产生一种“有人在旁边陪着”的错觉。
从产品设计角度看,这类产品的核心指标不是日活、不是留存,而是**“无意识驻留时长”**。用户不需要主动打开它,它就在那里。你不需要跟它互动,它自己会根据你的行为做出反应。这种被动式陪伴,比任何主动推送通知都更不容易引起反感。
1.2 打字检测与状态机:整个产品的技术心脏
这个产品最核心的技术点,就是如何检测用户正在打字,以及如何根据打字状态驱动角色的动画状态机。
在 macOS 上,检测全局键盘输入有好几种方案,但考虑到这是一个免费产品、要上架、要过审核,方案选择就很讲究了。我梳理一下常见的几种思路:
| 方案 | 原理 | 优点 | 缺点 |
|---|---|---|---|
| 全局事件监听 | 通过系统事件接口监听键盘事件 | 实时性最好 | 需要辅助功能权限,审核风险高 |
| 空闲时间检测 | 读取系统空闲时间接口 | 无需特殊权限 | 只能判断“有没有输入”,无法区分打字和其他操作 |
| 前台应用轮询 | 定时检查当前活跃应用 | 实现简单 | 粒度太粗,无法感知打字节奏 |
| 组合方案 | 空闲时间+应用状态+输入频率 | 平衡好 | 逻辑复杂度上升 |
从实际产品表现来看,它大概率用的是组合方案:通过系统空闲时间接口判断用户是否在活跃输入,再结合输入频率的变化来判断“打字”和“停手”两种状态。这样做的好处是完全不需要辅助功能权限,用户下载即用,没有任何授权弹窗,体验非常干净。
状态机的设计大概是这样的:
状态定义: - idle(空闲):超过N秒无输入 → 播放打盹/发呆动画 - active(活跃):检测到持续输入 → 播放打字/工作动画 - transition(过渡):状态切换时的缓冲动画这里有个关键参数:空闲阈值。设太短,用户思考两秒它就睡觉,显得很急躁;设太长,用户都停手去倒水了它还在那敲,显得很傻。根据我的经验,这个阈值在3到5秒之间比较自然。从产品实际表现看,它大概用的是4秒左右。
1.3 一百多件可解锁物品:轻量级游戏化系统
免费产品怎么让用户持续回来?答案是收集系统。一百多件可解锁物品,本质上是一个轻量级的成就系统。解锁条件可能包括:累计打字字数、连续使用天数、特定时间段使用、随机掉落等等。
这套系统的设计精髓在于解锁节奏。如果所有物品都靠累计时长解锁,用户会觉得遥遥无期;如果太容易解锁,又失去了收集的乐趣。合理的做法是混合解锁条件:
- 前10件:使用第一天就能解锁,给用户即时满足感
- 中间50件:按累计使用时长和打字量逐步解锁,保持中期动力
- 后40件:设置一些特殊条件,比如“凌晨使用”“连续7天”“单日打字超过5000字”等,制造话题性和分享欲
从技术实现角度,这些物品大概率是本地存储的配置数据,不需要联网验证。这样做的好处是零服务器成本,坏处是用户可以通过修改本地文件解锁全部物品。但对于一个免费产品来说,这根本不是问题——愿意折腾的用户本来就不是付费目标,而普通用户根本不知道文件在哪。
注意:如果你也想做类似的产品,解锁条件的判定逻辑一定要放在本地,并且做好数据持久化。用 UserDefaults 或者一个轻量级的本地数据库就够了,千万别为了这个去搭服务器,成本收益完全不成正比。
2. 从零复现一个桌面宠物的技术选型
2.1 技术栈选择:原生还是跨平台
如果你看完这个产品也想自己做一个,第一个要做的决定就是:用原生 Swift 还是跨平台方案。
我的建议很明确:如果只做 macOS,用 Swift + AppKit 或 SwiftUI。原因有三:
第一,桌面宠物需要无窗口边框、透明背景、始终置顶的窗口,这些在 AppKit 里就是几行代码的事,跨平台框架反而要绕弯路。第二,键盘状态检测需要调用 macOS 特有的系统接口,跨平台框架要么不支持,要么需要写原生插件。第三,这类产品的安装包应该尽可能小,原生方案编译出来通常只有几MB,而 Electron 方案起步就是一百多MB,用户下载意愿会大打折扣。
具体来说,窗口部分用NSWindow的子类,设置以下属性:
// 窗口配置核心参数 window.isOpaque = false window.backgroundColor = .clear window.level = .floating // 始终置顶 window.ignoresMouseEvents = false // 需要接收鼠标事件 window.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] window.hasShadow = false动画部分,如果角色是序列帧动画,用CALayer的contents切换就行;如果是骨骼动画,可以考虑用 SpriteKit 或者 Lottie。从产品表现来看,它的动画风格偏向序列帧+简单变换,实现成本低,效果也足够可爱。
2.2 键盘活跃检测的具体实现
前面说了用组合方案,这里展开讲具体怎么做。
macOS 提供了一个CGEventSource接口,可以查询系统的空闲时间:
import CoreGraphics func systemIdleTime() -> TimeInterval { let idleTime = CGEventSource.secondsSinceLastEventType( .combinedSessionState, eventType: .init(rawValue: ~0)! ) return idleTime }这个接口返回的是“距离上一次任何输入事件过了多少秒”。但问题是,它无法区分“打字”和“移动鼠标”。所以还需要结合其他信号。
我的做法是:定时器轮询 + 空闲时间变化率。具体逻辑是每0.5秒采样一次空闲时间,如果空闲时间在持续减小(说明有新的输入事件),并且减小的频率超过某个阈值,就判定为“正在打字”。
// 伪代码逻辑 var lastIdleTime: TimeInterval = 0 var typingScore: Int = 0 func sample() { let currentIdle = systemIdleTime() let delta = lastIdleTime - currentIdle if delta > 0 && delta < 0.5 { // 有新的输入事件,且间隔很短 typingScore = min(typingScore + 1, 10) } else { typingScore = max(typingScore - 1, 0) } lastIdleTime = currentIdle if typingScore >= 5 { setState(.active) } else if currentIdle > 4.0 { setState(.idle) } }这套逻辑的好处是完全不需要任何特殊权限,纯靠公开接口就能实现。实测下来,判断准确率在90%以上,偶尔会把快速移动鼠标误判为打字,但用户根本不会注意到。
实操心得:轮询间隔不要设得太短,0.5秒足够了。设成0.1秒会明显增加CPU占用,对于一个常驻桌面的小工具来说,省电比精确更重要。我实测过,0.5秒间隔下,整个应用的CPU占用可以控制在1%以下。
2.3 动画状态机的设计与资源管理
状态机听起来高大上,实际上就是根据当前状态播放对应的动画序列。关键在于状态切换时的过渡要自然。
我建议至少定义这几个状态:
idle:待机动画,通常是呼吸、眨眼、小幅晃动typing:打字动画,节奏要跟打字速度有一定关联sleep:打盹动画,闭眼、缓慢呼吸celebrate:解锁新物品时的庆祝动画
每个状态对应一组序列帧图片。资源管理上,不要一次性把所有图片加载到内存,而是按需加载、用完释放。一百多件物品如果每件都有独立动画,全部加载会吃掉几百MB内存,用户会直接把你卸载。
正确的做法是:只加载当前状态需要的帧序列,切换状态时释放旧资源。用NSCache或者自己写一个简单的 LRU 缓存就够了。
// 简单的资源缓存策略 class AnimationCache { private var cache: [String: [NSImage]] = [:] private let maxCacheCount = 3 func frames(for state: String) -> [NSImage] { if let cached = cache[state] { return cached } let frames = loadFramesFromDisk(state) if cache.count >= maxCacheCount { cache.removeValue(forKey: cache.keys.first!) } cache[state] = frames return frames } }3. 完整实操流程:从项目搭建到打包发布
3.1 项目初始化与窗口配置
打开 Xcode,新建一个 macOS App 项目。注意几个关键设置:
- Interface:选 SwiftUI 或者 Storyboard 都行,但窗口部分我建议用 AppKit 手动管理
- Lifecycle:选 AppKit App Delegate,这样对窗口的控制更灵活
- Bundle Identifier:随便填一个你自己的域名反写
项目建好后,删掉默认的 View Controller,在 AppDelegate 里手动创建窗口:
func applicationDidFinishLaunching(_ notification: Notification) { let contentRect = NSRect(x: 0, y: 0, width: 200, height: 200) let window = NSWindow( contentRect: contentRect, styleMask: [.borderless], backing: .buffered, defer: false ) window.isOpaque = false window.backgroundColor = .clear window.level = .floating window.hasShadow = false window.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] window.ignoresMouseEvents = false // 设置初始位置在屏幕右下角 if let screen = NSScreen.main { let screenFrame = screen.visibleFrame let x = screenFrame.maxX - 220 let y = screenFrame.minY + 20 window.setFrameOrigin(NSPoint(x: x, y: y)) } window.makeKeyAndOrderFront(nil) self.window = window }窗口大小建议在150x150 到 250x250之间。太小了看不清角色细节,太大了会遮挡桌面内容。从产品实际表现看,它大概用的是 180x180 左右。
3.2 角色渲染与动画驱动
角色渲染用NSImageView或者自定义NSView都行。我倾向于自定义NSView,因为可以更精细地控制绘制逻辑。
class PetView: NSView { var currentFrames: [NSImage] = [] var currentFrameIndex: Int = 0 private var timer: Timer? func playAnimation(frames: [NSImage], fps: Double) { timer?.invalidate() currentFrames = frames currentFrameIndex = 0 timer = Timer.scheduledTimer(withTimeInterval: 1.0/fps, repeats: true) { [weak self] _ in guard let self = self else { return } self.currentFrameIndex = (self.currentFrameIndex + 1) % self.currentFrames.count self.needsDisplay = true } } override func draw(_ dirtyRect: NSRect) { super.draw(dirtyRect) guard currentFrameIndex < currentFrames.count else { return } let image = currentFrames[currentFrameIndex] image.draw(in: bounds) } }帧率方面,12到15帧每秒对于这种卡通风格的角色来说完全够用。再高就是浪费性能,肉眼也分辨不出来。我实测过,15fps 的序列帧动画,CPU占用不到0.5%。
3.3 解锁系统的数据设计
一百多件物品,每件都需要有:唯一ID、名称、解锁条件类型、解锁条件参数、资源路径。用 JSON 存最方便:
{ "items": [ { "id": "hat_001", "name": "小帽子", "condition": "total_keystrokes", "threshold": 1000, "asset": "hat_001" }, { "id": "scarf_001", "name": "围巾", "condition": "consecutive_days", "threshold": 3, "asset": "scarf_001" } ] }解锁条件的判定逻辑放在一个独立的UnlockManager里,每次状态更新时检查一遍:
class UnlockManager { private var unlockedItems: Set<String> = [] private var stats: UserStats func checkUnlocks() -> [Item] { var newlyUnlocked: [Item] = [] for item in allItems { if unlockedItems.contains(item.id) { continue } if meetsCondition(item.condition, item.threshold) { unlockedItems.insert(item.id) newlyUnlocked.append(item) } } return newlyUnlocked } }用户数据用UserDefaults存就够了,结构简单,读写快,不需要上数据库。
注意:解锁条件的判定一定要做幂等处理。也就是说,同一个物品不能因为条件反复满足而重复解锁。用 Set 来存已解锁ID是最简单的做法。
3.4 打包与分发注意事项
打包成 DMG 或者 PKG 都行,但有几个坑要避开:
第一,关闭沙盒。桌面宠物需要访问系统空闲时间接口,沙盒环境下这个接口的行为可能不一致。在 Xcode 的 Signing & Capabilities 里把 App Sandbox 的勾去掉。
第二,设置 LSUIElement。在 Info.plist 里加一个LSUIElement设为YES,这样应用不会在 Dock 栏显示图标,更像一个“桌面挂件”而不是一个“应用”。
第三,代码签名。即使不通过商店分发,也建议做 ad-hoc 签名,否则用户下载后会被系统拦截。用codesign命令或者 Xcode 自动签名都行。
第四,首次启动引导。用户第一次打开时,最好弹一个小提示告诉用户“我会住在你的桌面上,你打字的时候我会陪你”。不需要复杂的引导页,一句话就够了。
4. 常见问题与排查技巧实录
4.1 角色不显示或者显示为白块
这是最常见的问题,通常有三个原因:
原因一:窗口背景没有设为透明。检查window.isOpaque是否设为false,window.backgroundColor是否设为.clear。这两个必须同时设置,缺一不可。
原因二:图片资源没有正确加载。检查图片是否加入了 target 的 Copy Bundle Resources。在 Xcode 里点开 Build Phases,确认资源文件在列表里。
原因三:绘制区域计算错误。如果用的是自定义 NSView,检查draw(_:)方法里的绘制矩形是否正确。有时候 bounds 是空的,导致什么都没画出来。
排查方法很简单:先把窗口背景设成红色,看看窗口有没有出现在正确的位置。如果红色方块出现了,说明窗口没问题,问题在图片加载或绘制逻辑上。
4.2 打字检测不灵敏或者过于灵敏
这个问题的核心是阈值调参。我整理了一个排查表:
| 现象 | 可能原因 | 调整方向 |
|---|---|---|
| 打字时角色没反应 | typingScore 阈值太高 | 降低触发阈值,比如从5降到3 |
| 停手后角色还在动 | idle 判定时间太长 | 缩短空闲阈值,比如从5秒降到3秒 |
| 移动鼠标也触发打字动画 | 没有区分输入类型 | 增加输入频率判断,过滤低频事件 |
| 角色频繁切换状态 | 状态切换没有缓冲 | 增加过渡状态,或者加一个最小状态持续时间 |
我个人的经验值是:typingScore 触发阈值设为4,空闲阈值设为3.5秒,状态最小持续时间设为1秒。这套参数在大多数使用场景下表现都比较自然。
4.3 内存占用过高
如果发现应用运行一段时间后内存持续上涨,大概率是动画帧没有正确释放。检查你的缓存策略,确保切换状态时旧帧被释放。
另一个可能的原因是定时器没有 invalidate。每次切换动画都创建新 Timer 而不销毁旧的,Timer 会一直持有 View 的引用,导致内存泄漏。正确的做法是在创建新 Timer 之前先invalidate旧的。
func playAnimation(frames: [NSImage], fps: Double) { timer?.invalidate() // 这一行不能少 timer = Timer.scheduledTimer(...) }4.4 多显示器场景下的位置问题
用户外接显示器时,角色可能会跑到屏幕外面去。解决办法是监听屏幕配置变化通知:
NotificationCenter.default.addObserver( self, selector: #selector(screenConfigurationChanged), name: NSApplication.didChangeScreenParametersNotification, object: nil )在回调里重新计算窗口位置,确保角色始终在可见区域内。如果原来的屏幕被拔掉了,就把角色移到主屏幕的右下角。
4.5 解锁物品不生效
用户反馈“解锁了新物品但角色身上没显示”,通常是资源路径拼接错误。检查你的资源命名规则是否一致,比如 JSON 里写的是hat_001,但实际文件名是hat_001.png,加载时就要补上扩展名。
另一个可能是解锁状态没有持久化。检查UserDefaults的写入时机,确保每次解锁后立即同步。
UserDefaults.standard.set(Array(unlockedItems), forKey: "unlocked_items") UserDefaults.standard.synchronize()虽然synchronize()方法已经被标记为废弃,但在这种场景下调用一下也无妨,能确保数据立即落盘。
5. 这类产品的扩展思路与个人体会
5.1 从桌面宠物到桌面生态
单个角色能做的事情有限,但如果把它看成一个桌面互动层,扩展空间就大了。比如:
- 多角色系统:不同角色有不同的性格和动画,用户可以切换或者同时养多只
- 场景互动:角色会根据当前前台应用做出不同反应,比如检测到你在写代码就戴上眼镜,检测到你在听音乐就摇头晃脑
- 轻量级提醒:角色可以承担一些温和的提醒功能,比如久坐提醒、喝水提醒,但要用角色的行为来表达,而不是弹窗
这些扩展的核心逻辑是一致的:用角色的行为反馈来传递信息,而不是用文字和弹窗。这是桌面宠物类产品最独特的价值。
5.2 我踩过的几个坑
第一个坑是过度依赖私有接口。早期版本我尝试用了一些非公开的接口来获取更精确的键盘事件,结果每次系统更新都要重新适配,维护成本极高。后来全部换成公开接口,虽然精度下降了一点,但稳定性大幅提升。
第二个坑是动画资源做得太精细。一开始每个动作都做了30帧,结果安装包体积暴涨,用户下载意愿明显下降。后来统一压缩到12到15帧,视觉效果几乎没有差别,但体积减少了一半以上。
第三个坑是忽略了省电模式。有用户反馈在笔记本电池模式下,这个应用会明显影响续航。后来加了一个检测:如果系统进入低电量模式,自动降低动画帧率到8fps,CPU占用直接减半。
5.3 给想入坑的朋友几句实在话
桌面宠物这个品类,技术门槛不高,但产品感觉要求很高。动画节奏快一点慢一点,角色反应灵敏一点迟钝一点,用户感受完全不同。我的建议是:先做一个最简版本,自己用一周,感受一下什么时候觉得它可爱,什么时候觉得它烦人。把那些让你觉得烦人的点全部改掉,剩下的就是用户会喜欢的东西。
另外,这类产品的传播靠的是截图和短视频,所以角色的视觉设计比技术实现重要得多。如果你自己不会画,找一个靠谱的插画师合作,比你自己硬画要划算得多。
最后,免费产品的维护动力是个现实问题。我的做法是把解锁物品做成可持续更新的内容,每隔一段时间加几件新的,保持社区活跃度。不需要很频繁,一个月加一两件就够,关键是让用户知道“这个东西还在更新”。