Repository Wiki
isHarryh/Ark-Pets

日志系统与调试模式

Ark-Pets 的日志子系统由 core 模块的静态日志门面 Logger(封装 Apache log4j 1.x)与 desktop 模块的 JavaFX 日志查看器 LogDialog 组成,负责将 core(桌宠进程)与 desktop(启动器进程)的运行日志按 PID 分文件落盘、分流到 stdout/stderr,并在 GUI 中聚合展示(含 JVM 崩溃日志)以便用户导出排障。

Purpose and Scope

本页覆盖"日志系统与调试模式"这一完整能力端到端的实现:

  • Logger 静态门面:初始化流程、appender 拓扑、级别常量、日志格式与文件命名规则;
  • 日志文件生命周期管理(Cleaner 按修改时间滚动清理);
  • 调试模式面向用户的表面:LogDialog(日志查看对话框)如何发现、展示并导出 core/desktop/JVM 崩溃三类日志;
  • 相关失败模式、并发与运维注意事项。

以下内容有意留给兄弟页面,本页仅作指引:

  • 应用整体配置体系(ArkPetsConfig / Const 的其它常量组)→ 参见配置相关页面;
  • 启动器 GUI 对话框框架(DialogController / DialogComposer)→ 参见启动器界面架构页面;
  • 更广泛的进程管理与遥测能力 → 参见同目录下其它 telemetry 页面。

Overview

Ark-Pets 是多进程应用:desktop(JavaFX 启动器)会拉起 core(桌宠渲染进程)。两个进程都依赖同一个 Logger 类输出日志,因此日志系统必须解决三个问题:

  1. 进程隔离:两个进程不能写同一个文件互相覆盖。方案是日志文件名带进程 PID("%s.%d.log".formatted(logPrefix, pid)),每个进程一份独立文件,天然避免写冲突,也便于按进程归因问题。
  2. 诊断信息自描述:日志文件头部写入环境快照(OS、架构、Java 版本、工作目录、创建时间、PID),使一份日志文件无需额外提问即可定位环境问题。
  3. 面向用户的调试模式:普通用户不会读控制台。LogDialog 把磁盘上的日志(包括 JVM 崩溃产生的 hs_err_pid*.log)以树表形式呈现,支持全选/选最近/导出,把"反馈问题"变成几步点击。

关键概念与术语:

术语含义
Loggercore 模块 cn.harryh.arkpets.utils.Logger,静态门面,业务代码通过 Logger.info(tag, msg) 等静态方法打日志
tag每条日志的模块前缀,经 combine() 拼接为 "tag: message"
rootLogger / currentLoggerlog4j 1.x 根 Logger;本实现将二者指向同一对象,即全应用共用一个 Logger 实例
Appenderlog4j 输出目标:FileAppender(日志文件)、两个 ConsoleAppender(stdout / stderr)
LevelRangeFilter按级别区间过滤:stdout 只收 ≤ WARN,stderr 只收 ≥ ERROR
CleanerLogger 内部静态类,按修改时间删除最旧的日志,把同前缀日志数量压到上限内
LogDialogdesktop 模块 cn.harryh.arkpets.controllers.LogDialog,日志查看对话框控制器
hs_err_pid*.logJVM 致命错误自动生成在工作目录(而非日志目录)的崩溃转储日志

Architecture

Loading diagram...

