第14章 - 构建、打包与分发
14.1 Vite 构建:一份站点产物
// apps/site/vite.config.ts
export default defineConfig({
base: './',
plugins: [vue(), tailwindcss()],
server: { host: '127.0.0.1', port: 5183, strictPort: true },
build: { target: 'es2022', chunkSizeWarningLimit: 2000 },
});
三个配置各有明确目的:
base: './'(相对路径):产物不仅部署在域名根目录,还要被 Tauri 与 Capacitor 以本地协议加载 —— 绝对路径/assets/...在本地壳里会 404;target: 'es2022':现代语法直出,移动端浏览器与两个壳(WebView2 / Android System WebView)都支持;chunkSizeWarningLimit: 2000:Phaser 共享 chunk 有 300 KB+,默认的 500 KB 警告无意义 —— 体积把关交给check-size.mjs(有预算、有分类),不靠构建警告。
产物形态(apps/site/dist):
dist/
├─ index.html # 只引用外壳的 JS/CSS
└─ assets/
├─ index-xxxx.js # 外壳:大厅 + 图鉴 + 路由 + Pinia
├─ index-xxxx.css # Tailwind 产物
├─ phaser-xxxx.js # 引擎共享 chunk(首次进游戏才加载)
├─ caochong-elephant-xxxx.js # 各游戏自己的业务 chunk
├─ pangu-xxxx.js
└─ ...
每个游戏是独立 chunk 是自动的:注册表里是 () => import('@chags/game-xxx') 动态导入,Vite 按导入图切分。这也是「外壳首屏不含 Phaser」在构建层面的保证 —— 想打破它,必须在主包里静态 import 游戏,而契约与注册表的设计让这件事没人会做。
14.2 体积现状与预算
写作本文时的实测(以仓库当前版本为准,跑 pnpm size 可得最新数字):
| 指标 | 数值 | 预算 |
|---|---|---|
| 外壳首屏 JS + CSS | 约 83 KB gzip | ≤ 150 KB |
| Phaser 共享 chunk | 约 363 KB gzip | ≤ 450 KB(单 chunk) |
| 单个游戏业务 chunk | 1.2–6.1 KB gzip | — |
| assets 原始总量 | 远小于预算 | ≤ 3500 KB |
三条来自真实决策的数字:Phaser 引擎实测 gzip 357,778 字节(约 350 KB),所以「引擎绝不打进主包」;外壳首屏 83 KB 说明 Vue + Pinia + Tailwind 的外壳足够轻;单游戏几 KB 说明模板复用的成本控制成立。
14.3 Windows:Tauri 便携 exe
为什么是 Tauri
| Tauri 2 | Electron | |
|---|---|---|
| 产物 | 约 3.3 MB | 约 150 MB |
| 渲染 | 系统自带 WebView2 | 内置 Chromium |
| 本地加载 | 默认自定义协议 | file:// 需要自行处理 |
还有一个具体到本项目的差异:站点 index.html 用 <script type="module" crossorigin>,在 Electron 的 file:// 下会被 Chromium 的 CORS 拒绝,必须额外注册自定义协议;Tauri 默认就走自定义协议,这个坑不存在。
配置与构建
// apps/desktop/src-tauri/tauri.conf.json(节选)
{
"productName": "CHAGS",
"identifier": "com.chags.tales",
"build": { "frontendDist": "../../site/dist" }, // 直接吃站点产物
"app": {
"windows": [{
"title": "中国历史典故 · Chinese Tales",
"width": 500, "height": 920, // 竖屏窗口,贴近手机比例
"minWidth": 400, "minHeight": 700,
"resizable": true, "center": true,
"backgroundColor": "#3a2f26"
}]
}
}
pnpm dist:win # 站点构建 + tauri build --no-bundle
# → apps/desktop/src-tauri/target/release/chags.exe(约 3.3 MB,双击即玩)
--no-bundle 表示只出可执行文件,不打安装包 —— 目标是「便携 exe,不写注册表、不用安装」。运行依赖系统 WebView2 运行时(Win10/11 通常已内置)。未签名的 exe 首次运行会有 SmartScreen 提示(「更多信息 → 仍要运行」),要消掉它需要代码签名证书(约 ¥1000–3000/年),属于分发阶段的可选项。
14.4 安卓:Capacitor apk
配置
// apps/mobile/capacitor.config.ts
const config: CapacitorConfig = {
appId: 'com.chags.tales',
appName: '典故游戏',
webDir: '../site/dist',
android: {
backgroundColor: '#3a2f26',
allowMixedContent: false,
captureInput: true,
webContentsDebuggingEnabled: false,
},
};
关键点(文件头注释原文):
不配置
server.url—— 走 Capacitor 内置的本地服务(https://localhost),这样 ES module、IndexedDB、WebGL 全部按浏览器标准工作。
如果改成远程 URL 就变成「套壳网页」,离线、IndexedDB 的稳定性都会受影响;本地服务让安卓壳里的运行环境和浏览器保持一致。
原生能力对接
- TTS:
@capacitor-community/text-to-speech—— Android WebView 没有 Web Speech API,必须走系统语音(第 9 章的runtime.ts通过桥接对象取用); - 返回键:
@capacitor/app—— 游戏/图鉴里按返回回典故馆,已在典故馆才退出(第 5 章); - 竖屏与全屏:
AndroidManifest.xml里screenOrientation="portrait";styles.xml全屏 +windowLayoutInDisplayCutoutMode=shortEdges(刘海屏);CSS 里env(safe-area-inset-*)让开手势条。
构建与 JDK 选择
pnpm dist:apk # 站点构建 + cap sync + gradlew assembleRelease
scripts/build-apk.mjs 自动挑一个 17–24 的 JDK(原因见第 2 章:Gradle 8.14 不支持 JDK 25):
// 依序检查:CHAGS_JDK 环境变量 → C:\Java\jdk21 → 常见安装目录(Eclipse Adoptium / Program Files\Java)…
const probe = spawnSync(java, ['-version'], { encoding: 'utf8' });
const major = Number(/version "(\d+)/.exec(text)?.[1] ?? '0');
if (major >= 17 && major <= 24) return { dir, major };
找不到时给出可执行的报错(装 Temurin 21 或用 CHAGS_JDK 指定),而不是让每个人自己去搜「Unsupported class file major version 69」是什么。
签名规则
| 文件 | 是否入库 | 说明 |
|---|---|---|
android/keystore.properties |
❌ | 本地签名配置 |
*.keystore |
❌ | 务必自己备份;丢了只能让用户先卸载再装新版 |
android/local.properties |
❌ | 本机 SDK 路径 |
没有 keystore 时 Gradle 会自动退回 debug 签名 —— 产物依然可以安装,方便任何人自己构建(CI 上同样如此,只给 ::warning::)。
首次构建需要
- JDK 17–21(JDK 25 超出 Gradle 8.14 支持范围);
- Android SDK:
platforms;android-36+build-tools;36.0.0(Capacitor 8 要求 compileSdk 36); - Rust 工具链 + MSVC C++ 生成工具(Tauri);
- WebView2 运行时(Win10/11 通常已内置)。
14.5 图标与启动图
scripts/make-icons.py 用 Pillow 几何绘制生成两套壳的图标(与游戏内「几何绘制」的美术策略一致,零设计外包):
pnpm icons
安卓侧的启动图与自适应图标由 @capacitor/assets 生成(apps/mobile/resources/ 里的源文件 → android/ 下的各尺寸资源)。
14.6 GitHub Actions 自动发布
推一个 tag 就自动构建两个平台并挂到 GitHub Release:
git tag v0.2.0
git push origin v0.2.0
(另有 workflow_dispatch 手动入口:只出产物、不发 Release,用于验证流程。)
流水线结构(.github/workflows/release.yml):
version 解析 tag → version 与 versionCode
web 质量门禁(typecheck/lint/test/content:check)+ 构建站点 + 上传 artifact
windows Tauri 构建便携 exe(windows-latest)
android Capacitor 构建 apk(ubuntu-latest,JDK 21 + android-36)
release 两个产物挂到 GitHub Release
几个值得学习的工程细节:
① 站点产物只构建一次:windows 与 android 两个 job 都 download-artifact 同一份 web-dist,保证 exe 与 apk 里的网页代码完全一致,也省掉一次构建。
② 版本号自动注入:
# tag v1.2.3 → versionName 1.2.3 / versionCode 10203
CODE=$(( MAJOR * 10000 + MINOR * 100 + PATCH ))
# 安卓要求 versionCode >= 1;手动触发(0.0.0-dev)与 v0.0.0 都会算出 0,直接构建会报错
if [ "$CODE" -lt 1 ]; then CODE=1; fi
Windows job 会用 node 脚本把版本写进 tauri.conf.json;安卓 job 通过 CHAGS_VERSION_NAME / CHAGS_VERSION_CODE 环境变量传入。带 - 的 tag(如 v1.0.0-beta.1)自动标为预发布。
③ 不依赖第三方 setup-android:workflow 里有段注释解释得很清楚 —— android-actions/setup-android@v3 会去装早已从 SDK 仓库移除的 tools 包,必然报 Failed to find package 'tools' 然后整个 job 挂掉;ubuntu runner 本来就预装了 SDK,自己定位 ANDROID_HOME 即可。
④ 签名 Secrets(可选):
| Secret | 内容 |
|---|---|
ANDROID_KEYSTORE_BASE64 |
keystore 文件的 base64 |
ANDROID_KEYSTORE_PASSWORD |
storePassword |
ANDROID_KEY_ALIAS |
keyAlias |
ANDROID_KEY_PASSWORD |
keyPassword |
不配也能跑(自动退回 debug 签名并给 warning)。生成 base64:
[Convert]::ToBase64String([IO.File]::ReadAllBytes("apps/mobile/android/chags-release.keystore"))
⑤ 细节兜底:chmod +x gradlew(gradlew 在 Windows 上提交,git 没保留可执行位);apksigner verify --print-certs 校验签名;构建前校验站点产物就位。
14.7 分发策略
| 平台 | 方式 | 注意事项 |
|---|---|---|
| Windows | 直接发 exe,双击即用 | 未签名会有 SmartScreen 提示(「更多信息 → 仍要运行」) |
| 安卓 | 直接发 apk,需要允许「安装未知来源的应用」 | 上 Google Play 要 AAB + 隐私政策,儿童类应用还受 Families 政策约束,成本高得多 |
| Web | 静态托管(CDN / 对象存储) | 面向国内需已备案域名;要上微信生态,备案是硬门槛,建议尽早办 |
项目的建议是明确的:先直接分发文件,跑通体验再考虑上架。
14.8 打包没有改变的东西
打包不是「另做一个版本」,验收清单里明确列了不变项:
- 游戏代码一行没改:两个壳都只加载同一份
apps/site/dist; - 存档仍然是 IndexedDB,在桌面与安卓上都能持久保存;
- 中英切换、
?lang=、?autoplay=1在两端照常工作; pnpm verify与端到端测试完全不受影响(它们针对的是 Web 产物)。
这也解释了为什么「平台适配」在 CHAGS 里是一个薄层(第 9 章的 runtime.ts 一百行不到):壳的差异被压缩到运行环境探测与两个原生插件入口,其余全部复用。
14.9 小结
- 构建产物是一份纯静态站点,游戏自动分 chunk,Phaser 是共享 chunk 且不进首屏;
- 桌面壳选 Tauri(3.3 MB、系统 WebView2、自定义协议避开 file:// CORS);安卓壳选 Capacitor(本地服务保证 ESM / IndexedDB / WebGL 的标准行为),原生能力只接 TTS 与返回键;
- 两个打包入口(
pnpm dist:win/dist:apk)都把「环境差异」写进脚本(JDK 选择、签名回退),而不是写进文档让人记; - 发布走 tag 驱动的 GitHub Actions:站点构建一次、双平台复用、版本自动注入、签名可选回退。
下一章换一个视角:把这些工程手段背后的「儿童向设计规范」集中讲一遍。