Repository Wiki
isHarryh/Ark-Pets

Gradle 多模块构建

Ark-Pets 采用 Gradle 多模块布局(core 模块 + desktop 模块),其中 desktop/build.gradle 承担了从"编译 → Fat JAR → jlink 裁剪 JRE → jpackage 打包 → ZIP/EXE 发行"的完整构建与分发流水线。

目的与范围

本页覆盖仓库的 Gradle 多模块构建体系,重点讲解桌面端模块 desktop/build.gradle 中的:

  • 源码集(sourceSets)与非标准目录布局(源码在 src/、资源在 ../assets)
  • 运行/调试任务(run / debug)与平台相关 JVM 参数
  • 资源过滤(processResources 排除模型资源)
  • 分发任务链(distJar → jlink → jpackage → distZip / distExe → distAll)

不属于本页的内容(由兄弟页面承接):Inno Setup 安装脚本 docs/scripts/ExePacking.iss 的语法细节、core 模块内部业务逻辑、模型资源(models / models_enemies)的运行时下载机制、以及 CI 发布流程。本页仅在构建脚本引用到它们时做必要的边界说明。

概述

仓库采用典型的 libGDX 风格双模块结构:core/build.gradle 与 desktop/build.gradle。desktop 模块是可执行桌面应用模块,入口类为 cn.harryh.arkpets.DesktopLauncher;两个模块共用仓库根目录下的 assets 资源目录(通过 sourceSets.main.resources.srcDirs = ["../assets"] 引入)。

desktop/build.gradle 定义了两组自定义任务:

任务组任务用途
executerun、debug在开发机直接启动应用(含/不含调试)
distdistJar、jlink、jpackage、distZip、distExe、distAll生成可分发的 JAR / ZIP / EXE 产物

这种设计的意图是:构建脚本即发布流水线。开发者只需要一条 gradle distAll,即可在本地产出与官方 Release 完全一致的发行物,避免"本地能跑、打包不行"的偏差。

架构

Loading diagram...

架构要点解读:

  1. 多模块但单入口:core 与 desktop 各自持有 build.gradle,但只有 desktop 定义 mainClassName(即 cn.harryh.arkpets.DesktopLauncher),因此所有"运行"与"打包"任务都注册在 desktop 模块上。
  2. 资源目录跨模块共享:desktop 模块不复制资源,而是直接把源码集资源根指向仓库级 ../assets,保证构建产物与仓库资源始终同步。
  3. 根项目属性作为唯一事实来源:appName、appAuthor、project.version 等在根项目级别定义,desktop 脚本以 project.appName、project.version 形式消费,用于 eclipse.project.name、jpackage 的 --name/--vendor/--app-version 以及发行物命名 distName。
  4. 操作系统探测驱动条件分支:通过 org.gradle.internal.os.OperatingSystem 与 System.getProperty('os.name') 两条途径探测平台,分别作用于 run 任务的 JVM 参数与 jpackage 的图标/参数选择。

核心流程:distAll 任务依赖链

dist 组内任务通过 dependsOn 形成一条严格串行的流水线,任何一环失败都会中断后续打包:

Loading diagram...

这条依赖链的设计意图是"自下而上、逐层复用":

  • distJar 产出的 Fat JAR 同时被 jpackage --input 与 distAll 直接发行(ZIP 内含的 EXE 与独立 JAR 都来自同一份构建)。
  • jlink 产出的精简运行时只服务于 jpackage 的 --runtime-image,确保最终 EXE 不依赖用户机器上的 JDK。
  • distAll 是唯一会清理中间产物(build/libs、build/jlink、build/jpackage)的任务,避免这些目录膨胀或在下次构建时触发"遗留 JAR"校验失败。

实现详解

源码集与非标准目录布局

groovy
1[compileJava, compileTestJava]*.options*.encoding = "UTF-8" 2sourceSets.main.java.srcDirs = ["src/"] 3sourceSets.main.resources.srcDirs = ["../assets"] 4 5project.ext.mainClassName = "cn.harryh.arkpets.DesktopLauncher" 6project.ext.assetsDir = new File("../assets") 7eclipse.project.name = appName + "-desktop"

Source: desktop/build.gradle

  • 强制 UTF-8 编译编码,保证中文资源与源码在任意平台上行为一致。
  • 源码目录被重定向到 desktop/src(而非 Gradle 默认的 src/main/java),这是 libGDX 项目生成器的惯用布局,仓库沿用了它。
  • 资源目录指向仓库根的 assets/,processResources、run 的 workingDir、jpackage 的图标路径都依赖同一个 project.ext.assetsDir,形成了单一引用点。
  • mainClassName 与 assetsDir 存放在 project.ext 上,供本文件后续多个任务复用。