架构要点(均可在源码中验证):

  • 单例根 Logger:rootLogger 与 currentLogger 都指向 org.apache.log4j.Logger.getRootLogger()(Logger.java#L21-L22)。整个应用不使用命名 Logger 层级,"tag" 承担了模块归属职责——这是一种刻意简化:不需要为每个模块维护 logger 实例,同时保证文件 appender 只需挂一次。
  • 三路输出:一次 Logger.info(...) 调用最多产生三路输出——文件(带时间戳与 header)、stdout(DEBUG~WARN)、stderr(ERROR/FATAL)。级别分流由两个 LevelRangeFilter 在静态初始化块中配置(Logger.java#L34-L40)。
  • 文件 vs 控制台格式差异:文件布局含 %d{ABSOLUTE} 时间戳与环境 header,控制台布局刻意省去时间戳以保持终端输出精简(Logger.java#L53-L54)。
  • UI 只读磁盘:LogDialog 不直接对接 log4j,而是扫描日志目录中的 .log 文件——解耦了"产生日志"与"查看日志",因此启动器也能看到 core 子进程(不同 PID)的日志与 JVM 崩溃日志。

核心实现详解

初始化流程:Logger.initialize(logPrefix, maxFileCount)

初始化是静态、幂等式的重建过程,任何进程(core 或 desktop)在启动时调用一次:

java
1public static void initialize(String logPrefix, int maxFileCount) { 2 // Reset log appender 3 rootLogger.removeAllAppenders(); 4 5 // Generate log header 6 PatternLayout fileLayout = getPatternLayout(logPrefix); 7 PatternLayout consoleLayout = new PatternLayout("[%p] %m%n"); 8 9 // Initialize log appender 10 Logger.maxFileCount = Math.max(1, maxFileCount); 11 String logFilePath = "%s.%d.log".formatted(logPrefix, pid); 12 try { 13 Cleaner.cleanByModifiedTime(logPrefix, Logger.maxFileCount - 1); 14 FileAppender fileAppender = new FileAppender( 15 fileLayout, logFilePath, false, false, 0 16 ); 17 fileAppender.setEncoding(charsetDefault); 18 currentLogger.addAppender(fileAppender); 19 isFileLoggerAvailable = true; 20 } catch (IOException e) { 21 LogLog.error("Failed to initialize the file logger."); 22 } 23 ConsoleAppender sysOutAppender = new ConsoleAppender(consoleLayout); 24 ConsoleAppender sysErrAppender = new ConsoleAppender(consoleLayout); 25 sysOutAppender.addFilter(sysOutFilter); 26 sysErrAppender.addFilter(sysErrFilter); 27 sysOutAppender.setTarget(ConsoleAppender.SYSTEM_OUT); 28 sysErrAppender.setTarget(ConsoleAppender.SYSTEM_ERR); 29 rootLogger.addAppender(sysOutAppender); 30 rootLogger.addAppender(sysErrAppender); 31 32 // Reset log level 33 setLevel(level); 34 isInitialized = true; 35}

Source: Logger.java

逐段解读(WHY):

  1. rootLogger.removeAllAppenders() — 幤等重建:若重复调用 initialize,先清掉旧 appender,避免同一消息被写两次。这是防御性设计,因为静态字段在 JVM 内只初始化一次,但方法可能被多次调用。
  2. Cleaner.cleanByModifiedTime(logPrefix, maxFileCount - 1) — 在打开新文件之前清理旧日志,参数是 maxFileCount - 1,为新文件预留一个名额。若清理失败抛 IOException,会被 catch 并降级为"仅控制台日志"模式。
  3. new FileAppender(layout, path, false, false, 0) — 关键参数依次是:append = false(同名文件覆盖写,因为文件名含 PID,同 PID 重启属异常场景,覆盖更安全)、bufferedIO = false(无缓冲直写,崩溃时日志不丢失——对桌宠这种随时可能被杀死/崩溃的进程至关重要)、bufferSize = 0。
  4. fileAppender.setEncoding(charsetDefault) — 强制使用 Const.charsetDefault 写文件,保证中文在任意系统区域设置下不乱码(避免 Windows GBK 与 Linux UTF-8 差异)。
  5. 文件失败 ≠ 功能失败 — IOException 时仅记录 LogLog.error,isFileLoggerAvailable 保持 false,appender 不挂载;此时所有日志方法都会静默丢弃消息(见下文"门卫检查")。console appender 挂在 try 块之后,无论文件初始化成败都会挂上。
  6. setLevel(level) — 把静态字段 level(默认 Level.INFO)应用到 root/current logger。若之前调用过 setLevel(DEBUG) 再 initialize,级别得以保留。

日志文件头:环境快照

java
1private static PatternLayout getPatternLayout(String logPrefix) { 2 final String header = "ArkPets Log - " + new File(logPrefix).getName() + " (PID" + pid + ")"; 3 return new PatternLayout("%d{ABSOLUTE} [%p] %m%n") { 4 @Override 5 public String getHeader() { 6 return "# *** " + header + " ***" + Layout.LINE_SEP + 7 "# Created: " + LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss,SSS")) + Layout.LINE_SEP + 8 "# OS: " + System.getProperty("os.name") + " (" + System.getProperty("os.arch") + ")" + Layout.LINE_SEP + 9 "# Java version: " + System.getProperty("java.version") + Layout.LINE_SEP + 10 "# Working directory: " + System.getProperty("user.dir") + Layout.LINE_SEP + 11 Layout.LINE_SEP; 12 } 13 }; 14}

Source: Logger.java

FileAppender 每次打开新文件会写入一次 getHeader()。这份以 # 开头的环境快照使一份日志文件自带排障上下文(进程角色/PID/创建时间/OS 架构/Java 版本/工作目录),是"日志即诊断报告"设计意图的体现。

记录方法与门卫检查

java
1/** Logs a message with the level {@code ERROR}, 2 * together with the detailed information (such as stacktrace). 3 */ 4public static void error(String tag, String message, Throwable error) { 5 if (isFileLoggerAvailable) 6 currentLogger.error(combine(tag, message), error); 7} 8 9protected static String combine(String tag, String message) { 10 return tag + ": " + message; 11}

Source: Logger.java

  • debug/info/warn/error 四个方法(Logger.java#L133-L157)结构完全一致:先查 isFileLoggerAvailable 再委托 log4j。
  • 设计取舍(重要):门卫条件名为"fileLogger",但实际控制的是全部输出(含 console appender)。也就是说文件初始化失败时连控制台日志也一并静默。这是一种保守取舍:半残的日志系统输出不完整日志反而可能误导排障,宁可完全静默由 LogLog 提示一次错误。
  • 唯一带 Throwable 的重载是 error——堆栈信息只在最高级别才落盘,避免 DEBUG/INFO 刷爆日志体积。
  • combine 把 tag 与消息拼接为 "tag: message",因此 log4j 层看到的始终是单字符串,%m 输出形如 LogDialog: Refreshing table。

日志级别体系

java
1public static final int ERROR = 40000; 2public static final int WARN = 30000; 3public static final int INFO = 20000; 4public static final int DEBUG = 10000;

Source: Logger.java

int 常量与 log4j 1.x Level 数值一致,供外部(如启动器在"调试模式"下切到 DEBUG)以 setLevel(int)/setLevel(String) 动态调整。三个 setLevel 重载(Logger.java#L102-L122)同时更新静态字段 level 与 root/current logger,保证后续 initialize 不会把级别重置回 INFO。注意 log4j 1.x 的 Level.toLevel(String) 对无法识别的字符串会回退到 DEBUG 而非抛错,配置拼写错误不会导致崩溃,但会让日志变多。

日志滚动清理:Cleaner

java
1public static void cleanByModifiedTime(String logPrefixName, int maxFileCount) { 2 List<File> fileList = getAllLogs(logPrefixName); 3 maxFileCount = Math.max(1, maxFileCount); 4 if (fileList.size() >= maxFileCount) { 5 sortByModifiedTime(fileList); 6 deleteButKeep(fileList, maxFileCount); 7 } 8}

Source: Logger.java

Cleaner 实现的是"同前缀文件数量上限"策略而非按大小滚动:

  • getAllLogs 用 dir.listFiles(pathname -> pathname.getName().indexOf(finalLogPrefixName) == 0) 取以日志前缀开头的所有文件(注意是"前缀匹配"而非精确匹配,logs/core 会匹配到 core.1234.log;副作用是若前缀本身就是另一前缀的子串可能多删);
  • sortByModifiedTime 按 File.lastModified() 升序(最旧在前);
  • deleteButKeep 删除列表头部多余的旧文件,删除时的 SecurityException 被捕获并记 LogLog.error,清理失败不阻断日志初始化;
  • 默认 maxFileCount = 256(Logger.java#L26),initialize 时实际传入上限可覆盖。

该策略的意图:桌面宠物应用每次启动都产生新 PID 新文件,若不清理,长期使用会积累海量小文件;按"数量 + 修改时间"淘汰最旧日志即可控制磁盘占用,且实现只有 ~40 行,无需引入额外滚动框架。

Core Flow

日志消息的完整生命周期

Loading diagram...

时序要点:

  1. 一次调用最多三路输出并行发生(log4j 遍历 root logger 的全部 appender)。
  2. WARN 恰好落在两个过滤器的边界上:sysOutFilter.setLevelMax(Level.toLevel(WARN)) 含 WARN,sysErrFilter.setLevelMin(Level.toLevel(ERROR)) 从 ERROR 起——因此 WARN 进 stdout,ERROR/FATAL 进 stderr,DEBUG/INFO 也进 stdout。
  3. 文件写入为无缓冲直写(bufferedIO = false),进程被强杀时已写日志不丢失。

进程启动 → 用户导出的端到端流程

Loading diagram...

调试模式 UI:LogDialog

LogDialog 是 desktop 模块实现 DialogController<ArkHomeFX> 的对话框控制器,绑定 assets/UI/LogDialog.fxml。其日志发现逻辑:

java
1public void refreshTable() { 2 Logger.info("LogDialog", "Refreshing table"); 3 logView.getSelectionModel().clearSelection(); 4 loadLogFiles( 5 desktopLogList, 6 new File(logDir), 7 (dir, name) -> name.startsWith("desktop") && name.endsWith(".log") 8 ); 9 loadLogFiles( 10 coreLogList, 11 new File(logDir), 12 (dir, name) -> name.startsWith("core") && name.endsWith(".log") 13 ); 14 loadLogFiles( 15 jvmCrashLogList, 16 new File("."), 17 (dir, name) -> name.startsWith("hs_err_pid") && name.endsWith(".log") 18 ); 19 GuiPrefabs.Tables.autoResizeColumns(logView, 20.0); 20 GuiPrefabs.Tables.autoExpandRows(logView); 21 logView.refresh(); 22}

Source: LogDialog.java

三个 ObservableList 对应树的三个分组根节点,loadLogFiles 的过滤条件揭示了两类日志来源的物理位置差异:

java
1private void loadLogFiles(ObservableList<LogItem> targetList, File directory, FilenameFilter filter) { 2 targetList.clear(); 3 File[] logFiles = directory.listFiles(filter); 4 if (logFiles != null) { 5 List<LogItem> logItems = Arrays.stream(logFiles) 6 .map(LogItem::new) 7 .filter(LogItem::isAvailable) 8 .sorted(Comparator.comparingLong(logItem -> -logItem.modifiedTime.toEpochMilli())) 9 .toList(); 10 targetList.addAll(logItems); 11 Logger.debug("LogDialog", "Found " + logItems.size() + " log files in \"" + directory + "\""); 12 } else { 13 Logger.warn("LogDialog", "Directory \"" + directory + "\" is not available"); 14 } 15}

Source: LogDialog.java

  • 目录扫描失败(listFiles 返回 null)时降级为 WARN 日志,不抛异常、不白屏——对查看器这种辅助功能,可用性优先。
  • 按修改时间倒序排列(-logItem.modifiedTime.toEpochMilli()),最新的日志总是排在最前,符合"用户最关心最近一次问题"的直觉。
  • LogItem.isAvailable() 过滤不可用条目,表格渲染时用 HumanSize/HumanInstant 包装值并标记不可用状态(LogDialog.java#L145-L154)。

信息面板监听器实现了"递归选择"交互:选中一个分组根节点时,通过 Platform.runLater 异步把其全部子节点也加入选集,并实时更新"已选择 N 个文件"计数(LogDialog.java#L195-L220)。Platform.runLater 的使用是必要的:在 ListChangeListener 回调里同步修改选集可能触发 ConcurrentModificationException,延迟到 JavaFX 事件队列下一拍处理则安全。同时文件顶部导入的 ZipTask(LogDialog.java#L8)表明导出走通用后台打包任务,quickSelectAll / quickSelectRecent 两个快速选择按钮则对应"全选/选最近"两种典型排障场景。

Usage Examples

基础用法:按级别打日志

java
Logger.info("LogDialog", "Refreshing table"); Logger.debug("LogDialog", "Clearing table"); Logger.warn("LogDialog", "Directory \"" + directory + "\" is not available");

Source: LogDialog.java

调用方只需提供稳定的 tag 字符串与人类可读消息。生成的文件日志形如 14:23:05,781 [INFO] LogDialog: Refreshing table,控制台形如 [INFO] LogDialog: Refreshing table。

进阶用法:带异常堆栈的错误日志

java
} catch (SecurityException e) { LogLog.error("Cannot delete file: " + fileList.get(i), e); }

Source: Logger.java

上述是 log4j 自身的 LogLog 内部诊断 API;业务代码对等写法是 Logger.error(tag, message, throwable)(Logger.java#L162-L165),会把堆栈完整写入日志文件。这是 error 独有的重载——错误排障必须依赖堆栈,而低级别日志带堆栈会显著膨胀文件。

进阶用法:初始化日志系统并限制文件数量

java
Logger.initialize("logs/desktop", 256); Logger.setLevel(Logger.DEBUG); // int 重载,进入调试模式

Source: Logger.java

initialize 会清理 logs/desktop* 中除最新 255 个以外的旧文件,然后打开 logs/desktop.{当前PID}.log。

Configuration Options

选项类型默认值说明
logPrefix(initialize 参数)String—日志路径与前缀,决定文件名 {logPrefix}.{PID}.log;core 进程为 core、desktop 进程为 desktop,LogDialog 据此前缀识别分组
maxFileCount(initialize 参数)int256(静态字段)同前缀日志文件数量上限;清理时保留 maxFileCount - 1 个旧文件为新文件留位
Logger.level(静态字段)LevelLevel.INFO全局日志级别;initialize 结束时会重新应用
charsetDefault(Const)String—日志文件编码,写入 FileAppender.setEncoding,统一跨平台字符集
Const.LogConfig.logDirStringlogs/ 相关常量日志目录;LogDialog 扫描 core/desktop 日志与"在系统中打开"按钮均引用该常量(LogDialog.java#L35)
stdout 过滤上限LevelWARN(Level.toLevel(WARN))静态块设定,sysOutFilter 的 levelMax
stderr 过滤下限LevelERROR(Level.toLevel(ERROR))静态块设定,sysErrFilter 的 levelMin
FileAppender 追加模式booleanfalse同名日志文件覆盖写(文件名含 PID,正常情况下不会撞名)
FileAppender 缓冲booleanfalse(bufferSize = 0)无缓冲直写,崩溃不丢日志

API Reference

Logger.initialize(logPrefix: String, maxFileCount: int): void

初始化静态日志器(默认级别 INFO)。重建 appender、先清理旧日志再打开 {logPrefix}.{PID}.log、挂载 stdout/stderr 双控制台 appender、重设级别并置 isInitialized = true。

Parameters:

  • logPrefix(String):日志路径与前缀,如 "logs/myLog"
  • maxFileCount(int):同前缀日志数量上限,内部会 Math.max(1, maxFileCount) 下限保护

Throws: 不抛出——文件初始化的 IOException 被捕获,仅经 LogLog.error 记录并降级为无文件日志模式。

Source: Logger.java

Logger.setLevel(level): void(三个重载)

java
public static void setLevel(Level level) public static void setLevel(int level) public static void setLevel(String level)

Source: Logger.java

同时更新静态字段 Logger.level 与 rootLogger/currentLogger 的级别。int 与 String 重载经 Level.toLevel 归一(未识别字符串回退 DEBUG)。

Logger.getLevel(): Level

返回 rootLogger.getLevel(),即当前生效级别。

Source: Logger.java

Logger.debug / info / warn / error(tag: String, message: String): void

四个同构方法:门卫检查 isFileLoggerAvailable 后委托 currentLogger 对应级别输出 combine(tag, message)。

Source: Logger.java

Logger.error(tag: String, message: String, error: Throwable): void

在 error 基础上携带异常对象,log4j 会把完整堆栈写入输出目标。这是唯一接受 Throwable 的 API。

Source: Logger.java

LogDialog 对话框生命周期方法

方法签名行为
initializeWith(ArkHomeFX app): void初始化三个 ObservableList、建表、建信息面板、绑定"刷新/在系统中打开"按钮
notifyDialogOpened(Object data): void每次对话框打开时触发 refreshTable()
notifyDialogClosed(): void关闭时 clearTable() 清空列表
refreshTable(): void扫描三个日志来源并重建表格
clearTable(): void清空 core/desktop 日志列表
getDialogPane / getReturnButton—DialogController 接口要求的 FXML 根节点与返回按钮

Source: LogDialog.java

Failure Modes, Edge Cases & Concurrency

场景行为源码依据
日志文件无法创建(目录只读/磁盘满)IOException 被捕获,LogLog.error 提示,isFileLoggerAvailable 保持 false,此后所有级别日志静默丢弃Logger.java#L67-L69
重复调用 initializeremoveAllAppenders() 先行清空,不会重复输出Logger.java#L50
同前缀日志超上限初始化时先清理到 maxFileCount - 1,再开新文件Logger.java#L60
删除旧日志被安全管理器拒绝SecurityException 捕获,LogLog.error 记录后继续初始化Logger.java#L189-L191
Level.toLevel 收到非法字符串回退 DEBUG(log4j 1.x 行为),不抛错Logger.java#L112
日志目录不存在/不可读(查看器侧)listFiles 返回 null → WARN 日志,表格该分组为空LogDialog.java#L187-L189
JVM 致命崩溃崩溃日志 hs_err_pid*.log 由 JVM 写在工作目录而非日志目录,LogDialog 单独扫描 .LogDialog.java#L123-L127
多进程并发写日志文件名含 PID,core 与 desktop 各写各的文件,无锁竞争Logger.java#L58
UI 选集回调中修改选集递归选子节点包在 Platform.runLater 中延迟到 FX 事件队列,避免并发修改LogDialog.java#L204-L207

值得注意的两个边界:

  • 同 PID 重启覆盖:append = false 意味着若 PID 被操作系统复用且日志未被清理,旧日志会被直接覆盖。因为 Cleaner 在每次初始化时都按数量清理,实际暴露窗口很小,这是用简洁换轻微风险的取舍。
  • 前缀匹配的宽匹配:getAllLogs 用 indexOf(prefix) == 0 判定,logs/core 前缀同样匹配 coreXXX.log 之类意外文件;由于日志目录由应用自管,此风险被接受。

Performance & Operational Notes

  • 无缓冲直写(bufferedIO = false):每次日志调用都触发文件写入,换取崩溃时日志完整性。桌宠进程常被用户直接关闭甚至强杀,日志可靠性优先于吞吐。
  • 静态门面零分配:业务调用路径上只有一次字符串拼接(combine)与 log4j 事件对象,无锁、无 per-module logger 查找(直接用根 Logger),调用成本极低。
  • 日志文件数量受控:maxFileCount(默认 256)+ 每次初始化清理,防止桌面应用长期运行累积小文件。单文件体积无上限——若某进程长时间停留在 DEBUG 级别,文件可能显著增大,运维上应以"调到 DEBUG 时才需要关注体积"理解。
  • 调试模式的操作路径:用户侧调试流程为"启动器 → 打开日志对话框(notifyDialogOpened 自动 refreshTable)→ 全选/选最近 → 导出";logExplore 按钮调用 app.popBrowser(new File(logDir).toURI()) 直接在系统文件管理器中打开日志目录(LogDialog.java#L87)。

Extension Points

  • 新增日志分组:LogDialog 的分组由"分组根 LogItem + ObservableList + loadLogFiles(filter)"三元组构成(LogDialog.java#L156-L161)。要展示新的日志类别(例如某个子组件日志),按同样模式追加一个列表与前缀过滤即可,RecursiveTreeItem 会自动纳入树。
  • 替换日志后端:Logger 是唯一对业务代码暴露的 API,内部全部经由 log4j 1.x 根 Logger。若需迁移到其它日志框架,只需重写 Logger 内部实现,业务调用点(Logger.info(tag, msg) 等)无需改动。
  • 动态级别调整:三个 setLevel 重载(含 int/String)即为运行时调试开关的挂点,可在启动器设置界面接入以实现"调试模式"一键切换。
  • 自定义文件头:重写 getPatternLayout 中 getHeader() 即可扩充环境快照字段(如显卡、内存等)。

Tests

本仓库当前未发现针对 Logger 或 LogDialog 的单元测试文件(在源码探索范围内未检索到相关测试)。上述行为描述均直接源自实现代码本身;若后续引入测试,Cleaner.cleanByModifiedTime 的数量边界(恰好等于/超过上限)与 initialize 的幂等性是最值得覆盖的场景。

  • Logger.java — 日志门面与文件清理核心实现
  • LogDialog.java — 日志查看对话框控制器
  • LogDialog.fxml — 日志对话框界面布局
  • 桌宠渲染进程与进程模型 → 参见启动器/进程管理相关页面
  • GUI 对话框框架(DialogController、DialogComposer)→ 参见启动器界面架构页面
  • 应用配置体系(Const、ArkPetsConfig)→ 参见配置相关页面

Sources

(2 files)
core/src/cn/harryh/arkpets/utils
desktop/src/cn/harryh/arkpets/controllers