Repository Wiki
isHarryh/Ark-Pets

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 启动器。

发布物分两条路线:

  1. 免安装版:distZip 任务把 build/jpackage 镜像目录连同根目录 README.md 压缩为 ${distName}.zip,输出到 build/dist;
  2. Windows 安装包版:仓库提供 Inno Setup 脚本 docs/scripts/ExePacking.iss(路径由 build.gradle 中的 issFileRel 变量引用),用于把应用镜像封装为带安装向导的 Setup EXE。

所有分发任务均注册在 dist 任务组下,并通过 dependsOn 形成 distJar → jlink → jpackage → distZip 的级联链,开发者执行任一下游任务即可自动完成全部上游步骤。

架构

Loading diagram...

架构说明:

  • 单一职责的任务分层: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 块中,这是打包链路的"单一配置点":

groovy
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

变量解析

变量类型/示例值用途
rootDirFile仓库根目录,用于复制根级 LICENSE / README.md
javaHomejava.home 系统属性定位当前 JDK 的 bin/jlink、bin/jpackage 与 jmods 模块库
osNamewindows / linux / mac驱动 jpackage 的平台分支(图标格式、macOS 专属 JVM 参数)
osPathSep; 或 :平台路径分隔符
jarLibDirbuild/libsFat JAR 产出目录,也是 jpackage --input 的输入目录
jarLibNamedesktop-<version>Gradle 默认 JAR 命名,distJar 重命名前的基础名
jlinkDirbuild/jlinkjlink 输出目录,其中 runtime/ 即精简 JRE
jlinkModuleList9 个 JDK 模块裁剪进精简 JRE 的模块白名单
jlinkLocalesListen-US,zh-CN精简 JRE 仅保留两种语言资源
jpackageDirbuild/jpackagejpackage 应用镜像输出目录
issFileReldocs/scripts/ExePacking.issWindows 安装包 Inno Setup 脚本路径
distDirbuild/dist最终分发物(重命名后的 JAR 与 ZIP)落盘目录
distNameArkPets-v<version>分发文件统一命名前缀

设计意图:把"产物路径 + 命名 + 平台探测"收敛到一处,发布改名或调整模块时只改这一块;osName 用 split(' ')[0] 截断(例如把 Windows 11 规整为 windows),使后续 contains 判断简单可靠。

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

groovy
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

groovy
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:组装自包含应用镜像

groovy
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

groovy
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 的直接内容源,一份产物服务两条发布路线。

核心流程

Loading diagram...

流程要点:

  1. 单命令级联:由于每个任务的 dependsOn 指向上一环,gradlew distZip 即可串起全部步骤(源码编译 → Fat JAR → 精简 JRE → 应用镜像 → ZIP)。
  2. 失败早暴露:jpackage 的 doFirst 防污染检查会在打包前中断构建,把"残留 JAR 导致的安装包污染"这类问题前移到本地构建阶段。
  3. 安装包是链外工序:Setup EXE 由 Inno Setup 对 build/jpackage 镜像编译生成,不在 Gradle 任务图中,因此 CI 上需要单独调用 ISCC(Inno Setup 编译器)处理该脚本。

配置选项

配置/变量类型默认/取值说明
jlinkModuleListStringjava.base,java.desktop,java.logging,java.management,java.scripting,jdk.crypto.ec,jdk.localedata,jdk.management,jdk.unsupported裁剪进精简 JRE 的 JDK 模块白名单
jlinkLocalesListStringen-US,zh-CN精简 JRE 保留的语言环境
issFileRelStringdocs/scripts/ExePacking.issWindows 安装包 Inno Setup 脚本相对路径
appName / appAuthor / versionProject 属性—(在 build.gradle 其他位置定义)决定 jpackage --name/--vendor/--app-version 与产物命名 distName
mainClassNameProject 属性—(在 build.gradle 其他位置定义)Fat JAR 的 Main-Class 与 jpackage 的 --main-class
sentryDsnProject 属性空则跳过非空时向安装版注入 -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/libs JAR 数量 > 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 任务组之外的常规构建)请参见对应的构建系统页面

Sources

(1 files)