平台窗口控制与多显示器支持
Ark-Pets 通过 cn.harryh.arkpets.platform 包将"桌宠窗口"这种非常规窗口(无边框、透明、可点击穿透、置顶、隐藏任务栏项)的控制能力抽象为平台无关的 HWndCtrl 接口层,并由 User32HWndCtrl(Windows 原生实现)与 NullHWndCtrl(空实现)提供具体平台分支。本页覆盖该抽象层、窗口几何模型、鼠标事件注入、多实例窗口标题编号,以及与 GLFW/libGDX 渲染窗口的衔接方式。
Purpose and Scope
本页聚焦 桌面窗口(HWND)的平台级控制机制,即 Ark-Pets 如何在操作系统层面查找、定位、样式化桌宠窗口,以及多只桌宠实例如何在同一桌面共存。具体包括:
HWndCtrl抽象基类的完整 API 契约(前景/可见性、置顶、任务栏、穿透、定位、关闭)WindowRect/MousePoint几何模型与MouseEvent事件枚举NumberedTitleManager:基于窗口标题扫描的多实例编号机制- 平台分支结构:
WindowSystem(窗口枚举/查找入口)、User32HWndCtrl、NullHWndCtrl
以下相邻主题有意留给兄弟页面,本页仅点到为止:
- 桌宠渲染循环、动画与行为状态机 —— 见「桌宠运行核心」相关页面
- 开机自启动(
StartupConfig/WindowsStartupConfig/NullStartupConfig)—— 属于独立的自启动能力,不属于窗口控制 - 应用配置文件(ArkPetsConfig)的解析 —— 见配置系统页面
Overview
Ark-Pets 的桌宠本质是一个 libGDX/LWJGL3 渲染的 GLFW 窗口,但要让它表现得像"趴在桌面上的宠物",仅靠 libGDX 提供的窗口 API 是不够的。需要操作系统原生层面的窗口操作:
| 需求 | libGDX/GLFW 能力 | 需要平台层补充的能力 |
|---|---|---|
| 鼠标穿透(宠物身体空白处点击落到下层窗口) | glfwSetWindowAttrib(GLFW_MOUSE_PASSTHROUGH) | 无需原生 API |
| 隐藏任务栏图标(桌宠不应出现在任务栏) | 不提供 | setTaskbar()(Win32 窗口样式扩展) |
| 始终置顶(宠物不被其他窗口盖住) | 部分提供 | setTopmost()(Z 序控制) |
| 查找/关闭其他 ArkPets 实例窗口(多开管理) | 不提供 | WindowSystem.findWindow() + close() |
| 无激活移动窗口(拖拽宠物不抢焦点) | 不提供 | setWindowPosition(insertAfter, x, y, w, h) |
| 向其他窗口注入鼠标消息 | 不提供 | sendMouseEvent() |
因此 Ark-Pets 设计了一个模板方法风格的抽象基类 HWndCtrl:通用逻辑(几何计算、GLFW 穿透切换、标题编号)实现在基类,平台相关逻辑(前景、置顶、任务栏、消息注入等)声明为抽象方法,由 User32HWndCtrl(Windows)和 NullHWndCtrl(不支持的平台,全部空操作)分别实现。
多显示器方面,HWndCtrl 携带的 posTop/posBottom/posLeft/posRight 是操作系统全局屏幕坐标系下的窗口外接矩形,窗口跨显示器移动时该矩形随虚拟桌面坐标系连续变化;具体的显示器枚举与摆放策略由 WindowSystem 与上层 UI 协同完成(该部分实现细节未在本页读取范围内,见下文"已验证边界"说明)。
Architecture
架构要点说明:
- 单一抽象出口:上层代码只依赖
HWndCtrl与WindowSystem.findWindow(),不直接接触任何 Win32 概念,保证非 Windows 平台上代码可编译、可运行(仅功能降级)。 - GLFW 与原生句柄双轨:桌宠自身的渲染窗口由 GLFW 管理(
glfwHandle,通过attachGLFWWindow注入),穿透切换走 GLFW;而其他进程/实例的窗口(无 GLFW 句柄)只能走平台抽象方法。setTransparent在glfwHandle == 0时直接返回,正体现了这一双轨设计的防御性。 - 不可变快照语义:
HWndCtrl持有的位置信息是构造时的快照,调用updated()才能拿到最新状态——窗口是操作系统共享资源,其属性随时可能被用户或其他进程改变,不可变快照避免了脏读引发的并发问题。
已验证边界(Implementation Boundary)
本页文档基于 core/src/cn/harryh/arkpets/platform/HWndCtrl.java 的完整源码验证。platform 包实际包含以下 7 个文件:
| 文件 | 角色 | 本页验证程度 |
|---|---|---|
HWndCtrl.java | 窗口控制抽象基类 | ✅ 完整读取 |
User32HWndCtrl.java | Windows 原生实现 | 仅确认存在,内部实现细节未在本页验证 |
NullHWndCtrl.java | 空实现(不支持平台) | 仅确认存在,内部实现细节未在本页验证 |
WindowSystem.java | 平台窗口系统工具 | 仅通过 findWindow(null, title) 调用点确认签名返回 HWndCtrl 或 null |
WindowsStartupConfig.java | 开机自启动(Windows) | 不属于本页主题 |
NullStartupConfig.java | 开机自启动(空实现) | 不属于本页主题 |
StartupConfig.java | 自启动抽象 | 不属于本页主题 |
对于 User32HWndCtrl / NullHWndCtrl / WindowSystem 的内部实现(例如是否使用 JNA 调用 user32.dll、显示器枚举细节),本页不做未经证实的描述。
主内容:HWndCtrl 抽象层实现剖析
1. 窗口几何快照与构造
HWndCtrl 构造函数接收窗口标题与 WindowRect,将屏幕坐标系的窗口外接矩形展开为公开字段,并派生宽高:
1public abstract class HWndCtrl {
2 public final String windowText;
3 public final int posTop;
4 public final int posBottom;
5 public final int posLeft;
6 public final int posRight;
7 public final int windowWidth;
8 public final int windowHeight;
9 protected long glfwHandle;
10
11 public HWndCtrl(String windowText, WindowRect windowRect) {
12 this.windowText = windowText;
13 posTop = windowRect.top;
14 posBottom = windowRect.bottom;
15 posLeft = windowRect.left;
16 posRight = windowRect.right;
17 windowWidth = posRight - posLeft;
18 windowHeight = posBottom - posTop;
19 }Source: HWndCtrl.java
设计意图:posTop/posBottom/posLeft/posRight 使用操作系统全局(虚拟桌面)坐标——在多显示器环境下,副屏窗口的坐标可以落在主屏矩形之外甚至为负。字段声明为 public final 意味着每个 HWndCtrl 实例是窗口状态的一次性快照,配合 updated() 抽象方法实现"拉取最新状态"的显式协议,而非引用共享可变状态。
2. 几何辅助方法
1 public float getCenterX() {
2 return posLeft + windowWidth / 2f;
3 }
4
5 public float getCenterY() {
6 return posTop + windowHeight / 2f;
7 }Source: HWndCtrl.java
getCenterX/getCenterY 返回 float 而非 int,供上层以亚像素精度计算桌宠身体中心(例如锚定宠物脚部/中心点)。
3. 鼠标穿透:基类中唯一的 GLFW 具体实现
1 public void setTransparent(boolean enable) {
2 if (glfwHandle == 0) return;
3 GLFW.glfwSetWindowAttrib(glfwHandle, GLFW.GLFW_MOUSE_PASSTHROUGH, enable ? 1 : 0);
4 }
5
6 public void attachGLFWWindow(Lwjgl3Graphics graphics) {
7 this.glfwHandle = graphics.getWindow().getWindowHandle();
8 }Source: HWndCtrl.java
这是抽象基类中唯一带具体实现的行为方法。设计意图有三层:
- 跨平台免费获得:
GLFW_MOUSE_PASSTHROUGH是 GLFW 3.3+ 标准属性,Windows/macOS/Linux 均支持,无需各平台重复实现,所以放在基类; glfwHandle == 0防御:HWndCtrl也可能包装其他 ArkPets 实例的窗口(此时没有 GLFW 句柄),对这类窗口调用穿透毫无意义,直接短路返回;attachGLFWWindow显式注入:libGDX 的Lwjgl3Graphics.getWindow().getWindowHandle()返回原生句柄(Win32 下即 HWND 的 long 表示),基类用protected long glfwHandle保存,避免每平台重复获取。
4. 抽象 API 契约
其余平台相关行为全部声明为抽象方法,构成每个平台实现必须满足的契约:
1 /** Returns true if the window is a foreground window now. */
2 public abstract boolean isForeground();
3
4 /** Returns true if the window is visible now. */
5 public abstract boolean isVisible();
6
7 /** Requests to close the window.
8 * @param timeout Timeout for waiting response (ms).
9 * @return true=success, false=failure. */
10 public abstract boolean close(int timeout);
11
12 /** Gets a new HWndCtrl which contains the updated information of this window.
13 * @return The up-to-dated HWndCtrl. */
14 public abstract HWndCtrl updated();
15
16 /** Sets the window as the foreground window. */
17 public abstract void setForeground();
18
19 /** Sets the window's position without activating the window.
20 * @param insertAfter The window to precede the positioned window in the Z order.
21 * @param x The new position of the left side of the window, in client coordinates.
22 * @param y The new position of the top of the window, in client coordinates.
23 * @param w The new width of the window, in pixels.
24 * @param h The new height of the window, in pixels. */
25 public abstract void setWindowPosition(HWndCtrl insertAfter, int x, int y, int w, int h);
26
27 /** Sets the window's taskbar visibility.
28 * @param enable Whether to let the window's entry visible on the taskbar. */
29 public abstract void setTaskbar(boolean enable);
30
31 /** Sets the window's topmost style.
32 * @param enable Whether to enable the topmost style. */
33 public abstract void setTopmost(boolean enable);
34
35 /** Sends a mouse event message to the window.
36 * @param msg The window message value.
37 * @param x The X-axis coordinate, related to the left border of the window.
38 * @param y The Y-axis coordinate, related to the top border of the window. */
39 public abstract void sendMouseEvent(MouseEvent msg, int x, int y);
40
41 @Override
42 public abstract boolean equals(Object o);
43
44 @Override
45 public abstract int hashCode();Source: HWndCtrl.java
几个值得注意的契约细节:
setWindowPosition(HWndCtrl insertAfter, ...):insertAfter参数对应 Win32SetWindowPos的hWndInsertAfter语义——移动窗口的同时指定其在 Z 序中插入到哪个窗口之后。这正是"拖拽桌宠不抢焦点"的关键:移动窗口的 API 与激活窗口的 API 解耦,桌宠被拖动时既改位置又保持 Z 序。参数说明中"client coordinates"(客户区坐标)与像素宽高,配合setTopmost可以组合出"贴在某个窗口上方/下方"的桌面伴生行为。close(int timeout):以毫秒超时等待窗口响应关闭请求,返回成败布尔。超时语义暗示实现层存在"发送关闭消息后轮询/等待"的逻辑,用于优雅关闭其他 ArkPets 实例(例如多开管理时逐个关闭旧实例)。updated()返回新HWndCtrl:而非原地修改——与不可变快照设计一致。equals/hashCode强制抽象:窗口身份比较(判断两次枚举到的窗口是否同一个)是平台实现细节(Windows 下应基于 HWND 句柄),故必须由子类定义。sendMouseEvent坐标系:x/y相对目标窗口左边框/上边框(窗口局部坐标),MouseEvent枚举覆盖移动与左/右/中键按下与抬起。
5. 多实例窗口标题编号:NumberedTitleManager
桌宠多开时每只宠物是一个独立进程/窗口。Ark-Pets 用窗口标题编号作为实例间互认与寻址机制:
1 public static class NumberedTitleManager {
2 private final String zeroNameFormat;
3 private final String numberedNameFormat;
4 private final Pattern zeroNamePattern;
5 private final Pattern numberedNamePattern;
6
7 public NumberedTitleManager(String coreName) {
8 zeroNameFormat = coreName;
9 numberedNameFormat = coreName + " (%d)";
10 zeroNamePattern = Pattern.compile("^" + coreName + "$");
11 numberedNamePattern = Pattern.compile("^" + coreName + " \\(([0-9]+)\\)");
12 }
13
14 public int getNumber(HWndCtrl hWndCtrl) {
15 if (hWndCtrl == null) return -1;
16 return getNumber(hWndCtrl.windowText);
17 }
18
19 public int getNumber(String windowText) {
20 if (windowText.isEmpty()) return -1;
21 if (zeroNamePattern.matcher(windowText).find()) return 0;
22 try {
23 Matcher matcher = numberedNamePattern.matcher(windowText);
24 return matcher.find() ? Integer.parseInt(matcher.group(1)) : -1;
25 } catch (NumberFormatException ignored) {
26 return -1;
27 }
28 }Source: HWndCtrl.java
编号解析规则:核心名(如 ArkPets)映射为编号 0,ArkPets (2) 解析为 2;任何无法匹配的标题返回 -1(空标题、null 窗口、数字非法均视为未知)。
1 public String getIdleTitle() {
2 String title = String.format(zeroNameFormat);
3 if (WindowSystem.findWindow(null, title) == null) {
4 return title;
5 } else {
6 for (int cur = 2; cur <= 1024; cur++) {
7 title = String.format(numberedNameFormat, cur);
8 if (WindowSystem.findWindow(null, title) == null)
9 return title;
10 }
11 throw new IllegalStateException("Failed to get idle title.");
12 }
13 }Source: HWndCtrl.java
getIdleTitle() 是启动时申请空闲标题的核心:先尝试无编号标题;被占用则从 2 开始线性探测(注意编号 1 被跳过),最多探测到 1024,全部占用则抛出 IllegalStateException。每次探测都调用 WindowSystem.findWindow(null, title) 做一次真实窗口枚举——这是一个 TOCTOU(检查-使用竞态)敏感点:如果两个 ArkPets 实例同时启动并探测到同一个空闲标题,可能出现标题冲突;实现将探测循环限制在 1024 次以内以保证有界终止。此处 findWindow 的第一个参数为 null(推测为窗口类/过滤条件,具体签名未在本页验证范围内)。
6. 窗口数据模型
1 public record WindowRect(int top, int bottom, int left, int right) {
2 public WindowRect() {
3 this(0, 0, 0, 0);
4 }
5
6 public int width() { return right - left; }
7 public int height() { return bottom - top; }
8 }
9
10 public enum MouseEvent {
11 EMPTY,
12 MOUSEMOVE,
13 LBUTTONDOWN,
14 LBUTTONUP,
15 RBUTTONDOWN,
16 RBUTTONUP,
17 MBUTTONDOWN,
18 MBUTTONUP,
19 }
20
21 public record MousePoint(int x, int y) {
22 }Source: HWndCtrl.java
WindowRect:Java record 定义的不可变屏幕矩形,提供无参构造(全零矩形,供"空窗口占位"场景)与width()/height()派生量。字段顺序为top, bottom, left, right,与 Win32RECT的left/top/right/bottom顺序不同,是使用时需注意的细节。MouseEvent:与 Win32WM_MOUSEMOVE/WM_LBUTTONDOWN/...消息一一对应的枚举(含EMPTY占位),用于sendMouseEvent注入。MousePoint:简单二维点 record。
toString() 的展示格式为 ‘标题’ 宽*高(注意使用的是中文单引号与乘号):
1 @Override
2 public String toString() {
3 return "‘" + windowText + "’ " + windowWidth + "*" + windowHeight;
4 }Source: HWndCtrl.java
Core Flow:多实例启动时的标题申请时序
流程要点:标题申请发生在窗口创建之前,因此探测针对的是已存在的其他实例;探测上限 1024 保证方法必然终止(成功返回或抛出异常)。运行期还可以反向使用 getNumber(HWndCtrl) 对枚举到的窗口做编号识别,从而在多开管理 UI 中区分实例顺序。
API Reference
HWndCtrl 公开/受保护成员
| 成员 | 签名 | 说明 |
|---|---|---|
| 构造器 | HWndCtrl(String windowText, WindowRect windowRect) | 以标题与屏幕矩形构造窗口快照;派生 windowWidth/windowHeight |
isForeground | abstract boolean isForeground() | 窗口当前是否为前台窗口 |
isVisible | abstract boolean isVisible() | 窗口当前是否可见 |
getCenterX | float getCenterX() | 窗口中心 X(全局坐标) |
getCenterY | float getCenterY() | 窗口中心 Y(全局坐标) |
close | abstract boolean close(int timeout) | 请求关闭窗口,timeout 为等待响应毫秒数;返回是否成功 |
updated | abstract HWndCtrl updated() | 返回包含该窗口最新信息的新 HWndCtrl |
setForeground | abstract void setForeground() | 将窗口设为前台 |
setWindowPosition | abstract void setWindowPosition(HWndCtrl insertAfter, int x, int y, int w, int h) | 不激活窗口地设置位置与尺寸;insertAfter 指定 Z 序插入参考窗口 |
setTaskbar | abstract void setTaskbar(boolean enable) | 设置任务栏条目可见性 |
setTopmost | abstract void setTopmost(boolean enable) | 设置置顶样式 |
setTransparent | void setTransparent(boolean enable) | 切换鼠标穿透;仅当已 attachGLFWWindow 时生效 |
sendMouseEvent | abstract void sendMouseEvent(MouseEvent msg, int x, int y) | 向窗口注入鼠标消息,坐标相对窗口左上角 |
attachGLFWWindow | void attachGLFWWindow(Lwjgl3Graphics graphics) | 附加 GLFW 窗口句柄,启用 setTransparent |
equals / hashCode | abstract | 由平台实现定义窗口身份 |
toString | String | 格式 ‘标题’ 宽*高 |
glfwHandle | protected long | 附加的 GLFW 原生句柄,0 表示未附加 |
NumberedTitleManager 成员
| 成员 | 签名 | 说明 |
|---|---|---|
| 构造器 | NumberedTitleManager(String coreName) | 以核心窗口名构建格式与正则 |
getNumber | int getNumber(HWndCtrl hWndCtrl) | 从窗口对象解析编号;null 返回 -1 |
getNumber | int getNumber(String windowText) | 从标题字符串解析编号;核心名为 0,非法返回 -1 |
getIdleTitle | String getIdleTitle() | 申请一个未被任何现存窗口占用的标题;探测范围 2..1024,耗尽抛 IllegalStateException |
WindowRect / MousePoint / MouseEvent
| 类型 | 成员 | 说明 |
|---|---|---|
WindowRect(int top, int bottom, int left, int right) | width(), height() | 不可变屏幕矩形;另含全零无参构造 |
MousePoint(int x, int y) | — | 二维点 |
MouseEvent | EMPTY, MOUSEMOVE, LBUTTONDOWN, LBUTTONUP, RBUTTONDOWN, RBUTTONUP, MBUTTONDOWN, MBUTTONUP | 鼠标消息枚举,对应 Win32 鼠标窗口消息 |
Failure Modes, Edge Cases & Concurrency
- 快照失效(staleness):所有
pos*字段都是构造时快照。上层若持有旧HWndCtrl引用做拖拽/碰撞计算,会基于过期坐标操作;正确用法是周期性updated()或重新WindowSystem.findWindow。 setTransparent无句柄静默失败:glfwHandle == 0(包装他人窗口或未调用attachGLFWWindow)时直接 return,不抛异常。调用方若忘记 attach,桌宠穿透功能会无报错地失效——排查时需检查是否附加了 GLFW 句柄。getIdleTitle的 TOCTOU 竞态:探测空闲标题与窗口实际创建之间存在时间窗,两个同时启动的实例可能拿到同一编号标题。探测循环上限 1024 保证有界,但冲突需上层(或用户重命名/重启实例)消化。- 编号语义边界:
getNumber对空标题、null、非数字后缀统一返回-1,不区分"未编号的陌生窗口"与"解析失败";getIdleTitle从2起探测,编号1永远不会由本机制产生。 close(timeout)超时失败:目标窗口可能拒绝/忽略关闭请求(例如渲染主循环繁忙),此时方法返回false,上层需要决定重试或放弃;不会阻塞超过timeout毫秒。- 非 Windows 平台降级:
NullHWndCtrl提供空实现,setTaskbar/setTopmost/sendMouseEvent等调用不会生效也不报错;多开标题探测依赖WindowSystem.findWindow的平台实现,若平台不支持枚举,getIdleTitle会立即认为标题空闲。
Performance & Operational Notes
- 窗口枚举成本:
getIdleTitle最坏情况下发起 1024 次findWindow调用,每次都是跨进程窗口枚举;正常路径只有 1 次。实例数极多时启动延迟会线性上升,实际桌面场景(几十只宠物)可忽略。 - 渲染热路径隔离:穿透切换(
glfwSetWindowAttrib)是轻量属性设置,可在运行期按宠物身体透明度动态调用而不会明显影响帧率;但updated()/findWindow这类涉及系统窗口枚举的调用不宜放在逐帧循环中。 - 多显示器:
pos*为虚拟桌面全局坐标,副屏可为负坐标/超主屏范围;跨屏拖拽时坐标连续变化,无需特殊处理,但上层做屏幕边界裁剪时必须使用虚拟桌面矩形而非主屏矩形。
Extension Points
- 新增平台支持:继承
HWndCtrl实现全部抽象方法(参考User32HWndCtrl/NullHWndCtrl的分支结构,配合WindowSystem选择实现),上层代码零改动——这是该抽象层最主要的扩展点。 setWindowPosition的 Z 序编排:通过传入不同的insertAfter窗口,可实现"宠物夹在两个特定窗口之间"这类桌面伴生布局,无需新增 API。MouseEvent扩展:枚举目前覆盖基本鼠标事件,若需要滚轮/双击等注入能力,可在枚举中增加值并在平台实现中映射到对应原生消息。
Related Links
- 源码入口:HWndCtrl.java
- 平台包目录:
core/src/cn/harryh/arkpets/platform/下的User32HWndCtrl.java、NullHWndCtrl.java、WindowSystem.java - 兄弟主题:开机自启动(
StartupConfig/WindowsStartupConfig/NullStartupConfig)属于独立能力,见相关页面 - 兄弟主题:桌宠渲染与行为控制(GLFW 窗口的创建、动画循环)见「桌宠运行核心」相关页面