资源过滤:processResources 排除项

groovy
1processResources { 2 includeEmptyDirs = false 3 excludes = [ 4 "**/models_enemies/**", 5 "**/models/**", 6 "**/logs/**", 7 "models_data.json" 8 ] 9}

Source: desktop/build.gradle

构建产物(JAR/EXE)会剥离体积庞大的模型资源(models/、models_enemies/、models_data.json)以及运行日志目录。这解释了为什么发行包体积可控:模型在运行时由应用自身另行获取,而 JAR 内只保留代码与少量必要资源。includeEmptyDirs = false 进一步避免把被排除后残留的空目录打进 JAR。

运行与调试任务

groovy
1def sentryDsn = (project.findProperty("SENTRY_DSN") ?: "").trim() 2 3// Runs the app without debug. 4tasks.register("run", JavaExec) { 5 dependsOn = ["classes"] 6 group = "execute" 7 8 mainClass = mainClassName 9 classpath = sourceSets.main.runtimeClasspath 10 standardInput = System.in 11 workingDir = assetsDir 12 13 setIgnoreExitValue(true) 14 15 jvmArgs += "-Dfile.encoding=UTF-8" 16 if (sentryDsn) 17 jvmArgs += "-Dsentry.dsn=${sentryDsn}" 18 19 if (OperatingSystem.current() == OperatingSystem.MAC_OS) { 20 jvmArgs += "-XstartOnFirstThread" // Required to run on macOS 21 } 22}

Source: desktop/build.gradle

关键行为:

  • 工作目录即资源目录:workingDir = assetsDir,应用按相对路径读取资源,与打包后的运行时布局保持一致。
  • setIgnoreExitValue(true):应用的退出码不会让 Gradle 构建失败,便于反复启动调试。
  • Sentry DSN 可选注入:通过 -PSENTRY_DSN=... 或 gradle.properties 传入;未设置时完全跳过,本地开发不会产生错误上报。
  • macOS 特判:libGDX/LWJGL 在 macOS 上要求主线程启动,必须附加 -XstartOnFirstThread。
  • debug 任务与之完全同构,仅多 setDebug(true)(JVM 挂起等待调试器)且不做 macOS 线程特判。

Fat JAR 生成:distJar

groovy
1tasks.register("distJar", Jar) { 2 dependsOn = ["classes"] 3 group = "dist" 4 5 doLast() { 6 copy { 7 from "${jarLibDir}/${jarLibName}.jar" 8 into distDir 9 rename "${jarLibName}.jar", "${distName}.jar" 10 } 11 } 12 13 duplicatesStrategy = DuplicatesStrategy.EXCLUDE 14 manifest { 15 attributes 'Main-Class': mainClassName 16 } 17 dependsOn configurations.runtimeClasspath 18 from { 19 configurations.runtimeClasspath.collect { it.isDirectory() ? it : zipTree(it) } 20 } 21 with jar 22}

Source: desktop/build.gradle

  • 生成可执行 Fat JAR:把 runtimeClasspath 上所有依赖(含 core 模块产物)解压合并进单一 JAR,并在清单写入 Main-Class: cn.harryh.arkpets.DesktopLauncher。
  • duplicatesStrategy = DuplicatesStrategy.EXCLUDE:合并大量依赖时必然出现同名资源(如 META-INF 服务文件),采用"先到先得"策略避免构建失败。
  • doLast 把 JAR 复制并重命名为 ${appName}-v${version}.jar 放入 build/dist,让原始坐标命名(desktop-<version>.jar)留给 jpackage --input 使用,两套命名互不干扰。
groovy
1tasks.register("jlink", Exec) { 2 dependsOn = ["distJar"] 3 group = "dist" 4 5 doFirst() { delete jlinkDir } 6 7 workingDir project.projectDir 8 inputs.property("runtime", "${jlinkDir}/runtime") 9 commandLine = [ 10 "${javaHome}/bin/jlink", 11 '--module-path', "${javaHome}/jmods", 12 '--add-modules', jlinkModuleList, 13 '--output', "${jlinkDir}/runtime", 14 '--strip-debug', '--no-header-files', '--no-man-pages', 15 '--vm=server', '--compress=1', 16 '--include-locales', jlinkLocalesList 17 ] as List<String> 18 outputs.dir(jlinkDir) 19}

