Windows 安装包打包与发布
Ark-Pets 桌面端通过 Gradle 构建脚本(desktop/build.gradle)中的一组 dist 任务链,把 Java 源码依次加工为可分发产物:可执行 Fat JAR → jlink 裁剪的定制 JRE → jpackage 自包含应用镜像(含 ArkPets.exe)→ 分发用 ZIP;Windows 平台的安装程序(Setup EXE)则由仓库中的 Inno Setup 脚本 docs/scripts/ExePacking.iss 基于该应用镜像进一步封装而成。
目的与范围
本页覆盖 Windows(以及跨平台共用的)安装包/分发产物的完整打包与发布机制:
desktop/build.gradle中的分发变量(ext块)与四个分发任务:distJar、jlink、jpackage、distZip的逐行实现与设计意图;- jlink 模块裁剪清单、jpackage 参数(含 Sentry 注入与平台分支图标);
- Inno Setup 脚本
docs/scripts/ExePacking.iss在链路中的角色; - 打包链路的失败模式、增量清理策略与产物落盘位置。
不在本页范围内、留给兄弟页面的内容:
- Java 业务代码与桌宠渲染逻辑本身(属于应用实现,不属于打包);
- 日常开发构建(编译、运行、测试任务)——仅在与分发链路交叉处提及;
- Gradle 依赖仓库与版本管理的通用配置。
概述
Ark-Pets 是一个 Java 桌面应用,为了让终端用户无需预装任何 JRE 即可双击运行,项目没有直接发布 JAR,而是采用 JDK 自带的 jlink + jpackage 工具链构建"自包含应用镜像"(self-contained app-image)。镜像内部带有一个按模块裁剪、按语言裁剪的精简 JRE,Windows 平台上生成 ArkPets.exe 启动器。
发布物分两条路线:
- 免安装版:
distZip任务把build/jpackage镜像目录连同根目录README.md压缩为${distName}.zip,输出到build/dist; - Windows 安装包版:仓库提供 Inno Setup 脚本
docs/scripts/ExePacking.iss(路径由build.gradle中的issFileRel变量引用),用于把应用镜像封装为带安装向导的 Setup EXE。
所有分发任务均注册在 dist 任务组下,并通过 dependsOn 形成 distJar → jlink → jpackage → distZip 的级联链,开发者执行任一下游任务即可自动完成全部上游步骤。
架构
架构说明:
- 单一职责的任务分层:
distJar只负责产出可运行 Fat JAR;jlink只负责产出精简 JRE;jpackage负责把"JAR + JRE + 图标 + 元数据"组装成平台镜像;distZip只负责归档分发。每一步的输入都是上一步的输出目录,因此任何一个环节都可以单独重跑。 - 直接调用 JDK 工具而非插件:
jlink、jpackage都是Exec任务,commandLine显式指向${javaHome}/bin/jlink与${javaHome}/bin/jpackage,避免依赖第三方 Gradle 插件的版本兼容问题,同时保证使用当前构建所用的 JDK 的工具链。 - 镜像后处理:
jpackage的doLast把根目录LICENSE复制进jpackageDir,distZip再补充README.md——许可证与说明文档随所有发布物一起走。 - 安装程序层:
build.gradle通过issFileRel = "docs/scripts/ExePacking.iss"指向 Inno Setup 脚本,安装包以 jpackage 应用镜像为内容源(虚线表示该封装步骤发生在 Gradle 任务链之外的 Inno Setup 编译环节)。
分发变量定义(ext 块)
所有路径、模块清单与产物命名都集中定义在 desktop/build.gradle 的 ext 块中,这是打包链路的"单一配置点":
1ext {
2 // Environment vars
3 rootDir = project.rootDir
4 javaHome = System.getProperty('java.home')
5 osName = System.getProperty('os.name').toLowerCase(Locale.ROOT).split(' ')[0]
6 osPathSep = File.pathSeparatorChar
7 // Distribution related vars
8 jarLibDir = layout.buildDirectory.dir("libs").get().toString()
9 jarLibName = project.name + "-" + project.version
10 jlinkDir = layout.buildDirectory.dir("jlink").get().toString()
11 jlinkModuleList = "java.base,java.desktop,java.logging,java.management,java.scripting,jdk.crypto.ec,jdk.localedata,jdk.management,jdk.unsupported"
12 jlinkLocalesList = "en-US,zh-CN"
13 jpackageDir = layout.buildDirectory.dir("jpackage").get().toString()
14 issFileRel = "docs/scripts/ExePacking.iss"
15 distDir = layout.buildDirectory.dir("dist").get().toString()
16 distName = project.appName + "-v" + project.version
17}Source: build.gradle
变量解析
| 变量 | 类型/示例值 | 用途 |
|---|---|---|
rootDir | File | 仓库根目录,用于复制根级 LICENSE / README.md |
javaHome | java.home 系统属性 | 定位当前 JDK 的 bin/jlink、bin/jpackage 与 jmods 模块库 |
osName | windows / linux / mac | 驱动 jpackage 的平台分支(图标格式、macOS 专属 JVM 参数) |
osPathSep | ; 或 : | 平台路径分隔符 |
jarLibDir | build/libs | Fat JAR 产出目录,也是 jpackage --input 的输入目录 |
jarLibName | desktop-<version> | Gradle 默认 JAR 命名,distJar 重命名前的基础名 |
jlinkDir | build/jlink | jlink 输出目录,其中 runtime/ 即精简 JRE |
jlinkModuleList | 9 个 JDK 模块 | 裁剪进精简 JRE 的模块白名单 |
jlinkLocalesList | en-US,zh-CN | 精简 JRE 仅保留两种语言资源 |
jpackageDir | build/jpackage | jpackage 应用镜像输出目录 |
issFileRel | docs/scripts/ExePacking.iss | Windows 安装包 Inno Setup 脚本路径 |
distDir | build/dist | 最终分发物(重命名后的 JAR 与 ZIP)落盘目录 |
distName | ArkPets-v<version> | 分发文件统一命名前缀 |
设计意图:把"产物路径 + 命名 + 平台探测"收敛到一处,发布改名或调整模块时只改这一块;osName 用 split(' ')[0] 截断(例如把 Windows 11 规整为 windows),使后续 contains 判断简单可靠。
jlink 模块清单的含义
jlinkModuleList 固定包含:java.base, java.desktop, java.logging, java.management, java.scripting, jdk.crypto.ec, jdk.localedata, jdk.management, jdk.unsupported。
java.desktop—— LibGDX/LWJGL 渲染与 AWT/Swing 基础;java.scripting、jdk.unsupported—— 上层库常用(jdk.unsupported提供sun.misc.*内部 API);jdk.crypto.ec—— 椭圆曲线加密,网络(HTTPS)通信必需;jdk.localedata配合--include-locales en-US,zh-CN—— 只带英文与简体中文的本地化数据,显著压缩体积。
若未来引入依赖新 JDK 模块的库(如 java.sql、jdk.zipfs),必须同步扩充此清单,否则安装包在用户机器上会因缺模块而启动失败。
distJar:生成可执行 Fat JAR
1// Generates a distributable JAR file for the app.
2tasks.register("distJar", Jar) {
3 dependsOn = ["classes"]
4 group = "dist"
5
6 doLast() {
7 copy {
8 from "${jarLibDir}/${jarLibName}.jar"
9 into distDir
10 rename "${jarLibName}.jar", "${distName}.jar"
11 }
12 }
13
14 duplicatesStrategy = DuplicatesStrategy.EXCLUDE
15 manifest {
16 attributes 'Main-Class': mainClassName
17 }
18 dependsOn configurations.runtimeClasspath
19 from {
20 configurations.runtimeClasspath.collect { it.isDirectory() ? it : zipTree(it) }
21 }
22 with jar
23}Source: build.gradle
实现要点:
- Fat JAR 装配:
from { configurations.runtimeClasspath.collect { it.isDirectory() ? it : zipTree(it) } }把所有运行时依赖解包合并进同一个 JAR,再with jar合入本项目编译输出,实现"单 JAR 可运行",这也是后续 jpackage 的--main-jar输入。 - 冲突处理:
duplicatesStrategy = DuplicatesStrategy.EXCLUDE在多个依赖携带同名资源(META-INF/services/*等)时只保留首个条目,避免任务因重复条目失败。 - 入口声明:
manifest { attributes 'Main-Class': mainClassName }写入MANIFEST.MF,供java -jar与 jpackage 启动器定位主类。 - 发布命名:
doLast把产物从build/libs/<jarLibName>.jar复制到build/dist/,重命名为<distName>.jar(即ArkPets-v<version>.jar),供直接分发的用户下载。
jlink:构建裁剪后的精简 JRE
1// Creates a customized Java Runtime Environment for the app.
2tasks.register("jlink", Exec) {
3 dependsOn = ["distJar"]
4 group = "dist"
5
6 doFirst() { delete jlinkDir }
7
8 workingDir project.projectDir
9 inputs.property("runtime", "${jlinkDir}/runtime")
10 commandLine = [
11 "${javaHome}/bin/jlink",
12 '--module-path', "${javaHome}/jmods",
13 '--add-modules', jlinkModuleList,
14 '--output', "${jlinkDir}/runtime",
15 '--strip-debug',
16 '--no-header-files',
17 '--no-man-pages',
18 '--vm=server',
19 '--compress=1',
20 '--include-locales', jlinkLocalesList
21 ] as List<String>
22 outputs.dir(jlinkDir)
23}Source: build.gradle
实现要点:
- 任务链上游:
dependsOn = ["distJar"],先有 JAR 再有 JRE。 - 先删后建:
doFirst { delete jlinkDir }清理上一次的精简 JRE,防止陈旧模块残留在镜像里造成"体积虚胖"或行为不一致。 - 参数逐条解释:
--module-path ${javaHome}/jmods—— 从当前 JDK 的模块库取模块;--add-modules $jlinkModuleList—— 只打包白名单模块(见上节);--output ${jlinkDir}/runtime—— 输出精简 JRE 到build/jlink/runtime,随后作为 jpackage 的--runtime-image;--strip-debug/--no-header-files/--no-man-pages—— 去除调试符号、头文件与手册,压缩体积;--vm=server—— 只保留 Server VM,桌面应用的吞吐场景更优且减少一份 client VM 体积;--compress=1—— 对 jimage 内容做压缩;--include-locales en-US,zh-CN—— 仅保留英文与简体中文 locale 数据。
- 增量构建正确性:声明
inputs.property(...)与outputs.dir(jlinkDir),让 Gradle 在输入未变时跳过重建(尽管doFirst总是清目录,outputs声明仍让 up-to-date 检查有据可依)。
jpackage:组装自包含应用镜像
1// Packs the app into an EXE.
2tasks.register("jpackage", Exec) {
3 dependsOn = ["jlink"]
4 group = "dist"
5
6 doFirst() {
7 fileTree(jarLibDir).size()
8 if (fileTree(jarLibDir).size() > 1)
9 throw new RuntimeException("There may be legacy jars in the libs dir, please run 'clean' first.")
10 delete jpackageDir
11 }
12 doLast() {
13 copy {
14 from "${rootDir}/LICENSE"
15 into jpackageDir
16 }
17 }
18
19 workingDir project.projectDir
20 def commands = [
21 "${javaHome}/bin/jpackage",
22 '--input', jarLibDir,
23 '--dest', jpackageDir,
24 '--type', 'app-image',
25 '--name', appName,
26 '--vendor', appAuthor,
27 '--app-version', project.version,
28 '--main-class', mainClassName,
29 '--main-jar', jar.archiveFile.get().asFile.getName(),
30 '--runtime-image', "${jlinkDir}/runtime",
31 '--java-options', '-Dfile.encoding=UTF-8',
32 '--java-options', '-Dsentry.environment=production'
33 ]
34 if (sentryDsn) {
35 commands << '--java-options'
36 commands << "-Dsentry.dsn=${sentryDsn}"
37 }
38 if (osName.contains('windows')) {
39 commands << '--icon'
40 commands << "${assetsDir}/icons/icon.ico"
41 } else if (osName.contains('linux')) {
42 commands << '--icon'
43 commands << "${assetsDir}/icons/icon.png"
44 } else if (osName.contains('mac')) {
45 commands << '--java-options'
46 commands << "-XstartOnFirstThread"
47 }
48 commandLine = commands as List<String>
49}Source: build.gradle
实现要点:
- 产物形态:
--type app-image表示生成目录型自包含镜像(Windows 下即含ArkPets.exe与runtime/的文件夹),而不是 MSI/DMG 安装器——安装器交由 Inno Setup(Windows)这条独立路线完成,职责分离。 - 启动器元数据:
--name/--vendor/--app-version/--main-class/--main-jar决定 exe 的名称、厂商、版本与真正的 Java 入口;--main-jar直接取jar.archiveFile,保证与刚构建的 JAR 完全一致。 - 运行时注入的系统属性:
-Dfile.encoding=UTF-8—— 强制 UTF-8,规避 Windows 默认 GBK 造成的乱码;-Dsentry.environment=production—— 标记生产环境上报;- 当
sentryDsn已配置时追加-Dsentry.dsn=...,使安装版应用启用 Sentry 错误上报;未配置则完全跳过,不产生空 DSN。
- 平台分支:Windows 用
icon.ico、Linux 用icon.png(都来自assetsDir/icons/);macOS 追加-XstartOnFirstThread(LWJGL 在 macOS 上必须在主线程启动)。 - 前置防污染检查:
doFirst统计build/libs下文件数,若多于 1 个 JAR(历史构建残留)直接抛异常提示先clean——因为--input jarLibDir会把整个目录打进镜像,残留 JAR 会污染安装包并放大体积。 - 后置许可证复制:
doLast把根目录LICENSE拷入build/jpackage,使镜像与后续 ZIP、安装包都附带许可证。
distZip:生成分发 ZIP
1// Generates a distributable ZIP file for the app.
2tasks.register("distZip", Zip) {
3 dependsOn = ["jpackage"]
4 group = "dist"
5
6 from(jpackageDir) { include("**") }
7 from(rootDir) { include("README.md") }
8 archiveFileName = "${distName}.zip"
9 destinationDirectory = file(distDir)
10}Source: build.gradle
实现要点:from(jpackageDir) 打包整个应用镜像(含 runtime/、exe、LICENSE),from(rootDir) 额外塞入根目录 README.md 供用户离线阅读;输出 build/dist/ArkPets-v<version>.zip。这是"免安装绿色版"的最终形态,Windows 安装包则在其上游镜像基础上继续加工。
Windows 安装程序:Inno Setup 脚本
desktop/build.gradle 中唯一的引用是变量 issFileRel = "docs/scripts/ExePacking.iss"(见 build.gradle)。该文件是仓库中唯一的 Inno Setup 编译脚本(通过 **/*.iss 全仓扫描确认,命中 docs/scripts/ExePacking.iss),其作用是把 build/jpackage 产出的应用镜像封装为带安装向导、开始菜单/桌面快捷方式与卸载注册的 Windows Setup EXE。
注:本次未对
ExePacking.iss的内部指令(向导界面、注册表项、卸载逻辑等)做逐行核验,如需定制安装向导行为请直接阅读该脚本:ExePacking.iss。
为什么 jpackage 用 app-image 而不是直接产出 MSI? 项目把"安装体验"交给成熟的 Inno Setup:向导界面、快捷方式、卸载器、自定义安装文案都比 jpackage 原生安装器灵活;同时 app-image 镜像又是绿色版 ZIP 的直接内容源,一份产物服务两条发布路线。
核心流程
流程要点:
- 单命令级联:由于每个任务的
dependsOn指向上一环,gradlew distZip即可串起全部步骤(源码编译 → Fat JAR → 精简 JRE → 应用镜像 → ZIP)。 - 失败早暴露:
jpackage的doFirst防污染检查会在打包前中断构建,把"残留 JAR 导致的安装包污染"这类问题前移到本地构建阶段。 - 安装包是链外工序:Setup EXE 由 Inno Setup 对
build/jpackage镜像编译生成,不在 Gradle 任务图中,因此 CI 上需要单独调用 ISCC(Inno Setup 编译器)处理该脚本。
配置选项
| 配置/变量 | 类型 | 默认/取值 | 说明 |
|---|---|---|---|
jlinkModuleList | String | java.base,java.desktop,java.logging,java.management,java.scripting,jdk.crypto.ec,jdk.localedata,jdk.management,jdk.unsupported | 裁剪进精简 JRE 的 JDK 模块白名单 |
jlinkLocalesList | String | en-US,zh-CN | 精简 JRE 保留的语言环境 |
issFileRel | String | docs/scripts/ExePacking.iss | Windows 安装包 Inno Setup 脚本相对路径 |
appName / appAuthor / version | Project 属性 | —(在 build.gradle 其他位置定义) | 决定 jpackage --name/--vendor/--app-version 与产物命名 distName |
mainClassName | Project 属性 | —(在 build.gradle 其他位置定义) | Fat JAR 的 Main-Class 与 jpackage 的 --main-class |
sentryDsn | Project 属性 | 空则跳过 | 非空时向安装版注入 -Dsentry.dsn 启用错误上报 |
assetsDir/icons/icon.ico | 文件 | — | Windows 平台 jpackage 图标 |
assetsDir/icons/icon.png | 文件 | — | Linux 平台 jpackage 图标 |
| 注入的 java-options | 固定 | -Dfile.encoding=UTF-8、-Dsentry.environment=production | 所有安装版启动参数 |
API/任务参考
gradlew :desktop:distJar(类型 Jar)
- 依赖:
classes、configurations.runtimeClasspath - 行为:合并依赖产出 Fat JAR(
Main-Class: mainClassName),并在doLast复制为build/dist/${distName}.jar - 产出:
build/libs/${jarLibName}.jar、build/dist/${distName}.jar
gradlew :desktop:jlink(类型 Exec)
- 依赖:
distJar - 命令:
${javaHome}/bin/jlink --module-path ${javaHome}/jmods --add-modules $jlinkModuleList --output ${jlinkDir}/runtime --strip-debug --no-header-files --no-man-pages --vm=server --compress=1 --include-locales $jlinkLocalesList - 产出:
build/jlink/runtime(精简 JRE)
gradlew :desktop:jpackage(类型 Exec)
- 依赖:
jlink - 前置:
build/libsJAR 数量 > 1 时抛RuntimeException;删除旧jpackageDir - 命令:
${javaHome}/bin/jpackage --input $jarLibDir --dest $jpackageDir --type app-image --name $appName --vendor $appAuthor --app-version $version --main-class $mainClassName --main-jar <jar> --runtime-image ${jlinkDir}/runtime --java-options "-Dfile.encoding=UTF-8" --java-options "-Dsentry.environment=production" [--java-options "-Dsentry.dsn=..."] [--icon <平台图标>] [--java-options "-XstartOnFirstThread"] - 产出:
build/jpackage(自包含应用镜像 +LICENSE)
gradlew :desktop:distZip(类型 Zip)
- 依赖:
jpackage - 行为:归档
build/jpackage/**与根目录README.md - 产出:
build/dist/${distName}.zip
失败模式、边界与运维要点
- libs 目录残留 JAR:
jpackage的doFirst检查fileTree(jarLibDir).size() > 1即抛RuntimeException("There may be legacy jars in the libs dir, please run 'clean' first.")。因为--input jarLibDir会全量打包该目录,任何历史 JAR 都会被卷进镜像;运维上遇到该错误直接gradlew clean重跑。 - 模块缺失运行期故障:
jlinkModuleList是白名单。若新增依赖用到未列入的 JDK 模块(如java.sql、java.naming),构建能成功,但安装版在用户机器启动时抛ModuleNotFoundException类错误。升级依赖后应同步审视该清单。 - JDK 版本强绑定:
jlink/jpackage路径取自System.getProperty('java.home'),因此构建机必须使用带jmods的完整 JDK(非 JRE、非精简发行版),否则--module-path ${javaHome}/jmods不存在导致任务失败。 - 每次构建先清后建:
jlink删jlinkDir、jpackage删jpackageDir,保证镜像内容确定性;代价是这两步总是全量执行。 - 平台差异需在对应 OS 上构建:
osName分支只影响图标与 JVM 参数,exe/dmg 等原生产物仍须在对应操作系统上运行任务获得;Windows 安装包自然在 Windows 构建机上产出。 - Sentry 环境分离:安装版统一注入
-Dsentry.environment=production,开发者本地运行(不经 jpackage)则不带该属性,便于在错误上报中区分生产与开发环境。 - 安装包体积控制:体积优化主要来自三处——模块白名单裁剪、
--strip-debug/--no-header-files/--no-man-pages、--include-locales只留两种语言;再加--compress=1压缩 jimage。调整这些参数是控制 Setup EXE / ZIP 体积的直接手段。
扩展点
- 新增运行时系统属性:在
jpackage任务的commands列表中追加'--java-options', '-Dxxx=yyy'即可,参照sentryDsn的条件追加模式可做成"配置存在才注入"。 - 更换图标:替换
assetsDir/icons/icon.ico(Windows)与icon.png(Linux),无需改脚本。 - 定制安装向导:编辑
docs/scripts/ExePacking.iss(issFileRel指向的脚本),如修改向导语言、默认安装路径、快捷方式、卸载清理逻辑等;该脚本以build/jpackage镜像为内容源。 - 新增发布形态(如 MSI/DMG):在现有
app-image之外注册新的 Exec 任务调用 jpackage 的--type msi/dmg,与 Inno Setup 路线并行共存。
相关链接
- 源码:desktop/build.gradle(
DISTRIBUTION TASKS全部内容) - 源码:docs/scripts/ExePacking.iss(Windows 安装包 Inno Setup 脚本)
- 相邻主题:构建任务体系与依赖管理(
dist任务组之外的常规构建)请参见对应的构建系统页面