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 定义了两组自定义任务:
| 任务组 | 任务 | 用途 |
|---|---|---|
execute | run、debug | 在开发机直接启动应用(含/不含调试) |
dist | distJar、jlink、jpackage、distZip、distExe、distAll | 生成可分发的 JAR / ZIP / EXE 产物 |
这种设计的意图是:构建脚本即发布流水线。开发者只需要一条 gradle distAll,即可在本地产出与官方 Release 完全一致的发行物,避免"本地能跑、打包不行"的偏差。
架构
架构要点解读:
- 多模块但单入口:
core与desktop各自持有build.gradle,但只有desktop定义mainClassName(即cn.harryh.arkpets.DesktopLauncher),因此所有"运行"与"打包"任务都注册在desktop模块上。 - 资源目录跨模块共享:
desktop模块不复制资源,而是直接把源码集资源根指向仓库级../assets,保证构建产物与仓库资源始终同步。 - 根项目属性作为唯一事实来源:
appName、appAuthor、project.version等在根项目级别定义,desktop脚本以project.appName、project.version形式消费,用于eclipse.project.name、jpackage 的--name/--vendor/--app-version以及发行物命名distName。 - 操作系统探测驱动条件分支:通过
org.gradle.internal.os.OperatingSystem与System.getProperty('os.name')两条途径探测平台,分别作用于run任务的 JVM 参数与jpackage的图标/参数选择。
核心流程:distAll 任务依赖链
dist 组内任务通过 dependsOn 形成一条严格串行的流水线,任何一环失败都会中断后续打包:
这条依赖链的设计意图是"自下而上、逐层复用":
distJar产出的 Fat JAR 同时被jpackage --input与distAll直接发行(ZIP 内含的 EXE 与独立 JAR 都来自同一份构建)。jlink产出的精简运行时只服务于jpackage的--runtime-image,确保最终 EXE 不依赖用户机器上的 JDK。distAll是唯一会清理中间产物(build/libs、build/jlink、build/jpackage)的任务,避免这些目录膨胀或在下次构建时触发"遗留 JAR"校验失败。
实现详解
源码集与非标准目录布局
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 排除项
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。
运行与调试任务
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
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使用,两套命名互不干扰。
定制 JRE:jlink
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
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 / 汇总任务
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):
| 变量 | 取值 / 来源 | 用途 |
|---|---|---|
rootDir | project.rootDir | 读取 LICENSE、distExe 工作目录 |
javaHome | System.getProperty('java.home') | 定位 jlink / jpackage 可执行文件与 jmods |
osName | os.name 归一化 | 决定图标与 macOS JVM 参数 |
osPathSep | File.pathSeparatorChar | 跨平台路径分隔符 |
jarLibDir | build/libs | Fat JAR 输出 & jpackage --input |
jarLibName | ${project.name}-${project.version} | distJar 产物原始命名 |
jlinkDir | build/jlink | 裁剪运行时输出 |
jlinkModuleList | java.base,java.desktop,java.logging,java.management,java.scripting,jdk.crypto.ec,jdk.localedata,jdk.management,jdk.unsupported | jlink 模块清单 |
jlinkLocalesList | en-US,zh-CN | 保留的 locale |
jpackageDir | build/jpackage | app-image 输出 |
issFileRel | docs/scripts/ExePacking.iss | Inno Setup 脚本相对路径 |
distDir | build/dist | 最终发行物目录 |
distName | ${appName}-v${version} | 发行物统一命名前缀 |
外部输入属性:
| 属性 | 传入方式 | 默认 | 作用 |
|---|---|---|---|
SENTRY_DSN | -PSENTRY_DSN=... 或 gradle.properties | 空字符串 | 注入 run/debug/jpackage 的 -Dsentry.dsn |
appName / appAuthor / project.version | 根项目定义 | — | 产物命名、jpackage 元数据 |
任务参考
| 任务 | 类型 | 依赖 | 说明 |
|---|---|---|---|
run | JavaExec | classes | 以资源目录为工作目录启动应用,忽略退出码;macOS 自动加 -XstartOnFirstThread |
debug | JavaExec | classes | 同 run,附加 setDebug(true),等待调试器连接 |
distJar | Jar | classes, runtimeClasspath | 生成带 Main-Class 的 Fat JAR,并复制改名到 build/dist |
jlink | Exec | distJar | 调用 JDK jlink 生成精简运行时 build/jlink/runtime |
jpackage | Exec | jlink | 调用 JDK jpackage 生成 app-image,前置于 build/libs 残留校验 |
distZip | Zip | jpackage | 将 app-image + README 打成 ${distName}.zip |
distExe | Exec | jpackage | 调用 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的清理放在所有产物任务完成后执行。
使用示例
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:distAllSource: desktop/build.gradle
相关链接
- desktop/build.gradle — 本页核心源码
- core/build.gradle — core 模块构建脚本
- Inno Setup 安装脚本(
docs/scripts/ExePacking.iss)的 EXE 打包细节属于"发布与打包"主题,见对应兄弟页面。