Repository Wiki
isHarryh/Ark-Pets

系统托盘与多实例管理

Ark-Pets 的系统托盘子系统由**宿主托盘(HostTray)与成员托盘(MemberTray)**两级结构组成:桌面启动器进程持有唯一的托盘图标与"角色管理"聚合菜单,而每一个独立运行的桌面宠物进程通过 Socket 长连接把自己的控制菜单"注册"进宿主菜单,实现多实例的统一纳管与优雅降级。

目的与范围

本页覆盖 Ark-Pets 中与系统托盘及多实例(多角色进程)管理相关的完整机制,包括:

  • core/src/cn/harryh/arkpets/tray/ 包下的全部四个类:HostTray、MemberTray(抽象基类)、MemberTrayImpl(宠物进程侧实现)、MemberTrayProxy(宿主侧代理)
  • 宿主与成员之间的 Socket 操作指令(SocketData.Operation)如何驱动菜单整合与状态同步
  • 连接断开时的"隔离托盘图标"降级策略
  • 启动器(ArkHomeFX)与更新模块(SettingsModule)如何批量向所有实例广播退出指令

以下相关主题属于兄弟页面,本页不做深入展开:

  • Socket 通信的底层协议、会话与服务器实现细节 —— 见 Socket 通信与会话管理 相关页面
  • 托盘菜单触发的角色行为(手动模式动画、透明模式渲染、形态切换)的内部逻辑 —— 见 角色行为与动画控制 相关页面
  • 启动器主界面(ArkHomeFX)的整体架构 —— 见 桌面启动器 相关页面

概述

Ark-Pets 采用多进程架构:用户可以同时启动多个桌面宠物,每个宠物是一个独立的 JVM 进程(ArkPets 实例)。如果每个进程都各自往系统托盘里塞一个图标,托盘很快就会被塞满,且用户无法从统一入口管理所有角色。

为此,v3.x 引入了"宿主-成员"托盘模型:

概念所在进程类职责
宿主托盘桌面启动器(ArkHomeFX)HostTray(单例)持有系统托盘中唯一的 ArkPets 图标、"角色管理"聚合子菜单,维护 Map<UUID, MemberTray> 成员注册表
成员托盘(客户端)每个宠物进程MemberTrayImpl构造时连接宿主,把菜单项与操作指令通过 Socket 上报;断连时降级为独立托盘图标
成员托盘(宿主端代理)桌面启动器MemberTrayProxy宿主侧的 MemberTray 实现,把远程成员呈现为一个 JMenu 挂载到 HostTray.playerMenu 下,并把菜单点击转换为 Socket 指令下发

关键设计意图:

  1. 单一托盘入口:无论开多少个角色,系统托盘中 ArkPets 只占一个图标,所有角色的控制入口收纳在"角色管理"子菜单中。
  2. 进程隔离下的伪整合:菜单项的"本地动作"(改动画、切透明)在宠物进程内直接执行,同时通过 Socket 把状态镜像给宿主,保证宿主菜单与实际状态一致。
  3. 优雅降级:宠物进程在宿主未启动或连接失败时,自动挂出自己独立的"隔离托盘图标"(isolated tray icon),功能不丢失;一旦连上宿主,立即移除隔离图标并整合进宿主菜单。
  4. 批量控制:宿主可以通过 forEachMemberTray 向所有存活实例广播 LOGOUT,用于启动器"实体退出"和版本更新前的统一收尾。

架构

Loading diagram...

