简介:一个用Python实现的桌面宠物程序,基于PyQt5开发,适合为电脑增添趣味交互,或学习PyQt5桌面应用开发的初学者参考。程序默认每10秒播放一个动作,支持双击左键激活行走、右键调出系统功能;内置图片素材来自网络,但可自行替换并扩展动作。代码结构清晰,core目录下按动作配置与功能模块拆分,并提供启动参数、托盘模式及pyinstaller打包配置,便于二次开发。资源包共46个文件,包含31张png动作贴图、9个py源码文件及说明文档、依赖清单等,压缩包仅457KB,内容轻量、下载与解压都很方便。目前已有1761人学习下载。对于想了解Python桌面程序写法、PyQt5事件处理或桌面宠物实现思路的读者,这是一份可直接运行和修改的入门示例。
1. 桌面宠物这个 Python 项目,不只是「会动的小图」
桌面宠物这个 Python 项目,看着像玩具,其实是一串很扎实的技能训练:透明窗口、事件循环、状态机、资源打包,全都要过一遍。某开发者最早写这东西,只是想让屏幕上有个会走路的像素猫陪自己加班,结果从 200 行的「会动的小图」改到 800 行的「会睡觉、会躲鼠标、会吃饭」,中间踩的坑比很多业务项目都多。适合刚学完 Python 基础、想找第一个完整 GUI 项目的人,也适合想要一个不占资源又有点温度的桌面挂件的普通用户。如果你也想动手做一只,下面这些从选型到排错的细节可以直接抄。
2. 选型先于写码:tkinter 做桌面宠物的四个关键判断
2.1 为什么是 tkinter 而不是 PyQt 或 Pygame
我先把结论放前面:做桌面宠物这类常驻桌面、图轻量、动画简单的小挂件,tkinter 是性价比最高的选择。PyQt 功能确实强,控件丰富、动画平滑,但打包体积动辄 50 MB 起步,对一个只想让宠物在桌面走来走去的项目来说太重了;Pygame 做游戏渲染很顺手,但它有自己的主循环,要和 tkinter 的 mainloop 共存,就得开线程或者自己写事件分发,容易把简单问题搞复杂。
tkinter 的优势体现在三点:第一,标准库自带,不需要额外安装 GUI 依赖,换个环境拿起来就能跑;第二,它原生支持透明键控和置顶这两个桌面宠物最关键的窗口能力;第三,打包成单个 exe 大约 10 MB 左右,对常驻进程来说很友好。缺点也有,动画本质上是「定时切换图片」,做不到 60 帧的补间平滑,但对像素风宠物来说完全够用。
个人建议:如果只是想要一个会动、能拖、能点着玩的宠物,tkinter 绰绰有余;如果后面想加粒子特效、骨骼动画、物理碰撞,再考虑迁移 Pygame,但迁移之前先把状态机想清楚,这部分两个框架能直接复用。
2.2 透明窗口、置顶与层级:三条属性决定体验下限
桌面宠物核心的窗口能力是三个:无边框、透明背景、置顶显示。tkinter 里分别对应 overrideredirect、transparentcolor、topmost 三条属性,它们都是窗口系统层面的东西,不是 tkinter 绘制出来的效果。
- overrideredirect(True):去掉标题栏和系统边框,窗口只剩矩形区域;
- attributes("-topmost", True):让窗口始终浮在其他窗口上方,宠物不会被编辑器挡住;
- attributes("-transparentcolor", "magenta"):把指定颜色作为键控色,屏幕上凡是这种颜色的像素全部透明。
这里要提前说透一个平台坑:transparentcolor 是 Windows 独有的窗口属性,在 macOS 和大多数 Linux 桌面上不生效。如果要在 macOS 上做,常见替代方案是背景设为纯色、再用 attributes("-alpha", 0.9) 做整体半透明,但那是「整块半透明」,不是按颜色抠除,效果差距极大。所以这个项目建议优先在 Windows 上开发和验证,别在 macOS 上研究半天 transparentcolor 才发现无效。
topmost 的层级参数也有讲究。如果想实现「置顶但允许点击穿透」,比如宠物只用来展示不接收鼠标,tkinter 原生做不到精细的镂空区域命中,需要依赖 Windows API 或第三方库。正常情况下,我们置顶的同时要接收鼠标,因为宠物要响应拖拽和点击,而鼠标点在窗口透明区域时,事件会被宠物窗口吞掉,这个问题第 5 章专门讲。
2.3 素材准备:序列帧 PNG 的命名、尺寸与边缘约束
桌面宠物的「动画」不是视频,是序列帧图片按顺序快速切换。素材准备得好,代码能省一大半事。我习惯的素材目录结构如下:
assets/ idle_01.png idle_02.png idle_03.png idle_04.png walk_01.png ... walk_08.png sleep_01.png sleep_02.png click_01.png click_02.png click_03.png要求有三条。第一是尺寸统一,同一个状态的帧必须完全一样大,否则切换帧时窗口会跳动;我习惯统一缩到 128x128 或 160x160,再大就不划算了,tkinter 每切换一帧都要重新解码图片,大图吃 CPU。第二是必须带 alpha 通道的 PNG,透明背景在图片里已抠好,运行时只靠 magenta 键控做窗口底色透明,图片内部半透明由 PNG 自己负责。第三是边缘要干净,如果素材边缘有深色描边,键控色会把深色一起抠成透明,出现「宠物缺一块」的观感。
素材来源常见做法是两类:一类是像素游戏拆包资源,注意许可;另一类是用现成素材包,尺寸已经统一,直接改文件名即可。如果手头只有 GIF 或零散图片,可以用 Pillow 批处理转成 PNG 并按状态重命名。某开发者第一次拿 8 帧 GIF 拆出来才发现每帧尺寸不一致,第一版跑起来窗口不停跳,后来补了个批处理脚本一次性解决。另外透明键控色也要挑好:我习惯选素材调色板里绝对不存在的颜色,比如纯青色 00ffff 或纯紫红 ff00ff,避免误抠。窗口背景、Label 背景、透明色三者必须一致,否则会留一圈脏边。
| 对比项 | tkinter | PyQt5/PyQt6 | Pygame |
|---|---|---|---|
| 安装成本 | 标准库自带 | pip 安装,依赖较多 | pip 安装,需初始化窗口 |
| 打包体积 | 约 10 MB 单文件 | 50 MB 起步 | 20 MB 左右 |
| 透明键控 | 原生支持(Windows) | 支持,配置稍多 | 需自行合成帧 |
| 动画方式 | 定时切帧 | 定时切帧或 QTimer | 主循环翻帧更流畅 |
| 适合场景 | 桌面挂件、轻量工具 | 复杂 GUI 工具 | 游戏渲染、粒子效果 |
3. 搭骨架:透明窗口、置顶与鼠标拖拽的完整实现
3.1 先用 30 行搭出透明窗口并验证效果
我不喜欢一上来就铺完整工程,习惯先搭一个最小窗口,看到「图片浮在桌面上、背景透明、鼠标能拖动」这三个现象后,再往上堆状态机。下面是完整的最小骨架代码,可以直接存成 pet_skeleton.py 运行:
import tkinter as tk from PIL import Image, ImageTk root = tk.Tk() root.overrideredirect(True) # 去掉标题栏和边框 root.attributes("-topmost", True) # 保持置顶 root.attributes("-transparentcolor", "magenta") # 键控透明色 root.config(bg="magenta") # 窗口背景与键控色一致 img = Image.open("assets/idle_01.png") # 读入宠物第一帧 w, h = img.size # 窗口严格匹配图片尺寸 root.geometry(f"{w}x{h}+100+100") photo = ImageTk.PhotoImage(img) # 转成 Tk 可显示的图片对象 label = tk.Label(root, image=photo, bg="magenta") # Label 背景也必须同色 label.pack() root.mainloop()运行效果是:一张 PNG 图片浮在桌面,图片区域之外因为是 magenta 被键控抠掉,看起来背景透明。注意透明区域只是视觉上消失,窗口热区还在,鼠标点在透明区域时实际上还是点在宠物窗口上,这个问题第 5 章会专门讲。
几个关键点。overrideredirect(True) 只要在 mainloop 之前调用即可;root.config(bg="magenta") 和 attributes("-transparentcolor", "magenta") 必须成对,否则窗口四周会留一圈默认灰色边;Label 的 background 也必须设置成同样的 magenta,否则 Label 自身背景会盖住窗口背景。窗口尺寸我习惯直接用图片尺寸,而不是写死,因为 PNG 素材尺寸一变,geometry 手动改容易漏。
3.2 鼠标拖拽的坐标计算:别用 root 坐标,用事件偏移
透明窗口没有标题栏,没法靠系统拖动,所以要自己写拖拽。核心是两个事件:按下时记录鼠标相对窗口左上角的偏移量,移动时通过窗口当前位置加偏移量来更新坐标。
offset_x = 0 offset_y = 0 def on_press(event): global offset_x, offset_y offset_x = event.x # 鼠标在窗口内的横向偏移 offset_y = event.y def on_drag(event): x = root.winfo_x() + event.x - offset_x y = root.winfo_y() + event.y - offset_y root.geometry(f"+{x}+{y}") root.bind("<ButtonPress-1>", on_press) root.bind("<B1-Motion>", on_drag)这段代码有个细节很容易翻车:拖拽计算用的是 event.x 而不是 event.x_root。event.x 是鼠标相对当前窗口左上角的坐标,winfo_x() 是窗口相对屏幕左上角的坐标,两者结合才能得到正确的新坐标;如果直接用 x_root,第一次移动会「跳一下」,因为 x_root 是绝对屏幕坐标,没有减去窗口原位置。某开发者第一次写的时候用 x_root 实现,拖一次宠物就瞬移到鼠标旁边,就是这个原因。
另外拖拽时不要传尺寸参数。geometry(f"+{x}+{y}") 只改坐标不改尺寸,Tk 会保持原窗口大小;如果误写成 f"{x}x{y}",窗口会被重置成默认大小,宠物瞬间变小或者变成 1 像素宽。
3.3 右键菜单与退出:tk_popup 需要全局坐标
右键菜单常用 Menu 组件挂在 root 上,关键是弹出位置的参数必须是全局坐标 event.x_root 和 event.y_root。如果误用 event.x,菜单会弹在窗口左上角附近,位置完全没法用。
menu = tk.Menu(root, tearoff=0) menu.add_command(label="切换状态", command=lambda: pet.set_state("walk")) menu.add_command(label="退出", command=root.destroy) def show_menu(event): menu.tk_popup(event.x_root, event.y_root) root.bind("<Button-3>", show_menu)注意 tk_popup 之后,少数情况下菜单不自动消失,表现为卡住一个菜单残影。常见做法是给每个 command 包一层 try/finally 调用 grab_release,或者干脆不处理,Windows 下大部分情况点击其他区域会自动关闭。如果频繁出现菜单不消失,优先检查是否还有别的 bind 拦截了 事件。
跑到这里,骨架已经齐了:透明窗口、置顶、拖拽、右键菜单。确认这四点正常后,再进入第 4 章的「活起来」环节。
4. 让宠物活起来:状态机设计与随机动作的切换逻辑
4.1 用 after 驱动帧切换,而不是 for 循环或 time.sleep
桌面宠物的动画循环是状态机驱动的:每个状态有自己的帧序列、帧间隔和触发条件。tkinter 是单线程事件循环,动画帧切换的正确做法是用 root.after(ms, callback) 注册下一次回调,而不是用 while 循环加 time.sleep。原因很简单:time.sleep 会阻塞 Tk 的事件处理,主循环被卡死后,窗口无法重绘、鼠标事件无法响应,宠物会像死机一样定住。
下面是一个极简的帧切换类,重点在 loop 方法里如何递进帧号和重新注册回调:
import tkinter as tk STATES = { "idle": {"frames": 4, "interval": 180}, "walk": {"frames": 8, "interval": 90}, "sleep": {"frames": 2, "interval": 500}, "click": {"frames": 3, "interval": 80}, } class Pet: def __init__(self, root): self.root = root self.state = "idle" self.frame_index = 0 self.photo = None # 必须持有引用,否则图片被回收 self.label = tk.Label(root, bg="magenta") self.label.pack() def set_state(self, name): if name in STATES: self.state = name self.frame_index = 0 def loop(self): frames = STATES[self.state]["frames"] path = f"assets/{self.state}_{self.frame_index + 1:02d}.png" self.photo = tk.PhotoImage(file=path) # 重新赋值给自己引用 self.label.config(image=self.photo) self.frame_index = (self.frame_index + 1) % frames interval = STATES[self.state]["interval"] self.root.after(interval, self.loop)逻辑说明:每次 loop 先根据当前状态和帧号拼出图片路径,用 tk.PhotoImage 读图并赋给 self.photo,避免图片对象被垃圾回收;然后帧号加一并对帧数取模,实现循环播放;最后用 after 注册下一次 loop。注意帧号格式化用了 02d,对应 idle_01.png 这样的两位数字文件名,如果素材是 idle_1.png,去掉 02 即可。
参数说明:interval 单位是毫秒,90 毫秒约等于每秒 11 帧,对走路来说观感自然;180 毫秒适合待机眨眼;sleep 的 500 毫秒适合睡觉状态,变化不明显。这些参数不是标准答案,取决于素材动作幅度,动作大的帧间隔要短,动作小的可以放长。
这里有个新手最容易翻车的地方:tk.PhotoImage(file=...) 每次循环都读一次磁盘文件,是 I/O 操作。帧数少还好,如果是 200x200 的大图,持续切帧会把一个 CPU 核心吃满。我一般建议初始化时把所有帧一次性读进内存,存成字典,loop 只做索引切换:
def load_all_frames(self): self.frames = {} for state, cfg in STATES.items(): frames = [] for i in range(cfg["frames"]): path = f"assets/{state}_{i + 1:02d}.png" frames.append(tk.PhotoImage(file=path)) self.frames[state] = frames这样内存多占用几十 KB,换来 CPU 占用大幅下降,对常驻进程来说是必要优化,第 5 章的 CPU 问题也与此相关。
4.2 状态切换:待机、走路、睡觉、点击反馈四种状态怎么联动
状态机不能只是切帧,还要有状态之间的转换规则。一个常见的宠物状态集合是:idle(待机,眨眼、左右看)、walk(走路,朝一个方向移动)、sleep(睡觉)、click(被点击时的反馈动作)。各状态的触发条件和行为如下表:
| 状态 | 触发条件 | 帧数 | 帧间隔 | 行为说明 |
|---|---|---|---|---|
| idle | 默认状态 | 4 | 180ms | 原地轻微晃动 |
| walk | 随机触发或点击后触发 | 8 | 90ms | 朝左右移动一段距离 |
| sleep | 长时间无操作 | 2 | 500ms | 垂眼,几乎不动 |
| click | 鼠标左键点击 | 3 | 80ms | 快速反馈动作后回到 idle |
状态切换的代码在 set_state 中完成,但要注意:切换时要把 frame_index 归零,否则从 idle 的第 4 帧直接跳到 click 的第 4 帧,动作衔接会断层。某开发者第一次没归零,宠物点击反馈经常从「跳起的最后一帧」开始播,看起来像闪了一下,排查半天才发现是帧号没有重置。
4.3 随机动作与节奏控制:别让宠物变成「多动症」
桌面宠物的观感很大程度取决于随机动作的频率。如果每 2 秒就随机 walk 一次,宠物会显得很躁;一直 idle 又像贴图。我常用的做法是维护一个「行为调度器」,每隔一段时间从状态表里按权重抽取下一个状态。
import random ACTION_WEIGHTS = { "idle": 60, "walk": 25, "sleep": 10, "click": 5, } def schedule(self): if self.state == "sleep": self.root.after(2000, self.schedule) # 睡着时等 2 秒再重新调度 return r = random.choices(list(ACTION_WEIGHTS), weights=ACTION_WEIGHTS.values())[0] self.set_state(r) self.root.after(random.randint(3000, 8000), self.schedule)这个调度器与动画 loop 分开运行,两者都用 after 注册,互不阻塞。random.choices 按权重抽取,idle 占 60%,walk 占 25%,sleep 和 click 权重低,保证宠物整体以安静为主,偶尔走两步、偶尔睡一觉,观感自然。权重数值不是玄学,是调出来的:先跑 5 分钟观察宠物行为分布,动太多就把 walk 权重降 5,太死板就加 5,没有标准答案。
sleep 状态的退出条件要单独处理。上面代码里 sleep 会在 2 秒后重新调度,宠物睡一小会儿就醒,而不是睡到天荒地老。如果你希望鼠标点击能强制唤醒,可以在 bind 里监听鼠标事件并 set_state("idle")。交互反馈一般做成「点击时先播放 click 动画,播完回到 idle」,实现可以简单一点:点击后 set_state("click"),在 click 对应的 loop 里判断帧号为 0 时自动回到 idle。
5. 避坑清单:透明色残留、卡顿与多屏错位的五个典型问题
这些坑都是真实踩过的,每条按「现象 → 原因 → 解决」写清楚,按顺序排查能省不少时间。
5.1 宠物旁边出现彩色方块或黑边
现象:透明窗口设置后,宠物背景区域有淡紫红色或黑色描边,怎么调透明色都消不干净。
原因:transparentcolor 的键控色只能处理纯色像素。如果窗口背景是 magenta,而 PNG 图片边缘有半透明抗锯齿像素,比如灰色或淡粉色,这些像素既不是纯 magenta 也不是完全透明,系统不会抠掉,于是显示成脏边。另一种情况是素材本身是 JPEG,JPEG 没有 alpha 通道,压缩产生的杂色块全部暴露。
解决:素材必须用带 alpha 通道的 PNG,边缘抗锯齿由 PNG 自身的 alpha 处理,键控色只管窗口底色。如果素材是硬边,可以用 Pillow 做一次去边,移除边缘 1~2 像素的杂色后再导出。另外透明键控色要选一个与素材调色板完全无关的颜色,素材整体偏暖就选纯青色 00ffff,素材偏冷就选纯紫红,防止误抠。
5.2 动画运行时 CPU 占用高,风扇起飞
现象:宠物静止时 CPU 占用正常,一旦走路或不断切帧,CPU 占用从 2% 飙到 15% 以上。
原因:最常见是循环里每次都 tk.PhotoImage(file=...) 读磁盘;另一个是素材尺寸过大,比如 512x512 的大图,每次切帧都要重新采样绘制。
解决:初始化时把全部帧读进内存,动画循环只做索引访问,这是最有效的优化。同时检查素材显示尺寸,桌面宠物 128~160 像素完全够看,超过 256 就是在燃烧 CPU。还有一个容易被忽视的:如果机器开了录屏或远程桌面,帧率被人为拉高,CPU 上升属于环境因素,不算代码问题。
5.3 点击透明区域时,下面窗口收不到鼠标
现象:宠物旁边有一大片「看不见」的透明区域,鼠标点在透明区域上,下面的 IDE 或浏览器无法点击。
原因:透明键控只是视觉上把像素抠掉了,窗口的热区仍然是整块矩形。tkinter 没有「透明区域不参与命中」的能力,宠物窗口矩形范围内,鼠标事件全被宠物窗口接收。
解决:这是桌面宠物最需要管理预期的地方。如果只是自用,把窗口尺寸严格压到图片大小,透明区域很小,影响可忽略;要做到完美穿透,需要调用 Windows API 设置镂空命中区域,tkinter 做不了,得用 ctypes 调 SetWindowRgn 或引入 pywin32。我一般优先把窗口做小,不追求完美穿透,毕竟桌面宠物要接收鼠标拖拽和点击。
5.4 打包成 exe 后,宠物图片加载失败
现象:源码运行时一切正常,用 PyInstaller 打包后双击 exe,窗口白屏或直接报错找不到图片。
原因:PyInstaller 打包后,exe 会在系统临时目录里解压运行,代码里的相对路径 assets/xxx.png 指向的是临时目录下不存在的路径,图片自然加载不到。这是运行环境变化导致的问题,不是代码逻辑问题。
解决:用打包工具提供的资源路径函数来拼路径。常见做法是判断 sys.frozen:
import sys, os def resource_path(relative): base = getattr(sys, "_MEIPASS", os.path.abspath(".")) return os.path.join(base, relative)这个函数源码运行时,getattr 取不到 _MEIPASS,回退到脚本所在目录;exe 环境里,_MEIPASS 指向解压目录。所有图片路径改成 resource_path("assets/idle_01.png"),打包时再用 --add-data 把 assets 目录带进去,问题就消失了。注意 --add-data 在 Windows 下用分号分隔源路径和目标路径,在 Linux 或 macOS 下用冒号。
5.5 多线程调用 Tk 导致窗口假死或闪退
现象:为了播放声音或后台下载资源,开了 threading.Thread,在线程里调用 label.config 或 root.after,结果窗口偶尔卡死,甚至闪退。
原因:tkinter 不是线程安全的,所有 UI 操作必须在主线程进行。子线程直接操作控件,轻则状态错乱,重则底层崩溃。很多人报「tkinter 不能刷新界面」,九成是踩了这个坑。
解决:子线程不要碰任何 Tk 对象,用队列把数据传给主线程,主线程通过 root.after 定时取数据并更新界面。标准写法:
import queue msg_queue = queue.Queue() def worker(): # do something heavy msg_queue.put("update_text") def poll_queue(): try: msg = msg_queue.get_nowait() label.config(text=msg) except queue.Empty: pass root.after(50, poll_queue)poll_queue 用 after 注册在主线程的事件循环里,只做 UI 更新,不阻塞任何线程。某开发者在宠物项目里加了个「喂食」功能,子线程跑下载任务,最初直接在子线程里调 label.config 导致程序不定期闪退,改成队列后就稳定了。这条规则对所有 tkinter 项目都适用。
提示:子线程更新 Tk 界面是 tkinter 项目的高频崩溃点,队列方案是通用解法。
6. 进阶玩法:托盘驻留、开机自启与单文件打包
6.1 托盘驻留与开机自启:两个小细节
宠物默认只有右键菜单退出,常驻后如果想收起而不是退出,可以加托盘图标。常见做法是用 pystray 配合 Pillow 加一个退出菜单项,pystray 有独立后台线程,不会阻塞主循环。开机自启我习惯用最简单的方式:在系统启动文件夹里放一个 exe 副本,开发环境下用 sys.executable 判断当前运行文件是否存在,配合 sys.frozen 区分源码态和打包态。
6.2 PyInstaller 打包命令与资源路径
打包时要把 assets 目录一起带进去:
pyinstaller --onefile --windowed --add-data "assets;assets" desktop_pet.pyWindows 下 --add-data 用分号分隔源与目标,其他地方用冒号。代码里所有加载图片的地方换成 resource_path 包装后的路径,打包后资源才能正确解压。
6.3 验证清单与我的固定习惯
最后给一份每次发版前都要走的验证清单:透明背景干净、拖拽无跳动、无操作 5 分钟后进入睡眠、右键菜单稳定弹出、打包后换一台机器能跑、挂机 1 小时内存不涨。某开发者有一段时间总是只测一分钟就发出去,结果朋友电脑上透明色残留严重,那次之后他给自己定了条铁律:任何常驻类 Python 小工具,必须打包后换机验证透明效果和内存曲线,确认不翻车才叫完成。
从那以后我每次写完桌面宠物这类小工具,都会强制走一遍「打包 → 换机运行 → 挂机一小时」的流程,把透明色、帧率和内存都确认一遍才收工。希望帮到你。
本文还有配套的精品资源,点击获取