Source: desktop/build.gradle

  • 模块清单 jlinkModuleList = "java.base,java.desktop,java.logging,java.management,java.scripting,jdk.crypto.ec,jdk.localedata,jdk.management,jdk.unsupported" 精确圈定运行时所需 JDK 模块;jdk.crypto.ec 支持椭圆曲线 TLS(网络请求),jdk.unsupported 覆盖 LWJGL 依赖的 sun.misc 内部 API。
  • --include-locales en-US,zh-CN 只保留英文与简体中文 locale 数据,显著缩小体积——这是中文桌面应用的典型裁剪策略。
  • --vm=server 选择 Server VM,--compress=1 压缩运行时镜像。
  • 任务用 inputs/outputs 声明增量构建边界,并用 doFirst { delete jlinkDir } 保证幂等。

应用镜像打包:jpackage

groovy
1tasks.register("jpackage", Exec) { 2 dependsOn = ["jlink"] 3 group = "dist" 4 5 doFirst() { 6 fileTree(jarLibDir).size() 7 if (fileTree(jarLibDir).size() > 1) 8 throw new RuntimeException("There may be legacy jars in the libs dir, please run 'clean' first.") 9 delete jpackageDir 10 } 11 doLast() { 12 copy { 13 from "${rootDir}/LICENSE" 14 into jpackageDir 15 } 16 } 17 18 workingDir project.projectDir 19 def commands = [ 20 "${javaHome}/bin/jpackage", 21 '--input', jarLibDir, 22 '--dest', jpackageDir, 23 '--type', 'app-image', 24 '--name', appName, 25 '--vendor', appAuthor, 26 '--app-version', project.version, 27 '--main-class', mainClassName, 28 '--main-jar', jar.archiveFile.get().asFile.getName(), 29 '--runtime-image', "${jlinkDir}/runtime", 30 '--java-options', '-Dfile.encoding=UTF-8', 31 '--java-options', '-Dsentry.environment=production' 32 ] 33 if (sentryDsn) { 34 commands << '--java-options' 35 commands << "-Dsentry.dsn=${sentryDsn}" 36 } 37 if (osName.contains('windows')) { 38 commands << '--icon' 39 commands << "${assetsDir}/icons/icon.ico" 40 } else if (osName.contains('linux')) { 41 commands << '--icon' 42 commands << "${assetsDir}/icons/icon.png" 43 } else if (osName.contains('mac')) { 44 commands << '--java-options' 45 commands << "-XstartOnFirstThread" 46 } 47 commandLine = commands as List<String> 48}

Source: desktop/build.gradle

  • 前置防错校验:doFirst 检查 build/libs 下 Jar 数量,超过 1 个即抛 RuntimeException,防止旧版本 JAR 与新 JAR 一同被打进镜像。
  • --type app-image 产出"免安装、自包含"应用镜像;真正的 Windows 安装器由后续 distExe 用 Inno Setup 生成。
  • --java-options '-Dsentry.environment=production':把发行版打上生产环境标记,与本地 run 区分错误上报来源。
  • 平台差异集中在图标选择(Windows 用 icon.ico、Linux 用 icon.png、macOS 无图标参数但补 -XstartOnFirstThread)。
  • doLast 把根目录 LICENSE 复制进镜像,满足发行合规。

ZIP / EXE / 汇总任务

groovy
1tasks.register("distZip", Zip) { 2 dependsOn = ["jpackage"] 3 group = "dist" 4 from(jpackageDir) { include("**") } 5 from(rootDir) { include("README.md") } 6 archiveFileName = "${distName}.zip" 7 destinationDirectory = file(distDir) 8} 9 10tasks.register("distExe", Exec) { 11 dependsOn = ["jpackage"] 12 group = "dist" 13 workingDir rootDir 14 def commands = ["iscc", "/Q", issFileRel] 15 commandLine = commands 16} 17 18tasks.register("distAll") { 19 dependsOn = ["distJar", "distZip", "distExe"] 20 group = "dist" 21 doLast() { 22 logger.lifecycle("All files were successfully generated, see: ${new File(distDir as String).absolutePath}") 23 try { 24 delete jarLibDir 25 delete jlinkDir 26 delete jpackageDir 27 } catch (Exception ignored) { 28 logger.lifecycle("Unable to delete temp files.") 29 } 30 } 31}

Source: desktop/build.gradle

  • distZip = app-image + README.md,命名为 ${appName}-v${version}.zip,对应 GitHub Release 的便携版。
  • distExe 调用 iscc(Inno Setup 编译器,需自行安装并加入 PATH)静默编译 docs/scripts/ExePacking.iss。
  • distAll 在全部产物生成后尝试清理中间目录;清理失败仅记日志不失败——正确性优先于整洁。

