第15章 - 构建打包与分发
前面十四章的产物最终都收敛到同一个目录:apps/site/dist。这一章讲三件事:这份产物是怎么被切出来的、为什么它能同时活在四种地方(本地文件、博客子目录、桌面壳、安卓壳)、以及发布时那些”错了就很难回头”的账——版本号、签名、图标。
先给结论:一份站点产物,四个分发出口,游戏代码零改动。
| 出口 | 载体 | 实测体积 | 谁在加载 dist |
|---|---|---|---|
| 浏览器 / 博客 | 静态文件(子路径 /bamboobuddy/) |
41 文件 / 1,245,469 B | 浏览器 |
| Windows | Tauri 2 便携 exe | 3.27 MB | 系统 WebView2 |
| Android | Capacitor 8 apk | 4.69 MB | 内置本地服务 https://localhost |
| 本地预览 | pnpm preview(5191) |
同 dist | Chromium |
15.1 一条命令到 41 个文件
开发与预览的端口是固定的:server.port 5190 与 preview.port 5191,都带 strictPort: true。被占用时宁可报错,也不悄悄换一个端口——因为 e2e 的 webServer.url、playwright.preview.config.ts 的 baseURL 都写死了这两个数字,静默换端口只会让测试连到一个空页面然后超时。
pnpm build 只是 pnpm --filter @bamboo/site build,真正干活的配置在 apps/site/vite.config.ts,其中四条决定了产物的形状:
// apps/site/vite.config.ts(节选)
export default defineConfig({
// 部署到任意子路径(家长用 U 盘拷进平板也能打开):资源一律相对路径
base: './',
resolve: {
// 只替换裸 'phaser' 导入;TS 类型仍解析到 phaser 包,typecheck 不受影响
alias: [{ find: /^phaser$/, replacement: phaserTrim }],
},
build: {
target: 'es2022',
chunkSizeWarningLimit: 1000,
rolldownOptions: {
output: {
codeSplitting: {
groups: [{ name: 'vendor-phaser', test: /node_modules[\\/]phaser[\\/]|phaser-kit[\\/]vendor[\\/]phaser-trim/ }],
},
chunkFileNames(chunk) {
const id = chunk.facadeModuleId ?? '';
const game = /[\\/]games[\\/][^\\/]+[\\/]src[\\/]games[\\/]([^\\/]+)\.ts$/.exec(id);
if (game) return 'assets/game-' + game[1] + '-[hash].js';
const detail = /[\\/]content[\\/]src[\\/]details[\\/]([^\\/]+)\.game\.json$/.exec(id);
if (detail) return 'assets/content-' + detail[1] + '-[hash].js';
if (chunk.name.includes('phaser')) return 'assets/vendor-phaser-[hash].js';
return 'assets/[name]-[hash].js';
},
},
},
},
});
四条决策各自的理由:
base: './'—— 所有资源引用变成相对路径。同一份产物可以放在域名根、/bamboobuddy/、file:///…/dist/index.html,不需要为每个部署位置重新构建(15.3 详述);- phaser alias —— 裸
phaser被换成packages/phaser-kit/vendor/phaser-trim.esm.js(裁剪版),但类型仍解析到phaser包,所以vue-tsc不会因为 vendor 文件没类型而崩; vendor-phaser单独成 chunk —— 十个玩法共用同一份 Phaser。不单独切出来的话,每换一个玩法都要重下一份引擎;chunkFileNames三条规则 + 一条兜底 —— 产物按”玩法 / 内容 / 第三方”分前缀。默认全叫index-<hash>.js,出了体积或懒加载问题只能靠猜;现在game-brush-*.js、content-brush-*.js、vendor-phaser-*.js一眼就能读出归属,preview.spec.ts也能直接按前缀断言。
target: 'es2022' 同样是有意的:三个出口(浏览器、WebView2、Android System WebView)都是 Chromium 内核,现代语法不需要降级到 ES5,产物因此更小、可读性也更好。反过来说,如果哪天真的要支持很旧的安卓 WebView,这里就是第一个要动的地方——而不是在每个源文件里加兼容代码。
chunkSizeWarningLimit: 1000 不是”放宽标准”,而是贴着裁剪后的 Phaser 定:它本来就是共享大 chunk,真正该出声的是”又多了一个大 chunk”。体积的真正守卫是 scripts/check-size.mjs(第 14 章),不是这条警告。
15.2 产物结构:41 个文件都是什么
2026-10-19 本机 pnpm build 后实测:
| 类别 | 文件数 | 原始大小 | 说明 |
|---|---|---|---|
| 首屏 | 2 | index-*.js 149,841 B + index-*.css 20,592 B |
index.html 直接引用;gzip 后 52.1 + 4.7 KB |
| 引擎 | 1 | vendor-phaser-*.js 876,874 B |
856.3 KB 原始 / 220.6 KB gzip;首次进玩法才下载 |
| 共享 | 1 | common-*.js 111,340 B |
108.7 KB / 32.4 KB gzip;被玩法与 PlayView 静态引用 |
| 玩法入口 | 10 | 每个 0.3–8.0 KB | game-<id>-*.js,其中 feed 最大(8.0 KB) |
| 中文详情 | 10 | 每个 0.2–0.7 KB | content-<id>-*.js |
| 英文详情 | 10 | 每个 0.2–0.7 KB | <id>.game-*.js |
| 其他 | 7 | 合计约 27 KB | scrub 9.3 KB、PlayView 7.4 KB、en 6.1 KB、ParentView 3.8 KB、summaries 1.6 KB、drag 0.8 KB、index.html 0.9 KB |
| 合计 | 41 | 1,245,469 B(约 1.19 MB) | 与体积预算 2400 KB 相比余量充足 |
有人会问:41 个文件是不是切得太碎?对首屏来说,chunk 越少越好;对玩法来说,每个玩法独立才好。这两件事在同一个 dist 里并存,靠的是”首屏只引两个文件、其余全部按需”。切碎的代价是请求数,收益是”中文用户不下载英文、没进过玩法的孩子不下载 Phaser”。在 HTTP/2 与长期缓存的现实下,这个交换是划算的——尤其当你的用户可能在 3G 网络下打开一个分享链接时。
三个从表里读出来的事实:
- 首屏真的不含引擎:
index.html只引index-*.js与index-*.css。vendor-phaser、common、PlayView、十个game-*一个都没出现在首屏(preview.spec.ts会逐条断言); - 内容 chunk 小得惊人:一条任务的中文详情只有 0.5 KB 左右,十条加起来还不到 Phaser 的百分之一。这说明”内容不是体积问题”;
common是被懒加载的:它由game-*与PlayView静态引用,进第一个玩法时才下载。check-size把它算进”按需加载”是正确的,不是漏算。
content-<id>-*.js 与 <id>.game-*.js 成对出现,不是重复打包:前者是中文详情数据,后者是英文详情补丁。两条动态 import 各自把 JSON 拆成独立数据 chunk,所以中文用户只下载前者,英文用户在进玩法时多下载后者。两者都只有几百字节,Network 里看到两次请求属于正常现象——不是「同一份内容被下载了两遍」。
每次构建的 hash 都不同(这里是 index-R_pSDSrN.js),所以文件名本身就是缓存键:内容不变则 hash 不变、命中长期缓存;内容一变 hash 就变、浏览器必然取新文件。这就是为什么可以给 /assets/* 设一年缓存,而 index.html 必须短缓存。
自己核对产物时,别只看目录列表,要看两个数:原始字节与 gzip 后字节。前者是桌面 / 安卓壳的真实成本(本地文件加载不走 gzip),后者才是浏览器首屏的下载量。两者差三到四倍是正常的——vendor-phaser 856.3 KB 原始压到 220.6 KB,正是它能留在预算内的原因。scripts/check-size.mjs 的七项预算里两条口径并存,就是为了不让人用其中一个数骗自己。
15.3 base: './' 与子路径部署
这是本项目最实用的一条工程决策。大多数 SPA 部署失败都栽在这里:产物里写死了 /assets/index-abc.js,一旦放到某个子目录(例如博客的 bamboobuddy/),浏览器会去域名根找 /assets/,于是白屏。
本项目要求资源相对路径,于是同一份 dist 能同时满足:
- 本地双击
dist/index.html(file://); - 博客子路径
https://znlgis.github.io/bamboobuddy/; - 桌面壳里的自定义协议;
- 安卓壳里的
https://localhost。
部署到博客的具体步骤
pnpm build,产物在apps/site/dist;- 把
dist里全部内容复制到站点仓库根目录下的bamboobuddy/(不是bamboobuddy/dist/,否则路径多一层); - 刷新
https://znlgis.github.io/bamboobuddy/,确认:- 小屋能出现、竹竹能画出来;
- 断网前先看 DevTools 的 Network:
/bamboobuddy/assets/index-*.js与 CSS 都是 200,没有跑到域名根; - 点任意任务能进玩法(此时才会请求
vendor-phaser与对应的game-*)。
不需要服务端 rewrite。路由是 hash 路由(/#/tasks),服务器只看到 /bamboobuddy/,刷新任意页面都落在同一个 index.html。这是第 5 章选择 hash 而不是 history 路由的直接回报:静态托管不需要任何配置,也不用担心 404 回退规则。
两个容易忽略的点:
- 路径大小写:Linux 上的 Pages 区分大小写,
BambooBuddy/与bamboobuddy/是两个目录; - 缓存:
index.html更新后浏览器可能仍用旧的,验证部署时先硬刷新(Ctrl+F5)或用无痕窗口。
线上试玩入口:https://znlgis.github.io/bamboobuddy/。它和本地 pnpm dev 的唯一差别是”没有 dev server 的模块解析”——这也正是 e2e:dist 存在的意义。
部署失败的三种典型症状与对应原因,看一眼就能定位:
| 症状 | 原因 | 修法 |
|---|---|---|
白屏,Network 里 /assets/index-*.js 404 |
产物用了绝对路径,或放到了多一层目录 | 确认 base: './',确认文件在 bamboobuddy/ 根下 |
| 小屋能开,点任务后一直转圈 | vendor-phaser 或 game-* 404(子目录被漏拷) |
整个 dist 递归复制,别只拷 index.html 与 assets/index-* |
| 刷新后 404 | 用了 history 路由或服务端拒绝了子路径 | 本项目是 hash 路由,出现这条说明访问的可能是旧产物 |
15.4 桌面壳:Tauri 2
Windows 产物是便携 exe:双击即玩,不安装、不写注册表。
// apps/desktop/src-tauri/tauri.conf.json(节选)
{
"productName": "Bamboo Buddy",
"identifier": "com.bamboo.buddy",
"build": { "frontendDist": "../../site/dist" },
"app": {
"windows": [{
"title": "熊猫宝宝的一天 · Bamboo Buddy",
"width": 500, "height": 920,
"minWidth": 400, "minHeight": 700,
"resizable": true, "center": true,
"backgroundColor": "#faf3e7"
}]
},
"bundle": {
"active": true,
"targets": ["nsis"],
"icon": ["icons/32x32.png", "icons/128x128.png", "icons/128x128@2x.png", "icons/icon.ico"]
}
}
几个刻意的选择:
- 窗口 500×920,是手机竖屏比例。游戏按 750×1334 设计、
Scale.FIT适配,桌面窗口只是”一台大一点的竖屏设备”; frontendDist指向../../site/dist:桌面壳不重新打包网页,只加载同一份产物。所以pnpm dist:win会先构建站点再构建壳;bundle.targets: ["nsis"],但pnpm dist:win实际用tauri build --no-bundle—— 只产出一个裸 exe(bamboo-buddy.exe),不做安装包。这就是”便携”的含义;- Rust 侧只有 8 行:
// apps/desktop/src-tauri/src/main.rs
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
tauri::Builder::default()
.run(tauri::generate_context!())
.expect("error while running Bamboo Buddy");
}
windows_subsystem = "windows" 让 release 构建不弹黑色控制台窗口。CI 实测产物 3.27 MB,远小于 Electron 同功能动辄 150 MB——因为它用系统自带的 WebView2,不打包浏览器内核。
本地第一次构建的前置条件比 Web 多三样:Rust 工具链(dtolnay/rust-toolchain@stable)、MSVC C++ 生成工具(链接器)、以及 WebView2 运行时(Win10/11 通常已内置)。缺任何一样,报错都发生在”编译 Rust”这一步,而不是在前端构建——所以 pack:check 也有必要先把配置层的错误挡在更前面。
为什么不用 Electron,还有一条更硬的理由:index.html 里是 <script type="module" crossorigin>,在 file:// 下会被 Chromium 的 CORS 拒绝,必须额外注册自定义协议。Tauri 默认就走自定义协议,这个坑不存在。
分发注意:未签名的 exe 首次运行会触发 SmartScreen,需要”更多信息 → 仍要运行”。这不是 bug,是 Windows 对未知发布者的默认策略。代码签名证书可以消除这条提示,但它要按年付费、要走身份审核——对一个免费、无账号、无内购的儿童小游戏来说,这笔成本换来的只是”少点一次按钮”,所以当前选择不签,并在 Release 说明里直接写清楚怎么点。
顺带说明为什么不打安装包:便携 exe 不写注册表、不建开始菜单项,家长把文件拷到 U 盘就能在另一台电脑上玩;代价是没有自动更新,这在一个”内容随版本一起发”的离线游戏里不构成问题。
15.5 安卓壳:Capacitor 8
// apps/mobile/capacitor.config.ts(节选)
const config: CapacitorConfig = {
appId: 'com.bamboo.buddy',
appName: 'Bamboo Buddy',
webDir: '../site/dist',
android: {
backgroundColor: '#faf3e7',
allowMixedContent: false,
captureInput: true,
webContentsDebuggingEnabled: false,
},
};
不配置 server.url 是关键:走 Capacitor 内置的本地服务 https://localhost,于是 ES module、IndexedDB、WebGL 全部按浏览器标准工作。如果改成加载远程地址,跨域、混合内容、离线可用性会同时变成问题。
竖屏与全屏也在壳里定死:AndroidManifest.xml 的 android:screenOrientation="portrait" 锁竖屏,styles.xml 里做沉浸全屏并为刘海屏设置 windowLayoutInDisplayCutoutMode=shortEdges,CSS 侧则用 env(safe-area-inset-*) 让开刘海与手势条。三处要一起改,只改一处会出现”顶部被状态栏盖住”或”底部按钮点到手势条”。
安卓侧有三处与 Web 不同的现实:
| 差异 | 处理 | 落点 |
|---|---|---|
| WebView 没有 Web Speech API | 旁白优先走原生 TTS 插件,失败再回落浏览器语音,最后静默降级 | packages/platform/src/runtime.ts、web.ts |
| 实体返回键会直接关应用 | 先回小屋,已经在小屋才退出 | apps/site/src/shell/backButton.ts(有单测) |
| 系统会云备份本地数据 | android:allowBackup="false",存档不参与备份 |
AndroidManifest.xml |
版本号与签名由 Gradle 读取环境变量,必须先在 android 块外算好:
// apps/mobile/android/app/build.gradle(节选)
def bambooVersionCode = (System.getenv("BAMBOO_VERSION_CODE") ?: "1").toInteger()
def bambooVersionName = (System.getenv("BAMBOO_VERSION_NAME") ?: "1.0")
// …
signingConfigs {
if (hasReleaseKeystore) {
release {
storeFile rootProject.file(keystoreProps['storeFile'])
storePassword keystoreProps['storePassword']
keyAlias keystoreProps['keyAlias']
keyPassword keystoreProps['keyPassword']
}
}
}
buildTypes {
release {
signingConfig hasReleaseKeystore ? signingConfigs.release : signingConfigs.debug
}
}
「没有 keystore 就退回 debug 签名」是刻意的:产物依然能装到自己手机上,方便体验;但它不能用于分发——换一台机器构建,debug 密钥可能不同,手机上会出现”签名不一致”,只能卸载旧版(存档一起没)。
首次构建安卓还需要 SDK 组件:platforms;android-36 与 build-tools;36.0.0(Capacitor 8 要求 compileSdk 36)。CI 在 ubuntu runner 上自己定位 ANDROID_HOME 再装这两个包,本地则要靠 Android Studio 或 sdkmanager。少了它们,报错发生在 Gradle 配置阶段,看起来像”工程坏了”,其实只是缺一个平台包。
本地构建还有一层坑:Gradle 8.14 只支持到 JDK 24,系统装 JDK 25 会报 Unsupported class file major version 69。scripts/build-apk.mjs 因此会自己找一个 17–24 的 JDK,并设 JAVA_HOME:
// scripts/build-apk.mjs(节选)
if (major >= 17 && major <= 24) return { dir, major };
// …
const result = spawnSync(gradlew, ['assembleRelease', '--no-daemon'], {
cwd: androidDir, stdio: 'inherit',
env: { ...process.env, JAVA_HOME: jdk.dir },
});
CI 实测 apk 4.69 MB(debug 签名),apksigner 校验通过。
15.6 GitHub Actions:tag 即发布
.github/workflows/release.yml 的 job 图:
version 解析 tag → version / versionCode
├─ web install → typecheck / lint / test / content:check / pack:check → build → size
│ └─ 上传 web-dist(两个平台共用同一份)
├─ e2e pnpm e2e + pnpm e2e:dist(与平台构建并行)
├─ windows needs: [version, web] → 下载 web-dist → tauri build --no-bundle → 体积门禁 → exe
├─ android needs: [version, web] → 下载 web-dist → cap sync → gradlew assembleRelease
│ → apksigner 校验 → 体积门禁 → apk
└─ release needs: [version, web, windows, android, e2e] → 挂到 GitHub Release
web job 上传的 artifact 保留 1 天就够:它只是给同一工作流的两个平台任务取用,不需要长期存档;真正要长期保存的是 Release 上的 exe 与 apk。
四条设计意图:
- 站点产物只构建一次(
webjob 上传 artifact,两个平台下载),保证 exe 与 apk 里的网页代码完全一致; release的needs含e2e—— 玩法门禁没过就不发版;但windows/android只依赖web,所以 e2e 慢不会堵住构建;- 版本号由 tag 注入:
v1.2.3→versionName 1.2.3、versionCode 10203(MAJOR*10000 + MINOR*100 + PATCH),Tauri 的tauri.conf.json也会被改成同一版本。安卓要求versionCode >= 1,所以手动触发(版本0.0.0-dev)会被兜到 1; - 产物体积硬门禁:
ARTIFACT_MAX_BYTES: 10485760(10 MB),超了直接 fail。几 MB 是 Tauri / Capacitor 的正常量级,超过 10 MB 多半是误打了调试符号或整个资源目录。
签名缺失时的行为也写在工作流里:
- 没有
ANDROID_KEYSTORE_* Secrets→ 用 debug 密钥签,Release 说明里明确写「本次 APK 用 debug 密钥签名」并提示卸载后果; - 手动运行并勾选
require_signed_apk→ 没有 Secrets 就直接失败。这是给”正式发版”用的开关:宁可不发,也不发一个不能覆盖安装的包。
带 - 的 tag(如 v1.0.0-beta.1)会被自动标为预发布(gh release edit --prerelease)。
手动触发(Actions → Release → Run workflow)是日常最常用的验证方式:它只出产物、不发 Release(release job 的 if: github.ref_type == 'tag' 不成立),版本号落在 0.0.0-dev / versionCode 1。想验证”整条链路能不能产出可安装的包”,用这条路径比推一个真 tag 更安全——tag 一旦推上去就会对外发布。
发布说明是工作流里手写的(body: 那段 Markdown),包含下载表格、SmartScreen 提示与签名状态,并根据 debug / 正式签名给出不同文案。它不是装饰:用户看到警告时的那一句话,决定了他是继续还是放弃。
还有一条与「谁能看到什么」有关:permissions: contents: read 是工作流级默认,只有 release job 单独升到 contents: write。构建 job 不需要写仓库,就不该有写权限——最小权限不是洁癖,是让「构建脚本被投毒」这类事故的爆炸半径小一点。
15.7 图标与签名:两件”错了很难回头”的事
图标全部程序化生成:scripts/make-icons.py 用 Pillow 画源图,再交给各平台工具产出多尺寸资源。
先说要提前知道的:换图标后旧图标会赖着不走。Windows 有图标缓存(资源管理器里 exe 还是旧图),安卓启动器也会缓存 ic_launcher。验证新图标时,Windows 换个目录或重启资源管理器,安卓卸载重装或换一台设备——不要因为”看起来没变”就反复重新构建。
pnpm icons
# = python scripts/make-icons.py
# && tauri icon src-tauri/icon-source.png
# && capacitor-assets generate --android
仓库里已提交生成好的图标与安卓工程,所以首次打包不需要先跑一次 pnpm icons——只有换图标时才需要。这也是 pack:check 会检查 bundle.icon 列出的四个文件是否真的存在的原因:图标缺一张,构建要到很后面才报错。
签名是唯一”丢了就回不去”的东西:
pnpm keystore
# 生成 apps/mobile/android/bamboo-release.keystore + keystore.properties
# 并打印四个要贴进 GitHub Secrets 的值
| Secret | 内容 |
|---|---|
ANDROID_KEYSTORE_BASE64 |
keystore 文件的 base64 |
ANDROID_KEYSTORE_PASSWORD |
storePassword |
ANDROID_KEY_ALIAS |
keyAlias(默认 bamboo) |
ANDROID_KEY_PASSWORD |
keyPassword |
make-keystore.mjs 里有两个细节值得学:密码用 randomBytes(18).toString('base64url')(只有 [A-Za-z0-9_-],塞进 shell / YAML / Secrets 不会被转义问题咬到);有效期 10000 天(约 27 年),因为”给小朋友玩的小游戏,不该让密钥在用户手里过期”。
keystore.properties 与 *.keystore 都不入库(.gitignore 已忽略)。丢了会怎样?手机上已装的版本再也无法覆盖安装,只能让用户卸载重装——卸载会清掉 IndexedDB 里的存档。所以这条要写进发布清单,而不是只写在文档里。
实际操作顺序建议固定成:pnpm icons(只在换图标时)→ pnpm keystore(只在首次或换密钥时)→ pnpm dist:win / pnpm dist:apk。反过来做(先打包再补签名)会得到一个「重新打了一遍、但什么都没变」的包,看起来修好了,其实只是浪费时间。
15.8 版本号、存档与”能覆盖安装”
发布时最容易忽略的一条:版本号不只是给商店看的,它决定用户能不能无损升级。
安卓侧有两个独立字段:
| 字段 | 来源 | 规则 |
|---|---|---|
versionName |
tag 去掉 v(1.2.3) |
展示用,随便改 |
versionCode |
MAJOR*10000 + MINOR*100 + PATCH(10203) |
必须严格递增,否则系统拒绝安装”降级包” |
所以 v1.2.3 → versionCode 10203 不是随手写的公式,而是”每一段给一百个位置”的取舍:三年内 minor 不超过 99、patch 不超过 99。手动触发(0.0.0-dev)会算出 0,被兜到 1——因为安卓要求 versionCode >= 1。
Web 侧的”版本”则是存档的 schemaVersion。这两件事经常被混为一谈:
- 应用版本可以随便升,存档必须能读;
packages/progress/src/save.ts的migrate()只做两件事:比当前版本新的存档整体清空(未来版本不可信),其余逐字段兜底;MIGRATIONS现在是一张空表(只有 v1),但流程先立起来了:以后加字段时把SAVE_SCHEMA_VERSION改成 2 并补一条迁移,老玩家的星星不会因为升级一次就清零。
一句话总结这条纪律:“版本不等就清空”是最省事也最伤人的做法,而这个项目从一开始就没走那条路。
15.9 发布检查清单
清单的顺序不是随意的:前三条在本地几十秒就能跑完,第 4–7 条决定 CI 会不会白跑一小时,第 8 条只能在真机上做。把最便宜的放在最前面,是这套门禁从第 14 章延续下来的一贯做法。
发版前按顺序过一遍,每一条都对应一次真实的失败:
| # | 检查 | 命令 / 位置 | 不通过的症状 |
|---|---|---|---|
| 1 | 全量门禁绿 | pnpm verify |
CI 红;release job 直接不发 |
| 2 | 端到端绿 | pnpm e2e + pnpm e2e:dist |
某个玩法点不通、chunk 没切出来 |
| 3 | 打包配置完整 | pnpm pack:check |
图标缺一张、appId 写错、workflow 少一步 |
| 4 | 版本号来源正确 | 打 tag 前确认 vX.Y.Z |
versionCode 不递增,安卓拒绝覆盖安装 |
| 5 | 签名材料在 | 本地有 keystore.properties 或 Secrets 齐全 |
出 debug 包,用户换版本要卸载 |
| 6 | 站点产物就位 | apps/site/dist/index.html 存在 |
两个平台构建出的壳里是白屏 |
| 7 | 体积在门禁内 | pnpm size;Release 的 10 MB 门禁 |
CI 在最后一步失败,白跑一小时 |
| 8 | 真机抽查 | 双击 exe;装 apk 后开一局、切英文、杀进程再开 | 存档没落盘、TTS 没声音、返回键直接退出 |
第 8 条不能省。自动化能证明”十个玩法在 Chromium 里通得了关”,证明不了”这台旧安卓机的 WebView 里 IndexedDB 会不会被清”。
反过来说,如果第 1–7 条都绿而第 8 条红,问题几乎一定在”壳”而不在”网页”:桌面看 WebView2 版本,安卓看系统 WebView 版本与 TTS 语音包。这类问题的排查路径与 Web 完全不同,第 16 章会逐条列出来。
15.10 小结与下一章
到这里,一条完整的交付链已经闭合:内容 JSON → 模板场景 → Vite 切 chunk → 41 个文件 → 四个出口。真正的复杂度不在构建配置,而在那些只能在真机上暴露的问题:无痕模式里 IndexedDB 打不开、Android WebView 没有 Web Speech、JDK 25 把 Gradle 顶掉、签名换了导致存档被卸载清空。
下一章把这些踩过的坑集中列出来,并给一份高频问答——如果你只想知道”为什么我的 exe 双击没反应”“为什么英文没生效”“为什么保存不了进度”,可以直接跳到那一章。