第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::)。

首次构建需要

  1. JDK 17–21(JDK 25 超出 Gradle 8.14 支持范围);
  2. Android SDK:platforms;android-36 + build-tools;36.0.0(Capacitor 8 要求 compileSdk 36);
  3. Rust 工具链 + MSVC C++ 生成工具(Tauri);
  4. 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:站点构建一次、双平台复用、版本自动注入、签名可选回退。

下一章换一个视角:把这些工程手段背后的「儿童向设计规范」集中讲一遍。