配置参考

以下变量均在 desktop/build.gradle 的 ext 块中集中定义(对应源码 L65-L81):

变量取值 / 来源用途
rootDirproject.rootDir读取 LICENSE、distExe 工作目录
javaHomeSystem.getProperty('java.home')定位 jlink / jpackage 可执行文件与 jmods
osNameos.name 归一化决定图标与 macOS JVM 参数
osPathSepFile.pathSeparatorChar跨平台路径分隔符
jarLibDirbuild/libsFat JAR 输出 & jpackage --input
jarLibName${project.name}-${project.version}distJar 产物原始命名
jlinkDirbuild/jlink裁剪运行时输出
jlinkModuleListjava.base,java.desktop,java.logging,java.management,java.scripting,jdk.crypto.ec,jdk.localedata,jdk.management,jdk.unsupportedjlink 模块清单
jlinkLocalesListen-US,zh-CN保留的 locale
jpackageDirbuild/jpackageapp-image 输出
issFileReldocs/scripts/ExePacking.issInno Setup 脚本相对路径
distDirbuild/dist最终发行物目录
distName${appName}-v${version}发行物统一命名前缀

外部输入属性:

属性传入方式默认作用
SENTRY_DSN-PSENTRY_DSN=... 或 gradle.properties空字符串注入 run/debug/jpackage 的 -Dsentry.dsn
appName / appAuthor / project.version根项目定义—产物命名、jpackage 元数据

任务参考

任务类型依赖说明
runJavaExecclasses以资源目录为工作目录启动应用,忽略退出码;macOS 自动加 -XstartOnFirstThread
debugJavaExecclasses同 run,附加 setDebug(true),等待调试器连接
distJarJarclasses, runtimeClasspath生成带 Main-Class 的 Fat JAR,并复制改名到 build/dist
jlinkExecdistJar调用 JDK jlink 生成精简运行时 build/jlink/runtime
jpackageExecjlink调用 JDK jpackage 生成 app-image,前置于 build/libs 残留校验
distZipZipjpackage将 app-image + README 打成 ${distName}.zip
distExeExecjpackage调用 iscc 编译 Inno Setup 脚本生成安装 EXE
distAll—distJar, distZip, distExe汇总全部产物并清理中间目录

故障模式、边界与并发

  • build/libs 残留 JAR:jpackage 的 doFirst 显式抛出 RuntimeException("There may be legacy jars in the libs dir, please run 'clean' first.")。版本号变化会导致 jarLibName 改变,旧 JAR 与新 JAR 共存时,jpackage 会把两者都打入镜像,因此脚本选择"快速失败"。
  • 非幂等目录:jlink 与 jpackage 在 doFirst 中 delete 输出目录,避免半成品目录影响增量构建;distAll 结束时再次清理。
  • macOS 差异:只有 run 任务通过 OperatingSystem.current() == MAC_OS 判定加 -XstartOnFirstThread;debug 任务不加,jpackage 则通过 osName.contains('mac') 路径加。在 macOS 上调试时需自行在 IDE 中配置该 VM 参数。
  • 清理容错:distAll 清理临时目录时捕获所有异常并只记 lifecycle 日志,不使构建失败。
  • 外部工具依赖:distExe 硬依赖环境中的 iscc 命令;jlink/jpackage 依赖本机 JDK(java.home)包含对应工具与 jmods。缺任一工具都会在 Exec 阶段失败。
  • Gradle 并发:所有 dist 任务构成线性依赖链,天然避免并行执行导致的目录竞争;distAll 的清理放在所有产物任务完成后执行。

使用示例

bash
1# 开发运行(macOS 会自动加 -XstartOnFirstThread) 2./gradlew :desktop:run 3 4# 调试运行(等待调试器接入) 5./gradlew :desktop:debug 6 7# 注入 Sentry DSN 运行 8./gradlew :desktop:run -PSENTRY_DSN=https://xxx@sentry.example.com/1 9 10# 生成全部发行物(JAR + ZIP + EXE) 11./gradlew :desktop:distAll

Source: desktop/build.gradle

相关链接

  • desktop/build.gradle — 本页核心源码
  • core/build.gradle — core 模块构建脚本
  • Inno Setup 安装脚本(docs/scripts/ExePacking.iss)的 EXE 打包细节属于"发布与打包"主题,见对应兄弟页面。

Sources

(1 files)