架构要点解读:

  • HostTray 是纯 AWT/Swing 组件:它不依赖 LibGDX,因此在启动器(JavaFX/Swing 混合进程)中即可独立运行。它通过 Map<UUID, MemberTray>(arkPetTrays 字段)持有所有已登录成员的代理对象,并提供 forEachMemberTray 遍历入口。
  • MemberTrayImpl 生活在宠物进程内:它同时持有 ArkPets(用于直接操纵角色,如 setTransparentMode、changeStage)与 SocketClient(用于向宿主上报)。每个成员在构造时生成 UUID.randomUUID() 作为跨进程唯一标识。
  • MemberTrayProxy 是宿主侧镜像:从 HostTray.addMemberTray(JMenu) 的签名与 ArkHomeFX 中 forEachMemberTray(... sendOperation(LOGOUT)) 的调用可以确定,MemberTrayProxy 继承自抽象类 MemberTray,把远程成员包装成一个 JMenu 挂到 playerMenu,并把 sendOperation 的调用转成对宠物进程的 Socket 下行指令。
  • 托盘图标的所有权转移:MemberTrayImpl.onConnected() 会执行 SystemTray.getSystemTray().remove(icon),onDisconnected() 则重新 add(icon)——这就是"整合 / 隔离"两种形态切换的实现机制。

类继承关系

Loading diagram...

MemberTray 抽象基类最大的特点是双监听器模式:每个菜单项同时挂载两个 ActionListener——一个执行本地动作回调(如 onKeepAnimEn()),一个向 Socket 发送对应操作指令(如 KEEP_ACTION)。这样无论是宠物进程本地的 MemberTrayImpl 还是宿主侧的 MemberTrayProxy,点击菜单都会既"做事"又"通知",保证两端状态一致。

核心组件解析

HostTray:宿主单例托盘

HostTray 使用懒加载单例(getInstance()),构造函数在 SystemTray.isSupported() 为真时才初始化 UI 组件:

java
1public class HostTray { 2 protected TrayIcon trayIcon; 3 protected boolean initialized = false; 4 protected Map<UUID, MemberTray> arkPetTrays = new HashMap<>(); 5 6 private JDialog popWindow; 7 private JPopupMenu popMenu; 8 private JMenu playerMenu; 9 10 private Runnable onShowStage; 11 private Runnable onCloseStage; 12 13 private static HostTray instance;

Source: HostTray.java

设计意图说明:

  • initialized 标志位与 applyTrayIcon() 分离:构造 HostTray 只是准备好 Swing 组件,真正把图标 SystemTray.getSystemTray().add(trayIcon) 是幂等的独立调用——失败只记录错误不抛异常,避免托盘不可用时拖垮整个启动器。
  • 回调而非直接依赖:onShowStage / onCloseStage 是 Runnable 回调,由启动器注入。HostTray 因此对 ArkHomeFX 零依赖,保持 core 模块的可复用性。
  • 成员注册表 arkPetTrays:以成员 UUID 为键,宿主收到 LOGIN 后写入,收到 LOGOUT 或连接断开时移除。

静态初始化块处理了 Windows 下的观感适配问题:

java
1static { 2 // Avoid AWT Thread problem. 3 SwingUtilities.invokeLater(() -> { 4 try { 5 String laf = UIManager.getSystemLookAndFeelClassName(); 6 if (laf.contains("WindowsLookAndFeel")) { 7 UIManager.put("MenuItem.margin", new Insets(0, -16, 0, 0)); 8 UIManager.put("Menu.margin", new Insets(0, -16, 0, 0)); 9 } 10 UIManager.setLookAndFeel(laf); 11 } catch (Exception ignored) { 12 } 13 }); 14 Const.FontsConfig.REGULAR.loadFontToSwing(); 15}

Source: HostTray.java

这段代码解决两个实际问题:其一,SwingUtilities.invokeLater 确保 LookAndFeel 设置发生在 AWT 事件分发线程上,规避 AWT 线程问题;其二,Windows 观感下的 MenuItem.margin 需要负的左内边距才能与宿主菜单的视觉风格对齐;最后加载 ArkPets 内置字体到 Swing,保证中文菜单项渲染一致。

HostTray 的弹出菜单与坐标适配

宿主菜单由一个 JDialog(1×1 像素、无边框)承载一个 JPopupMenu 组成——这是 Java 在系统托盘区域显示 Swing 弹出菜单的经典 workaround(TrayIcon 原生只支持 AWT PopupMenu,无法显示中文富组件):

java
1public void showDialog(int x, int y) { 2 if (!initialized) 3 return; 4 /* Use `System.setProperty("sun.java2d.uiScale", "1")` can also avoid system scaling. 5 Here we will adapt the coordinate for system scaling artificially. See below. */ 6 AffineTransform at = popWindow.getGraphicsConfiguration().getDefaultTransform(); 7 int scaledX = (int) (x / at.getScaleX()); 8 int scaledY = (int) (y / at.getScaleY()); 9 10 // Show the JDialog together with the JPopupMenu. 11 popWindow.setVisible(true); 12 popWindow.setLocation(scaledX, scaledY - popMenu.getHeight()); 13 popMenu.show(popWindow, 0, 0); 14}

Source: HostTray.java

这里主动做了 DPI 缩放适配:鼠标事件的屏幕坐标是物理像素,而 Swing 窗口坐标是逻辑像素,在高分屏(如 150% 缩放)上必须除以 AffineTransform 的缩放系数,否则菜单会飘到错误位置。代码注释也指出了替代方案(设置 sun.java2d.uiScale=1),但运行时手动换算更可控。

JPopupMenu.firePopupMenuWillBecomeInvisible 被重写为同时隐藏承载用的 JDialog,防止留下一个不可见但存在的窗口残留。

MemberTray:抽象基类与双监听器

MemberTray 定义了成员托盘的完整菜单结构与会话契约:

java
1public abstract class MemberTray { 2 protected JMenuItem optKeepAnimEn = new JMenuItem("手动模式"); 3 protected JMenuItem optKeepAnimDis = new JMenuItem("退出手动"); 4 protected JMenuItem optTransparentEn = new JMenuItem("透明模式"); 5 protected JMenuItem optTransparentDis = new JMenuItem("取消透明"); 6 protected JMenuItem optChangeStage = new JMenuItem("切换形态"); 7 protected JMenuItem optExit = new JMenuItem("退出"); 8 protected final UUID uuid; 9 protected final String name;

Source: MemberTray.java

构造函数中为每个菜单项注册成对的监听器:

java
1 public MemberTray(String name) { 2 this.uuid = UUID.randomUUID(); 3 this.name = name; 4 5 optKeepAnimEn .addActionListener(e -> onKeepAnimEn()); 6 optKeepAnimDis .addActionListener(e -> onKeepAnimDis()); 7 optTransparentEn .addActionListener(e -> onTransparentEn()); 8 optTransparentDis .addActionListener(e -> onTransparentDis()); 9 optChangeStage .addActionListener(e -> onChangeStage()); 10 optExit .addActionListener(e -> onExit()); 11 12 optKeepAnimEn .addActionListener(e -> sendOperation(SocketData.Operation.KEEP_ACTION)); 13 optKeepAnimDis .addActionListener(e -> sendOperation(SocketData.Operation.NO_KEEP_ACTION)); 14 optTransparentEn .addActionListener(e -> sendOperation(SocketData.Operation.TRANSPARENT_MODE)); 15 optTransparentDis .addActionListener(e -> sendOperation(SocketData.Operation.NO_TRANSPARENT_MODE)); 16 optChangeStage .addActionListener(e -> sendOperation(SocketData.Operation.CHANGE_STAGE)); 17 optExit .addActionListener(e -> sendOperation(SocketData.Operation.LOGOUT));

Source: MemberTray.java

对应指令全集:

菜单项本地抽象回调Socket 指令语义
手动模式onKeepAnimEn()KEEP_ACTION锁定当前动画,停止行为机切换
退出手动onKeepAnimDis()NO_KEEP_ACTION解除动画锁定
透明模式onTransparentEn()TRANSPARENT_MODE开启穿透/透明渲染
取消透明onTransparentDis()NO_TRANSPARENT_MODE关闭透明模式
切换形态onChangeStage()CHANGE_STAGE切换角色形态(皮肤/Stage)
退出onExit()LOGOUT结束该宠物进程

注意 SwingUtilities 的监听器按注册顺序依次执行,"先本地动作、后状态上报"的顺序保证了宿主收到指令时本地状态已经变更完毕。

MemberTrayImpl:宠物进程侧实现

构造函数完成三件事:搭建弹出菜单、建立 Socket 连接、处理首连失败:

java
1public MemberTrayImpl(ArkPets boundArkPets, SocketClient client) { 2 super(getName(boundArkPets)); 3 arkPets = boundArkPets; 4 this.client = client; 5 6 // Ui Components: 7 popWindow = new JDialog(); 8 popWindow.setUndecorated(true); 9 popWindow.setSize(1, 1); 10 JLabel innerLabel = new JLabel(" " + name + " "); 11 innerLabel.setAlignmentX(0.5f); 12 13 popMenu = new JPopupMenu() { 14 @Override 15 public void firePopupMenuWillBecomeInvisible() { 16 popWindow.setVisible(false); // Hide the container when the menu is invisible. 17 } 18 }; 19 popMenu.add(innerLabel); 20 popMenu.add(optKeepAnimEn); 21 popMenu.add(optTransparentEn); 22 if (arkPets.canChangeStage()) 23 popMenu.add(optChangeStage); 24 popMenu.add(optExit); 25 popMenu.setSize(100, 24 * popMenu.getSubElements().length); 26 27 Runnable onConnected = this::onConnected; 28 SocketSession session = new SocketClient.ClientSocketSession(client, this); 29 client.connect(onConnected, session); 30 if (!client.isConnected()) { 31 onDisconnected(); 32 client.connectWithRetry(onConnected, session); 33 } 34}

Source: MemberTrayImpl.java

值得注意的细节:

  • getName() 从 arkPets.config.character_label 取显示名,为空则回退为 "Unknown"——多实例菜单中用户靠这个标签区分角色。
  • optChangeStage 是条件菜单项:只有 arkPets.canChangeStage() 为真(角色模型支持多形态)才加入,避免对单形态角色显示无效入口。
  • 首连失败的容错路径:connect() 同步尝试一次,若 isConnected() 为假,立即调用 onDisconnected() 挂出隔离图标,再交给 connectWithRetry 后台重试。这保证了"启动器后开、宠物先开"的场景下宠物依然可用,且一旦启动器上线就自动整合。

连接成功:状态同步握手

onConnected() 是整合流程的核心,除了移除隔离图标,还要把当前运行状态全量上报:

java
1public void onConnected() { 2 // If integration was succeeded, remove the ISOLATED tray icon. 3 Logger.info("MemberTray", "Integrated tray service connected"); 4 SystemTray.getSystemTray().remove(icon); 5 client.sendRequest(SocketData.ofLogin(uuid, name)); 6 if (arkPets.canChangeStage()) 7 sendOperation(SocketData.Operation.CAN_CHANGE_STAGE); 8 for (MenuElement element : popMenu.getSubElements()) { 9 if (element.equals(optKeepAnimDis)) 10 sendOperation(SocketData.Operation.KEEP_ACTION); 11 if (element.equals(optTransparentDis)) 12 sendOperation(SocketData.Operation.TRANSPARENT_MODE); 13 } 14}

Source: MemberTrayImpl.java

状态同步的技巧很巧妙:MemberTrayImpl 用"菜单里当前显示的是 optKeepAnimDis(退出手动)还是 optKeepAnimEn(手动模式)"来推断开关状态——因为启用某功能时菜单项会被替换为对应的"退出"项(见 onKeepAnimEn() 中 popMenu.remove(optKeepAnimEn); popMenu.add(optKeepAnimDis, 1))。握手时遍历菜单子元素即可还原全部开关状态,无需额外的状态字段。CAN_CHANGE_STAGE 指令则让宿主知道是否要为该成员渲染"切换形态"子项。

连接断开:隔离降级

java
1public void onDisconnected() { 2 // When connection was broken: 3 Logger.info("MemberTray", "Integrated tray service disconnected"); 4 Image image = Toolkit.getDefaultToolkit().createImage(getClass().getResource(iconFilePng)); 5 TrayIcon icon = getTrayIcon(image); 6 7 // Add the ISOLATED tray icon to the system tray. 8 try { 9 SystemTray.getSystemTray().add(icon); 10 Logger.info("MemberTray", "Isolated tray icon applied"); 11 } catch (AWTException e) { 12 Logger.error("MemberTray", "Unable to apply isolated tray icon, details see below", e); 13 } 14}

Source: MemberTrayImpl.java

降级图标复用同一套弹出菜单(getTrayIcon() 中的 MouseListener 仍调用 showDialog),因此隔离形态下所有功能与整合形态完全一致,只是入口从宿主的"角色管理"子菜单变为自己独立的托盘图标。

退出流程:视觉淡出 + 延迟销毁

java
1@Override 2public void onExit() { 3 Logger.info("MemberTray", "Request to exit"); 4 remove(); 5 client.disconnect(); 6 arkPets.cha.setAlpha(0f); 7 new Timer().schedule(new TimerTask() { 8 @Override 9 public void run() { 10 Gdx.app.exit(); 11 } 12 }, (int) durationNormal.toMillis()); 13}

Source: MemberTrayImpl.java

退出顺序经过精心安排:先 remove()(清空菜单、dispose 弹窗容器、断开 Socket,宿主侧随即从注册表移除该成员),再把角色透明度设为 0 实现"瞬间消失"的视觉效果,最后延迟 Const.durationNormal 才调用 Gdx.app.exit()——给渲染循环留出渲染最后一帧(全透明)的时间,避免窗口突兀撕裂。

核心流程

成员托盘的整合 / 降级生命周期

Loading diagram...

流程解读:

  1. 构造即连接:MemberTrayImpl 必须在 Gdx.app 初始化之后创建(构造函数 Javadoc 明确要求),因为它要访问 arkPets.config 与 LibGDX 资源。
  2. 状态镜像握手:onConnected() 的上报序列(LOGIN → CAN_CHANGE_STAGE → 开关状态)让宿主无需任何持久化即可重建完整视图,这是无状态重连的关键。
  3. 双向同步:整合形态下用户既可能在宿主的"角色管理"菜单里点角色子项(宿主通过 MemberTrayProxy.sendOperation 下行指令,宠物进程收到后执行并刷新本地菜单),也可能在隔离图标上直接操作(本地回调 + 上行指令),两条路径最终收敛到同一组 SocketData.Operation。

宿主侧批量控制

启动器在两个场景下会遍历所有成员并广播退出指令:

java
// Notify ArkPets core instances that connected to this app to close. if (config != null && config.launcher_solid_exit) { HostTray.getInstance().forEachMemberTray(memberTray -> memberTray.sendOperation(SocketData.Operation.LOGOUT));

Source: ArkHomeFX.java

java
protected void onDownloadedFile(File file) { Logger.info("Updater", "Closing all core instances"); HostTray.getInstance().forEachMemberTray(memberTray -> memberTray.sendOperation(SocketData.Operation.LOGOUT));

Source: SettingsModule.java

设计意图:

  • launcher_solid_exit(实体退出):启动器关闭时把所有依附的宠物进程一并带走,避免"孤儿角色"滞留桌面。这是配置项驱动的可选行为——用户也可以选择关闭启动器但保留角色。
  • 更新前收尾:版本更新流程在下载完新文件后必须先让所有正在运行旧版本代码的宠物进程退出,否则 Windows 下文件被占用会导致更新失败。forEachMemberTray 提供了 O(n) 的统一关闭入口,无需逐个查找进程。

使用示例

场景一:启动器挂载宿主托盘并注册回调

java
1public static void main(String[] args) { 2 // ...启动器初始化... 3 HostTray hostTray = HostTray.getInstance(); 4 hostTray.applyTrayIcon(); // 幂等:把 ArkPets 图标加入系统托盘 5 hostTray.setOnShowStage(() -> { 6 // 用户左键单击托盘图标 → 显示启动器主窗口 7 showMainWindow(); 8 }); 9 hostTray.setOnCloseStage(() -> { 10 // 用户右键菜单"退出程序" → 关闭启动器 11 Platform.exit(); 12 }); 13}

Source: HostTray.java

(说明:上例为依据 HostTray 公开 API 与 ArkHomeFX 中的实际用法整理的调用模式;applyTrayIcon、setOnShowStage、setOnCloseStage 的真实签名见下方 API 参考。)

场景二:宠物进程创建成员托盘

java
1// 在 ArkPets (LibGDX Application) 的 create() 阶段之后: 2SocketClient client = new SocketClient(...); 3MemberTray tray = new MemberTrayImpl(this, client); 4// 此后 MemberTrayImpl 自动: 5// 1. 尝试连接宿主 → 成功则整合进 HostTray.playerMenu 6// 2. 失败则挂出隔离托盘图标,并后台 connectWithRetry

Source: MemberTrayImpl.java

场景三:菜单开关状态的双向维护(以透明模式为例)

java
1@Override 2public void onTransparentEn() { 3 Logger.info("MemberTray", "Transparent enabled"); 4 arkPets.setTransparentMode(true); 5 popMenu.remove(optTransparentEn); 6 popMenu.add(optTransparentDis, 2); 7} 8 9@Override 10public void onTransparentDis() { 11 Logger.info("MemberTray", "Transparent disabled"); 12 arkPets.setTransparentMode(false); 13 popMenu.remove(optTransparentDis); 14 popMenu.add(optTransparentEn, 2); 15}

Source: MemberTrayImpl.java

这对方法展示了本子系统的就地菜单项替换模式:开关切换时不改变菜单结构,只把位置 2(或手动模式的位置 1)上的菜单项替换为反义项。固定索引替换保证了菜单布局稳定,同时也成为 onConnected() 里推断开关状态的依据。

API 参考

HostTray

HostTray 为懒加载单例,所有方法非线程安全(应在 AWT/启动器主线程调用)。

getInstance(): HostTray

返回全局唯一实例,首次调用时执行构造(构建 Swing 组件、检测 SystemTray.isSupported())。

applyTrayIcon(): void

将 trayIcon 加入系统托盘。幂等方法:已初始化(initialized == true)时直接返回;AWTException 时记录错误并保持未初始化状态。

showDialog(int x, int y): void

在屏幕坐标 (x, y) 处显示宿主弹出菜单,内部做 DPI 缩放换算。未初始化时静默返回。

showStage(): void

触发 onShowStage 回调(用户左键单击托盘图标的语义),未初始化时静默返回。

setOnShowStage(Runnable handler): void / setOnCloseStage(Runnable handler): void

注入显示/关闭回调,后者由右键菜单"退出程序"项触发。

getMemberTray(UUID uuid): MemberTray

按 UUID 查找成员代理;注意 removeMemberTray(UUID) 内部会先调用 getMemberTray(uuid).remove(),因此对不存在的 UUID 调用移除会抛 NullPointerException。

addMemberTray(UUID uuid, MemberTray tray): void / removeMemberTray(UUID uuid): void

维护 arkPetTrays 注册表。

addMemberTray(JMenu menu): void / removeMemberTray(JMenu menu): void

把成员的 JMenu 直接挂载/移出 playerMenu(角色管理子菜单)。

forEachMemberTray(Consumer<MemberTray> action): void

遍历注册表执行动作,是批量广播指令(如 LOGOUT)的唯一入口。

MemberTray(抽象基类)

构造函数 MemberTray(String name)

生成随机 UUID、保存显示名,并为六个菜单项注册"本地回调 + Socket 指令"双监听器。

抽象方法

方法触发菜单项默认对应指令
onExit()退出LOGOUT
onChangeStage()切换形态CHANGE_STAGE
onTransparentEn() / onTransparentDis()透明模式/取消透明TRANSPARENT_MODE / NO_TRANSPARENT_MODE
onKeepAnimEn() / onKeepAnimDis()手动模式/退出手动KEEP_ACTION / NO_KEEP_ACTION
remove()—(清理钩子)—
sendOperation(SocketData.Operation)—(指令通道)—

MemberTrayImpl

构造函数 MemberTrayImpl(ArkPets boundArkPets, SocketClient client)

绑定宠物实例与 Socket 客户端;必须在 Gdx.app 初始化后调用。构造完成后连接流程自动进行。

showDialog(int x, int y): void(synchronized) / hideDialog(): void(synchronized)/ toggleDialog(int x, int y): void

弹出/隐藏/切换成员菜单;synchronized 修饰用于防止并发点击导致的菜单状态错乱。

sendOperation(SocketData.Operation operation): void

实现为 client.sendRequest(SocketData.ofOperation(uuid, operation))。

失败模式、边界情况与并发

托盘不受支持

HostTray 构造时检测 SystemTray.isSupported(),不支持时仅记录 Logger.error("HostTray", "Tray is not supported."),不抛异常——启动器其余功能不受影响,但 applyTrayIcon() 后所有方法因 initialized == false 而静默空操作。

图标添加失败

applyTrayIcon() 捕获 AWTException 并记录错误(典型原因:托盘图标数量达到平台上限,或无桌面环境)。同样采取降级而非崩溃策略。

连接生命周期边界

  • 先宠物后启动器:MemberTrayImpl 首连失败即挂隔离图标并 connectWithRetry,启动器上线后自动整合。
  • 运行中断连:onDisconnected() 恢复隔离图标,功能完整保留。
  • 重复整合:onConnected() 每次都先 SystemTray.getSystemTray().remove(icon),即使之前没有隔离图标,重复 remove 也不报错,天然幂等。

状态一致性的巧妙取舍

本子系统没有持久化状态:宿主的重连视图完全由 onConnected() 的握手上报重建;宠物侧的开关状态以"当前菜单显示哪个反义项"为唯一事实来源。代价是这两处必须严格同步(onKeepAnimEn/Dis、onTransparentEn/Dis 的菜单替换与 keepAnim 字段赋值必须成对出现),新增开关项时需要同时维护三处逻辑:菜单项对、onChangeStage 一类的重置逻辑、onConnected 的状态扫描。

并发注意点

  • HostTray 的 arkPetTrays(HashMap)与 playerMenu 的增删发生在 Socket 接收线程/宿主线程,forEachMemberTray 遍历期间若并发修改注册表,理论上存在 ConcurrentModificationException 风险;当前代码未加显式同步,依赖调用时序规避。
  • MemberTrayImpl.showDialog/hideDialog 使用 synchronized,防止托盘图标鼠标事件与 LibGDX 渲染线程并发操作菜单可见性。
  • onExit() 使用一次性 java.util.Timer 延迟调用 Gdx.app.exit(),而不是在 AWT 事件线程直接退出 LibGDX 应用,避免跨线程销毁 GL 上下文。

性能与运维要点

  • 托盘图标数量:整合形态下无论多少实例,系统托盘中 ArkPets 相关图标恒为 1 个(宿主图标);最坏情况(全部断连)才会出现 N+1 个图标。对 Windows 托盘溢出策略友好。
  • Socket 重试开销:connectWithRetry 在后台线程持续重试,宠物进程不阻塞;但需要注意长时间无法连接宿主时重试仍在进行,进程生命周期内不会自动放弃。
  • 退出清理顺序:remove() 先于 client.disconnect() 执行,保证断开连接前菜单资源已释放;ArkHomeFX 的实体退出和更新流程都依赖 LOGOUT 广播的顺序性(先让宠物进程退出再操作文件)。

扩展点

若要为成员托盘增加新的可控开关(例如"锁定位置"),标准步骤为:

  1. 在 SocketData.Operation 中新增一对指令(如 LOCK_POSITION / NO_LOCK_POSITION,协议层详见 Socket 通信相关页面)。
  2. 在 MemberTray 中新增一对 JMenuItem 字段并在构造函数注册双监听器。
  3. 在 MemberTrayImpl 中实现本地回调(调用 ArkPets 对应 API)与菜单项索引替换。
  4. 在 onConnected() 的状态扫描循环中补充新开关的上报逻辑。

由于宿主侧 MemberTrayProxy 继承同一基类并复用菜单项定义,宿主菜单通常无需单独改动即可呈现新子项。

相关链接