webgame-apk-release
> Web 游戏 → Android 签名 APK → 可上架交付物。一套流程跑完,出包即验、验完即交。
TL;DR(5 步)
体检 → tsc → 静态导出 → cap sync → gradle assembleRelease → 双校验 → 交付
出包前必答 3 件事:游戏名 / 版本号 / 是否覆盖同名旧包。
Phase 0 · 体检(不做完别往下走)
逐项确认,缺哪项先补哪项:
| 检查项 | 期望 | 缺失时的处理 |
|---|---|---|
capacitor.config.ts |
存在且 appId/appName/webDir 正确 | 新建,webDir 指向静态导出目录(Next 为 out) |
| Android 工程 | android/ 已由 npx cap add android 生成 |
见失败模式 F1 |
| 签名密钥 | keystore/*.jks + android/keystore.properties(已 gitignore) |
keytool -genkeypair 新建,见 Phase 2 |
android/app/build.gradle |
已挂 signingConfigs.release |
见 Phase 2 |
android/local.properties |
sdk.dir=<SDK 路径>(正斜杠) |
手写一行 |
| 静态导出配置 | Next:output: "export" + images.unoptimized + trailingSlash |
改配置后需重新 build |
| ↳ 模板陷阱 | 很多模板默认 output: "standalone" 且 build 脚本带 cp -r .next/standalone 命令——两处要同步改;存在 src/app/api 路由(尤其 force-dynamic)与 export 冲突会构建失败,需删除或改为 force-static 占位(删除被锁时用改名/清空占位绕过) |
|
| 应用内版本号 | src/lib/version.ts 的 APP_VERSION(或等价常量)= 本次 versionName |
更新常量;顶栏徽章/设置面板引用它,硬编码散落必过期 |
🛑 STOP · 版本确认:向用户确认 versionName(如 1.2)与 versionCode(必须比上一版大),以及是否删除被取代的旧 APK。
> 版本口径:对外 versionName 以用户口径为准——内部迭代号 ≠ 商店版本号(内部改了 5 版,首发对外仍常为 1.0);同步更新应用内版本常量(如 src/lib/version.ts),游戏内显示与商店必须一致。
🔴 CHECKPOINT:展示体检结果 + 版本方案,拿到确认再进入 Phase 1。
Phase 1 · 构建
# 1) 类型检查(Next 项目若忽略构建期错误,这一步是唯一防线)
npx tsc --noEmit
# 2) 静态导出(Next → out/;Vite → dist/)
npm run build
# 3) 同步到 Android 工程
export JAVA_HOME="<Temurin JDK 21 路径>"
npx cap sync android
> 体积检查:du -sh out/。若发现素材源目录(原图/拼版预览/PSD 之类)混进了 public/,加一个构建后清理脚本挂到 build(例:"build": "next build && node scripts/trim-out.mjs"),它们只服务于生成管线、运行时用不到。
Phase 2 · 签名(首次打包该游戏时)
# 生成独立密钥(每个游戏一把,切勿复用)
keytool -genkeypair -v -keystore android/keystore/<game>-keystore.jks \
-alias <game> -keyalg RSA -keysize 2048 -validity 10950 \
-storepass '<口令>' -keypass '<口令>' \
-dname "CN=<game>, OU=Game, O=<studio>, L=Beijing, ST=Beijing, C=CN"
# android/keystore.properties(加入 .gitignore,密钥与口令都不过库)
storeFile=../keystore/<game>-keystore.jks
storePassword=<口令>
keyAlias=<game>
keyPassword=<口令>
android/app/build.gradle 挂签名:
def keystorePropsFile = rootProject.file('keystore.properties')
def keystoreProps = new Properties()
if (keystorePropsFile.exists()) keystoreProps.load(new FileInputStream(keystorePropsFile))
android {
signingConfigs {
release {
if (keystorePropsFile.exists()) {
storeFile rootProject.file(keystoreProps['storeFile'])
storePassword keystoreProps['storePassword']
keyAlias keystoreProps['keyAlias']
keyPassword keystoreProps['keyPassword']
}
}
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
if (keystorePropsFile.exists()) signingConfig signingConfigs.release
}
}
}
⚠️ 交付时必须提醒用户备份 .jks 与口令:密钥丢失 = 该游戏永远无法再出覆盖更新包。
Phase 3 · 打包
cd android
export JAVA_HOME="<Temurin JDK 21 路径>" # 不要用 Android Studio 自带 JBR
export GRADLE_USER_HOME="<Windows 风格路径>" # 例:C:/Users/<你>/.gradle
# Windows
./gradlew.bat assembleRelease --console=plain -q
# macOS / Linux
./gradlew assembleRelease --console=plain -q
产物:android/app/build/outputs/apk/release/app-release.apk
Phase 4 · 双校验(任一不通过 → 回退,禁止交付)
BT=$(ls -d <SDK>/build-tools/* | sort -V | tail -1) # 取最高版本 build-tools
# ① 身份校验:包名 / versionCode / versionName
"$BT/aapt2" dump badging app-release.apk | head -1
# ② 签名校验:必须出现 V2 Signer,证书 DN 与自家 keystore 一致
"$BT/apksigner" verify --print-certs app-release.apk | head -3
| 校验项 | 通过标准 |
|---|---|
| 包名 | 与 capacitor.config.ts 的 appId 完全一致 |
| versionCode / Name | 与 Phase 0 确认值一致,且 code 大于上一版 |
| 签名 | 有 V2 Signer;证书 CN 为该游戏;与历史版本同一把钥匙 |
| 启动入口 | aapt2 dump badging 的 launchable-activity 必须指向真实存在的类(cap add 未读 config 时会指向 com.getcapacitor.myapp.MainActivity,而模板残留类可能已被清理 → 安装即闪退;修复:build.gradle 的 namespace 与 applicationId 一并改为正式包名) |
| 体积 | 与上一版对比无异常膨胀(±30% 以上要说明原因) |
Phase 5 · 交付
- 复制 APK 到工作区根目录,命名
《游戏名-vX.Y-release.apk》 - 删除被取代的旧包(删除前须用户确认)
- 更新日志写进项目
docs/(并在回复里给用户一份可直接粘贴的纯文本版) - 汇报:体积、包名、版本、签名指纹、APK 的 SHA-256
失败模式表(三段式 · 现象 → 原因 → 修复)
| # | 现象 | 原因 | 修复 |
|---|---|---|---|
| F1 | applicationId = com.getcapacitor.app、应用名 "My App" |
没有 capacitor.config.ts 就执行了 cap add android;cap sync 不会修正包名与名称 |
删除 android/ → 补写 capacitor.config.ts → 重新 cap add android → cap sync。若已改 applicationId,还须:改 strings.xml 的 app_name、删除模板残留旧包路径 app/src/main/java/com/getcapacitor/(内含 MainActivity,与新包路径并存会报『类重复』编译错) |
| F2 | Gradle 报 Java/类文件版本不支持 | JAVA_HOME 指向 Android Studio 自带 JBR(Java 25),Gradle 不支持 | 改用 Temurin JDK 21,并 export 到当前 shell |
| F3 | SdkLocator 报 IOException / 找不到 SDK |
local.properties 用了 C\: 转义或反斜杠 |
写成正斜杠:sdk.dir=C:/Users/<你>/AppData/Local/Android/Sdk |
| F4 | 打出来是未签名或 debug 签名 | 缺 keystore.properties,或 release 未挂 signingConfig |
按 Phase 2 生成密钥并挂签名,重打 |
| F5 | APK 体积异常大 | public/ 下混入了只服务于生成管线的素材源/预览图 |
构建后清理脚本挂进 build,重新导出并 sync |
| F6 | 提审报「启动闪退」,但日志里搜不到游戏进程 | 平台沙箱读不到被测包(作用域存储权限),属平台环境问题 | 在日志里搜 ziparchive.*Permission denied 与 va_core.*Failed to open APK 取证 → 走平台工单 + 复测,不要改游戏代码 |
| F7 | 编译报『类重复: MainActivity』(duplicate class) | cap add 模板残留:旧包路径 java/com/getcapacitor/myapp/MainActivity.java 与修改 applicationId 后的新包路径并存 |
删除 app/src/main/java/com/getcapacitor/ 整个目录,仅保留新包路径下的 MainActivity;同时修正新文件内的 package 声明与 local.properties(F3) |
| F8 | dev 或构建 SSR 报 ReferenceError: Image is not defined(首页 500) |
贴图/资源预载模块在顶层执行 new Image(),服务端渲染无此对象 |
预载代码包进 if (typeof window !== 'undefined') {...};取图函数在服务端返回 null(调用方回退程序绘制/占位) |
反例黑名单(违反即返工)
- 禁止用 Android Studio 的 JBR 当 JAVA_HOME 打包——只用 Temurin JDK 21。
- 禁止在没有
capacitor.config.ts的情况下cap add android——会生成默认包名模板,后续 sync 救不回来。 - 禁止复用或覆盖其它游戏的 keystore、禁止把口令写进任何会被提交的文件(含本 skill)。
- 禁止跳过 Phase 4 双校验就交付;禁止未确认就删除旧 APK、未确认就改 versionCode。
- 禁止把工作区绝对路径写死进流程——路径按参数取,示例值仅作参考。
边界(不在本 skill 范围)
- 应用商店平台侧操作(创建游戏、包体库、提审、测试计划、素材/资质)→ 用对应的平台 skill(如
taptap-publish-game、taptap-package-management) - 提审/资质确认等后台操作也可由用户自行在开发者后台完成(网页表单部分 CLI 无法代填);是否由 AI 代跑平台流程以用户意愿为准
- iOS 打包、原生引擎(Unity/Unreal/Cocos)出包
- 游戏玩法代码编写与美术资产生成