简介:利用Java开发桌面宠物的小例子,面向初学Java GUI的开发者,适合想了解Swing窗口、Graphics2D绘图与事件处理的读者。项目演示了使用JFrame和JPanel搭建主窗口、扩展自定义组件、重写paintComponent绘制宠物形象,以及实现鼠标拖拽、定时动画与多线程刷新的完整流程,并提供了资源加载和MVC结构的可参考思路。压缩包内共80个文件,约122KB,包含2个Java源文件、多个class编译产物、13张宠物图片jpg、Eclipse工程配置与Git元数据,目录划分为源码、编译输出和图片等模块,便于对照学习。已有2643人学习下载。通过阅读源码与图片素材,可以掌握paintComponent自定义绘制、MouseListener鼠标交互、javax.swing.Timer动画循环以及ImageIcon加载图片等核心技能,适合作为入门Java桌面动画与交互编程的练手项目。
1. 桌面宠物这种小例子,为什么值得用 Java 写
想在桌面养一只宠物,看着它睡觉、打哈欠、被鼠标拎起来乱晃?这个需求听上去挺没用的,但真把它拆开,你会发现它几乎把 Java 桌面开发要碰的东西全占了:无边框透明窗口、置顶、鼠标事件、帧动画、多线程调度、系统托盘。我见过不少 java 学习路线里把桌面宠物当作业,也有人拿它当面向对象编程的练手项目。这篇就给你一个能直接跑起来的小例子,从最朴素的 Swing 窗口开始,一点一点加动画、加交互、加打包,顺便把我踩过的坑都标出来。适合刚啃完 java 基础、想找个小而完整的项目练手的人,也适合那种想在摸鱼时有个东西陪自己的。
2. 先跑通最小窗口:无边框、透明、置顶和鼠标拖动
桌面宠物本质上不是普通应用窗口,而是一张“贴在屏幕上的贴纸”。很多第一次写的人会直接用 JFrame,然后一路跟标题栏、任务栏、焦点做斗争。我一般先用 JWindow 起手,它没有标题栏也没有任务栏按钮,天生适合做这种装饰性窗口。先跑通一个能显示图片、能拖动、能记住位置的骨架,后面所有动画和交互都在这上面加。
2.1 用 JWindow/JFrame 搭出宠物骨架:不用布局管理器反而省事
先给一个最少代码的透明窗口。这段代码也是后面所有版本的基座:窗口完全透明,里面放一个 JLabel,图片填满标签,标签填满窗口。如果你手头没有 GIF,随便放一张 png 也能跑,后面再补动画。
import javax.swing.*; import java.awt.*; import java.awt.event.MouseAdapter; import java.awt.event.MouseEvent; public class DesktopPet { private JWindow window; public DesktopPet() { window = new JWindow(); window.setType(Window.Type.UTILITY); // 不抢任务栏焦点,不出现在 Alt+Tab window.setBackground(new Color(0, 0, 0, 0)); // 完全透明背景 window.setAlwaysOnTop(true); // 保持在桌面最前 window.setSize(120, 140); window.setLocation(200, 300); JLabel label = new JLabel(new ImageIcon("pet_idle.gif")); window.add(label); window.setVisible(true); } public static void main(String[] args) { SwingUtilities.invokeLater(DesktopPet::new); } }代码里 setType(Window.Type.UTILITY) 是很多 Java 桌面教程不会提但桌面宠物必须加的一行:它告诉窗口系统这是一个工具窗口,在 Windows 上不会出现在 Alt+Tab 切换列表里,也不会抢任务栏空间。setBackground(new Color(0, 0, 0, 0)) 是真正的“透明背景”,注意是 RGB 为 0、alpha 为 0 的颜色,不要写成 new Color(0, 0, 0),那个是纯黑不透明。setAlwaysOnTop(true) 在 Windows 和 macOS 上基本可靠,但在部分 Linux 的 Wayland 会话下可能失效,后面避坑章会专门说。
setSize(120, 140) 不一定要和图片像素完全一致,但我建议先量好 GIF 的实际尺寸再设窗口,否则 JLabel 会把图标拉伸变形。Swing 的默认布局是 BorderLayout,JLabel 放进去会占满整个内容区,图片会跟着 JLabel 拉伸,如果不想要拉伸,可以给 JLabel 设一个固定尺寸 setPreferredSize,然后窗口 pack()。不过最简单的是直接把窗口大小设成图片像素,这个例子就这么做。
这里还有一个更稳妥的做法:用 JFrame 加 setUndecorated(true) 也能得到类似的窗口,而且 JFrame 自带的可访问支持更完整。但 JWindow 省掉了关闭按钮、系统菜单等无关要素,桌面宠物本来就不应该让用户去点最小化,这也是为什么很多开源项目用 JWindow。如果你后续要在宠物窗口上放 JavaFX 组件,那就另说,纯 Java 2D 图形和 Swing 组件这个例子用 JWindow 足够。
先验证这个骨架能不能跑:编译后运行,桌面应该出现一张透明背景的图片,它不会被其他窗口挡住。如果出现黑色或白色长方形,先检查 setBackground 有没有在 setVisible 之前调用,或者你所在的系统不支持每像素透明度,具体排查在第 5 章。
2.2 鼠标拖动和坐标记忆:让宠物不会跑出屏幕
透明窗口能显示只是第一步,不能拖的东西只能叫贴纸,不叫宠物。拖动的核心是让窗口坐标跟随鼠标,但注意不要直接 window.setLocation(e.getXOnScreen(), e.getYOnScreen()),那样老鼠会“瞬移”到鼠标左上角,因为鼠标点在图片内部的位置没有被扣除。正确的做法是记录鼠标按下时相对于窗户左上角的偏移,拖动时用鼠标的屏幕坐标减去这个偏移。
import javax.swing.*; import java.awt.*; import java.awt.event.*; import java.io.*; import java.util.Properties; public class DesktopPet { private JWindow window; private Point pressPoint; // 鼠标按下时相对于窗口左上角的偏移 public DesktopPet() { // ... 窗口初始化代码同上 ... JLabel label = new JLabel(new ImageIcon("pet_idle.gif")); window.add(label); // 记录按下点 label.addMouseListener(new MouseAdapter() { @Override public void mousePressed(MouseEvent e) { pressPoint = e.getPoint(); } @Override public void mouseReleased(MouseEvent e) { savePosition(); // 松手时保存位置 } }); // 拖动窗口 label.addMouseMotionListener(new MouseMotionAdapter() { @Override public void mouseDragged(MouseEvent e) { if (pressPoint == null) return; Point now = MouseInfo.getPointerInfo().getLocation(); int x = now.x - pressPoint.x; int y = now.y - pressPoint.y; // 取当前窗口所在显示器的边界,多屏下不会跑丢 Rectangle screen = window.getGraphicsConfiguration().getBounds(); x = Math.max(screen.x, Math.min(x, screen.x + screen.width - window.getWidth())); y = Math.max(screen.y, Math.min(y, screen.y + screen.height - window.getHeight())); window.setLocation(x, y); window.repaint(); // 部分系统需要手动刷新,避免残影 } }); window.setVisible(true); loadPosition(); // 启动时恢复上次位置 } private void savePosition() { try { Properties props = new Properties(); props.setProperty("pet.x", String.valueOf(window.getX())); props.setProperty("pet.y", String.valueOf(window.getY())); File file = new File(System.getProperty("user.home"), ".pet.properties"); try (OutputStream out = new FileOutputStream(file)) { props.store(out, "Desktop Pet Position"); } } catch (IOException ignored) { } } private void loadPosition() { File file = new File(System.getProperty("user.home"), ".pet.properties"); if (!file.exists()) return; try (InputStream in = new FileInputStream(file)) { Properties props = new Properties(); props.load(in); int x = Integer.parseInt(props.getProperty("pet.x", "200")); int y = Integer.parseInt(props.getProperty("pet.y", "300")); window.setLocation(x, y); } catch (IOException | NumberFormatException ignored) { } } }这段代码的 pressPoint 就是按下时的偏移点,mouseDragged 里用 MouseInfo 实时取鼠标屏幕坐标,这样即使鼠标移出窗口也还能继续拖,不会丢事件。边界的两行用一个 min/max 把窗口限制在屏幕边界以内,防止宠物被拖得只剩一条边在外面。注意这里用的是 window.getGraphicsConfiguration().getBounds() 而不是 Toolkit.getDefaultToolkit().getScreenSize(),前者在多显示器环境下能取到当前窗口所在那个显示器的矩形,后者只会返回主屏尺寸,多屏用户拖到副屏时就翻车了。
savePosition 和 loadPosition 用 Properties 把坐标存进用户目录。不要把配置写进当前工作目录,很多教程图省事写 new File("pet.properties"),换工作目录启动就丢了,用 System.getProperty("user.home") 才靠谱。Properties.load/store 对桌面宠物这种小型配置足够,不需要引入 JSON 依赖。文件格式就是普通的 key=value,用户如果想改初始位置可以直接编辑。
如果你想把“别跑出屏幕”做得更细腻一点,可以在 mouseDragged 里同时限制 x 不小于屏幕左边界,y 不小于屏幕上边界,并考虑任务栏高度。但那是优化,第 5 章会展开讲这个边界问题。先跑通拖动和记忆,你已经完成桌面宠物 60% 的工作了。
3. 让宠物动起来:把静态图标变成帧动画
能拖动的贴纸开始像个宠物了,但它一动不动的话还是缺点灵魂。这里给你两条路:一条是直接用 GIF,一条是拆帧序列自己用 Timer 驱动。两条路各有各的适用场景,我在实际做的时候会按项目需求选。GIF 适合快速验证,帧序列适合要精细控制动作和状态切换的场景。
3.1 两种动画方案:GIF 懒人法和 Timer 帧序列法
GIF 是最省事的:JLabel 的 setIcon(new ImageIcon("pet.gif")) 就能直接播放,Swing 的 ImageIcon 内置了 GIF 解码和帧循环,不需要你写任何线程代码。但它的缺点也很明显:帧率是 GIF 文件自己定的,你没法在程序里让动作变快变慢;而且很多从网上下载的 GIF 尺寸小得可怜,放到桌面上全是马赛克。还有一个隐蔽问题:部分 GIF 只循环一次就停了,看起来像是死掉了。
所以我更推荐用帧序列。去任何一个免费像素素材站下载动作序列,或者用工具把 GIF 拆成一张张 PNG,然后用 JPanel 自己画。下面这个类就是一个循环播放的帧动画面板:传入图片前缀和数量,它会按固定间隔切换帧。
import javax.swing.*; import java.awt.*; public class PetPanel extends JPanel { private Image[] frames; private int current; private Timer timer; /** * @param prefix 图片前缀,如 "walk_" * @param count 帧数量,如 4,即 walk_0.png ~ walk_3.png * @param interval 每帧间隔(毫秒),建议 120 ~ 200 */ public PetPanel(String prefix, int count, int interval) { frames = new Image[count]; for (int i = 0; i < count; i++) { frames[i] = new ImageIcon(prefix + i + ".png").getImage(); } timer = new Timer(interval, e -> { current = (current + 1) % count; repaint(); }); timer.start(); } @Override protected void paintComponent(Graphics g) { super.paintComponent(g); g.drawImage(frames[current], 0, 0, getWidth(), getHeight(), null); } }这里我特意用 Image[] 而不是 ImageIcon[]:paintComponent 里的 drawImage 可以直接缩放绘制,而 ImageIcon 的 paintIcon 在某些 JDK 版本上会多一层封装,绘制效率略低。Timer 的回调在 EDT 上执行,所以 current 变量不需要额外加锁,但如果你后面把动画线程切成 ScheduledExecutorService,就必须用 SwingUtilities.invokeLater 包装,否则会在非 EDT 线程刷新界面,轻则闪烁,重则抛出 NotOnEventThreadException。
间隔时间这个参数很关键,我一般把 interval 设在 120 到 200 毫秒:小于 80 毫秒基本看不出细节,反而把 CPU 占用拉高;大于 250 毫秒就会觉得宠物在演 PPT。帧数量也要控制,一个走路动作 4 到 8 帧就够了,12 帧以上肉眼不会觉得更细腻,内存倒是实打实多占几倍。如果你的是高清 PNG(例如 512x512),建议用 ImageIO.read 而不是 ImageIcon 的构造方法,两者都要加载磁盘文件,但 ImageIO 能更明确地让你做缓冲和缩放。
3.2 用 ScheduledExecutorService 做定时随机动作
宠物不能只会走路,它还要会发呆、睡觉、伸懒腰。用一个定时任务线程池去驱动“随机切换状态”,比在 Swing Timer 里写复杂逻辑要干净。我一般这样设计:定义一个枚举 PetAction,里面放 IDLE、WALK、SLEEP,然后在一个公共的 setAction 方法里切换显示的帧序列。调度线程每隔几秒随机选一个动作,通过 SwingUtilities.invokeLater 改 UI。
public enum PetAction { IDLE, WALK, SLEEP } // 核心动作切换方法 public void setAction(PetAction action) { switch (action) { case IDLE: changePanel("idle_", 2, 300); // 发呆帧少,间隔可以慢 break; case WALK: changePanel("walk_", 4, 150); // 走路帧多,间隔要快 break; case SLEEP: changePanel("sleep_", 2, 500); // 睡觉动作基本是呼吸起伏 break; } } private void changePanel(String prefix, int count, int interval) { // 移除旧面板并新建 PetPanel,或者复用面板并更新内部帧数组 // 若复用面板,记得旧 Timer 要 stop(),否则动画会叠着跑 } // 调度线程 ScheduledExecutorService executor = Executors.newSingleThreadScheduledExecutor(); Random random = new Random(); executor.scheduleAtFixedRate(() -> { PetAction[] actions = PetAction.values(); PetAction next = actions[random.nextInt(actions.length)]; SwingUtilities.invokeLater(() -> setAction(next)); }, 3, 10, TimeUnit.SECONDS);这段代码里有两个点要特别说明。第一个是 changePanel 里必须把旧 PetPanel 的 Timer 停掉。很多人第一次写时直接往容器里 add 新面板,旧面板的动画线程还在跑,结果 CPU 一路飙升,窗口上叠着一堆残影。第二个是随机动作不能这么粗暴,因为真实宠物的行为是有“惯性”的:它不可能从走路瞬间切到睡觉,中间应该有个“坐下”的过渡动画。你可以定义一个简单的状态机,例如从 IDLE 到 WALK 需要先播放过渡帧,到 SLEEP 前要先趴下,这些在 setAction 里做分支处理就行。
ScheduledExecutorService 的 random 动作目前是每 10 秒触发一次,测试时可以改成 3 秒方便看效果。正式使用时建议把随机种子去掉,让每次启动动作序列不一样。另外,executor 这个线程池记得在窗口关闭时 shutdown,否则 Java 进程不会退出——这个问题我拦过不少同事的代码,他们关掉宠物窗口后,任务管理器里 java.exe 还挂了一晚上。
4. 交互与托盘:右键菜单、随机状态和系统托盘
动画跑起来后,宠物已经有了“活”的感觉。但一个桌面宠物如果连右键菜单都没有,用户想让它换动作只能干瞪眼。这章加两个最实用的交互:右键菜单切换动作,以及系统托盘常驻。这两种交互都是 Java Swing/Java AWT 的标准功能,不需要额外引入依赖。
4.1 右键菜单切换动作,比按钮更符合桌面宠物习惯
桌面宠物不需要按钮,它需要像一个桌面小程序一样,右键点一下弹出菜单。最简洁的做法是给 JLabel 设置 setComponentPopupMenu,让 Swing 自己处理鼠标事件。
import javax.swing.*; JPopupMenu menu = new JPopupMenu(); JMenuItem idleItem = new JMenuItem("发呆"); idleItem.addActionListener(e -> setAction(PetAction.IDLE)); menu.add(idleItem); JMenuItem walkItem = new JMenuItem("走路"); walkItem.addActionListener(e -> setAction(PetAction.WALK)); menu.add(walkItem); JMenuItem sleepItem = new JMenuItem("睡觉"); sleepItem.addActionListener(e -> setAction(PetAction.SLEEP)); menu.add(sleepItem); menu.addSeparator(); JMenuItem exitItem = new JMenuItem("退出"); exitItem.addActionListener(e -> { if (SystemTray.isSupported()) SystemTray.getSystemTray().remove(trayIcon); executor.shutdown(); System.exit(0); }); menu.add(exitItem); label.setComponentPopupMenu(menu);setComponentPopupMenu 会自动判断鼠标左键还是右键,常规菜单是右键弹出,但在 mac 上 Command+点击也会触发。如果你希望双击左键也能换动作,可以再加 mouseClicked 监听器里判断 e.getClickCount() == 2。这里有一个小细节:JPopupMenu 在透明窗口上弹出时,菜单本身会强制拉一个不透明的原生窗口,所以不用担心菜单变透明看不见。
菜单项命名尽量用动作描述而不是代码里的英文枚举:用户看到“切换走路”一定比“PetAction.WALK”友好。如果你想做“随机动作”菜单,可以直接往里加一个 JMenuItem,触发时执行调度线程里的随机逻辑,这样用户也能手动体验随机状态切换。注意菜单里的所有 UI 操作都在 EDT 上,不要在 actionPerformed 里 sleep 或加载大图,否则整个菜单会卡住。
4.2 加入系统托盘,避免宠物被误杀
桌面宠物最烦人的事之一就是不小心退出就再也没了。正确做法是加入系统托盘:用户可以“隐藏”宠物到托盘,右键再选“显示”找回来。Java 的 SystemTray 是 AWT 的 API,和 Swing 混用时要小心事件线程,托盘菜单用的是 AWT 的 PopupMenu 和 MenuItem,不要直接拿 JPopupMenu 去给 TrayIcon 用,那是不能跨 API 的。
import java.awt.*; import java.awt.event.*; import javax.swing.*; if (SystemTray.isSupported()) { TrayIcon trayIcon = new TrayIcon( new ImageIcon("pet_icon_32.png").getImage(), "桌面宠物 - 右键菜单" ); PopupMenu trayMenu = new PopupMenu(); MenuItem showItem = new MenuItem("显示宠物"); showItem.addActionListener(e -> SwingUtilities.invokeLater(() -> { window.setVisible(true); window.setAlwaysOnTop(true); })); trayMenu.add(showItem); MenuItem exitItem = new MenuItem("退出"); exitItem.addActionListener(e -> { SystemTray.getSystemTray().remove(trayIcon); executor.shutdown(); window.dispose(); System.exit(0); }); trayMenu.add(exitItem); trayIcon.setPopupMenu(trayMenu); trayIcon.setImageAutoSize(true); try { SystemTray.getSystemTray().add(trayIcon); } catch (AWTException ex) { System.err.println("托盘添加失败:" + ex.getMessage()); } }托盘菜单里最好不要放“显示/隐藏”两个选项,只放一个“显示宠物”就够,因为用户隐藏宠物后托盘图标就是唯一的入口。trayIcon.setImageAutoSize(true) 可以让你用任意尺寸的图标,系统会自动缩放,但 16x16 和 32x32 是 Windows 托盘图标的黄金尺寸,建议直接做两个尺寸,不要依赖缩放。
如果你同时设置了 JWindow 的关闭事件,请注意:桌面宠物没有标题栏,正常关闭只能通过右键菜单的“退出”,但会出现另一个问题——当你调用 window.dispose() 后,再次 window.setVisible(true) 时会得到一个已经销毁的窗口,导致异常。所以在“显示宠物”里不要用 dispose 后的窗口对象,而是始终保证同一个 JWindow 实例,只是在隐藏时 setVisible(false),在退出时才 dispose。这是新手常犯的错,我有一个同事把窗口 dispose 又 setVisible,搞出两次 Java 异常才意识到问题。
5. 避开这些坑:透明窗口、多屏、DPI 和 CPU 占用
写到这儿,一个能跑、能动、能交互的桌面宠物已经在桌面上了。但真正从“能用”到“好用”,中间隔着的是几台不同机器上的实测。这一章把我踩过的和帮别人排查过的问题整理一下,每条都是真实见过的现象,不是抄文档。
5.1 透明窗口变黑/白底,或者鼠标“穿透”到后面
现象:运行后窗口不是透明背景,而是一个黑色矩形或白色矩形;另一种情况是鼠标点宠物的透明区域,直接点到桌面上的图标。
原因:第一种多半是 setBackground(new Color(0,0,0,0)) 没有被系统正确识别,或者被放在了 setVisible 之后。第二种是窗口本身没有启用“点击穿透”机制,透明区域仍属于窗口命中区。
解决:先严格保证“先 setBackground,再 setVisible”的顺序,且 setBackground 要用带 alpha 的颜色。如果用的是 JFrame,配合 setUndecorated(true) 和 setOpacity(1.0f) 一起试。有些 JDK 在 Windows 上对 JWindow 的透明支持更好,所以我建议从 JWindow 起步。点击穿透这个需求本身和“拖动窗口”冲突,因为一旦穿透,鼠标事件就留给下面的窗口,宠物也拖不动了。如果你的宠物有大量透明镂空区域,又想让它能拖动,可以把鼠标事件绑定在 JLabel 的图片上,而不是 JPanel 上;或者给透明区域画一个不可见但可点击的像素块,这不完美,但够用。
5.2 多显示器坐标越界与任务栏遮挡
现象:把宠物拖到副屏上,重启后宠物不见了;或者拖到屏幕底部,被任务栏挡住一半,点不到右键菜单。
原因:保存的坐标是绝对值,但显示器配置变了,副屏可能没插电;边界判断只用了 Toolkit.getScreenSize(),它只返回主屏尺寸,副屏上的边界检查全部失效。
解决:保存位置时建议记录当前的显示器索引(通过 Window.getGraphicsConfiguration().getDevice()),启动时遍历所有 GraphicsEnvironment.getLocalGraphicsEnvironment().getScreenDevices(),如果保存的坐标落在某个显示器的 bounds 内,就用它;否则放到主屏中央。任务栏的问题需要获取 Insets:Toolkit.getDefaultToolkit().getScreenInsets(window.getGraphicsConfiguration()),然后用 insets.bottom 减去屏幕高度,限制最大 y。这样在 Windows 上宠物永远不会被任务栏吞进去。
// 获取屏幕可用区域(去掉任务栏) Rectangle screen = window.getGraphicsConfiguration().getBounds(); Insets insets = Toolkit.getDefaultToolkit().getScreenInsets(window.getGraphicsConfiguration()); int maxY = screen.y + screen.height - insets.bottom - window.getHeight(); int maxX = screen.x + screen.width - insets.right - window.getWidth(); window.setLocation(Math.min(Math.max(x, screen.x), maxX), Math.min(Math.max(y, screen.y), maxY));5.3 GIF 循环、闪烁、内存泄漏与 CPU 飙升
现象:GIF 播一遍就停住;动画切换时界面闪烁;运行几小时后 CPU 占用常年 20% 以上。
原因:GIF 文件自带循环次数,有的只循环一次;JLabel 换 Icon 时,Swing 默认会先用背景色清一次区域,造成闪烁;Timer 的回调里反复 new ImageIcon 加载磁盘文件,或者动画面板的旧 Timer 没 stop,导致多个动画线程同时跑。
解决:GIF 循环问题要么找循环次数为 0 的素材,要么用 ImageIO 读入后自己拆帧,我最常用的是把 GIF 转成 PNG 序列,再用第 3 章的 PetPanel 播放,这样循环、帧率和缩放都掌握在自己手里。闪烁问题用重写 paintComponent 加双缓冲解决,Swing 的 JPanel 默认双缓冲,但如果你在 paintComponent 里直接 drawImage 一张一张画,仍然可能出现撕裂,这时可以在构造里 setDoubleBuffered(true),或者在 paint 前自己创建 BufferedImage 当离屏缓冲。CPU 飙升先用 jvisualvm 或 jcmd 看线程栈,重点搜 Timer 和 repaint,确认是否有两个动画定时器在跑。
5.4 setAlwaysOnTop 在某些 Linux 桌面失效
现象:同一个 jar 在 Windows 上置顶正常,在 Ubuntu 上运行,宠物总是被其他窗口盖住。
原因:Linux 的 X11/Wayland 窗口管理器对 always-on-top 提示的支持参差不齐,特别是 Wayland 协议下普通客户端无法强制置顶。
解决:不要求程序上做完美兼容。常见的处理是先调用 window.toFront() 再 setAlwaysOnTop(true),能解决一部分 X11 问题;Wayland 下只能建议用户改用 Windows 运行,或者放弃置顶需求。这个限制对桌面宠物不算致命,毕竟主要分发场景是 Windows。如果你确实需要跨平台,可以改用 JavaFX 的 Stage.setAlwaysOnTop,它在 Linux 上同样受制于窗口管理器,别抱太大期望。
5.5 多实例运行导致重叠和属性文件写入冲突
现象:用户多启动了几次程序,桌面上出现好几个宠物叠在一起,属性文件也被写乱。
原因:程序没有做单实例锁,Properties 文件被多个进程同时读写,后写进程覆盖前一个的位置记录。
解决:用 Java NIO 的文件锁在启动时占位。在用户目录创建一个 .pet.lock 文件,用 FileChannel.tryLock() 尝试加锁,拿不到锁就退出。顺便把启动位置改成随机偏移,即使真的出现异常多开,宠物也不会叠成一团。
File lockFile = new File(System.getProperty("user.home"), ".pet.lock"); try (FileChannel channel = FileChannel.open(lockFile.toPath(), StandardOpenOption.CREATE, StandardOpenOption.WRITE)) { FileLock lock = channel.tryLock(); if (lock == null) { System.err.println("已有桌面宠物实例在运行,退出"); return; } // 正常启动主逻辑 } catch (IOException e) { // 锁文件写入失败,可以选择继续运行或退出 }这个锁有两个局限:如果用户直接删掉了 .pet.lock,锁会失效;如果程序没有正常退出,操作系统会在进程结束时自动释放锁,所以不用担心僵尸锁。多实例问题解决后,你的桌面宠物基本达到了可以发给朋友试玩的稳定性。
6. 进阶验证:用 jpackage 打包成 exe,并在 100 台机器上测性能
能跑通只是开始,真正让别人用起来,你需要打包和验证两个动作。这里说一个我常用的最小流程:用 JDK 自带的 jpackage 生成免安装的 app-image,然后用一段简单的帧率打点代码确认动画流畅。
6.1 打包前检查的三件事
打包之前,先检查你的资源文件路径。桌面宠物的图片通常放在项目根目录的 resources 下,如果你直接用相对路径 new ImageIcon("pet_idle.gif"),在 IDE 里能跑,但打包后 jar 中的资源路径会变成 jar:/... 的二进制流,直接访问文件路径会失败。正确做法是统一走类加载器:getClass().getResource("/images/pet_idle.gif"),并把 resources 目录设为 jar 包的根。
第二件事是确认 JDK 版本。jpackage 是从 JDK 14 开始提供的,如果你还在用 JDK 8,只能退回去用 exe4j 或者手动打 jar 包。我的常用命令是:
jpackage --name DesktopPet --input target --main-jar pet.jar --main-class DesktopPet --type app-image --dest dist这条命令会在 dist 目录生成一个完整的 app 目录,里面有 exe 和运行时,双击就能跑。注意 jpackage 不会自动把你的资源文件打进镜像,你要么把 resources 里的图片复制到 input 目录,要么在 jar 里用 getResource 加载。图片尺寸建议都控制在 512x512 以下,否则打包出来的体积会占你 jar 包几十 MB,启动还变慢。
第三件事是验证打包后的目录结构。app-image 类型不会弹安装向导,适合直接发给用户;如果要 Windows 安装程序,用 --type exe,但需要系统装了 WiX 工具。我一般先出 app-image 自测,确认资源、动画、托盘都没问题了,再出安装包。
6.2 性能验证:帧率、CPU、内存的量化方法
很多人调动画全凭肉眼,觉得“看起来不卡”就完事。但桌面宠物要常驻后台,量化指标很重要。我一般监控两个数字:帧率和 CPU 占用。给 PetPanel 加一个最简单的帧率打点,不用额外工具,就在 paintComponent 里数调用次数:
private long lastFpsTime = System.nanoTime(); private long frameCount = 0; @Override protected void paintComponent(Graphics g) { super.paintComponent(g); frameCount++; long now = System.nanoTime(); if (now - lastFpsTime >= 1_000_000_000L) { // 每 1 秒统计一次 System.out.println("fps: " + frameCount); frameCount = 0; lastFpsTime = now; } g.drawImage(frames[current], 0, 0, getWidth(), getHeight(), null); }这个打印输出对调试有帮助,但不要留在最终版里,频繁写控制台本身也会吃 CPU。如果是间隔 150ms 的 4 帧动画,理想数字是每秒约 6 到 8 次刷新,因为一帧画面要持续 4 个 tick 才轮一圈。如果你看到 fps 在 20 以上,说明你的刷新间隔设置得太短,或者有多个动画线程在同时跑。CPU 占用用 Windows 自带的任务管理器看 java.exe 进程就够了,空闲状态应该低于 2%,动画状态不要超过 8%,如果超过,优先检查有没有旧 Timer 没停,这是最常见的翻车点。
我自己在写这个例子时,踩过最深的坑是透明窗口和坐标系,第一次用 getScreenSize 结果副屏直接跑飞。后来把显示器遍历和边界检查做成工具方法,才稳定下来。你如果照着这个流程做,不用等到打包,跑通动画那一步就能明显感觉到 Java 桌面的控制力比想象中要强。希望帮到你。
本文还有配套的精品资源,点击获取