学习目标
读完本章,你应当能够:
- 说清"为什么 Android 一定要签名",以及 v1 / v2 / v3 / v4 四种方案的区别;
- 解释签名方案与
minSdk的关系,以及本工程"配置里写了 v2、产物却只有 v3"为什么正常;- 理解 keystore 丢失 = 永远无法更新这个 App,并知道该备份什么;
- 看懂本工程的
signingConfigs(含一处因 Kotlin DSL 限制而手写的解析代码);- 独立跑完一遍完整发版流程:改版本号 → 构建 → 校验 → 命名 → 归档;
- 会用
apksigner verify/aapt2 dump badging校验一个 APK;- 掌握"如何确认一次修复真的进了包" ------比对 APK 内
.so与构建产物的 MD5 哈希;- 知道覆盖安装的常见坑,避免"用户升级后崩在旧代码上"。
前置 :ch20(构建体系)
对应源码 / 文档:
app/build.gradle.kts:41-69(signingConfigs)keystore.properties(项目根,已 gitignore,切勿提交)运行命令.txt(工程根的发版 runbook,本章大量引用它的实测输出)- 校验工具:
$ANDROID_SDK/build-tools/36.0.0/apksigner.bat、aapt2.exe
⚠️ 安全声明本章只讲签名机制与命令 。真实的密钥库密码、别名密码、密钥文件内容属于机密 ,
保存在项目根
keystore.properties(已在.gitignore中)。本书任何位置都不会写出真实密码。
你也应当把它们当作"比源码更重要的资产"对待------原因见 21.4。
🧭 读本章前,请先确认
- 前置:ch20(知道 APK 是怎么来的)。
- 需要的基础 :知道"哈希 / 指纹 "是什么
(一句话:内容的"指纹",内容变一个字节,指纹就不同);会用命令行。- 本章会出现的生词 :签名、keystore 、v1/v2/v3/v4 方案、
versionCode、
apksigner、aapt2、testOnly、覆盖安装。一起查 附录 B.7。- 读法建议 :本章最实用的不是签名原理 ,而是 21.7 的五条校验清单 ------
尤其是 21.7.5"用 MD5 确认修复真的进了包" 。
那是"我觉得应该进了 "和"我能证明它进了 "之间的分界线。
⚠️ 本章不含任何真实密钥密码,只讲机制与命令。
本章导读到这里,你已经能做出一个能装、能用的 APK 了。但"能用"和"能交付"是两回事。
从"能跑"到"能发布",中间隔着三件事:
事 为什么必须做 签名 不签名装不上 ;签名不对升不了级 校验 不校验,你可能把一个"看起来对但其实是坏的"包发出去 归档 发布物要有唯一命名,否则一周后你分不清哪个是哪个 💡 类比:公司的公章
- 签名 = 在文件上盖公章。没有章的文件,工商(系统)不认。
- keystore = 那枚公章本身 。文件可以重做,章丢了就再也做不出"同一家公司的文件" ------
以后出的所有文件都和之前的对不上,工商会认为"这不是同一家公司"。- 覆盖安装 = 用户手里已经有你盖过章的旧文件。你出了新版,必须用同一枚章 ,
否则系统(工商)会拒绝:"这和你之前立的案不是一家。"本章有一个很多人不会做、但极其重要的验证:
"我怎么知道我的修复真的进了这个 APK?"
答案是:把 APK 里的
.so抠出来,和构建产物的 MD5 比一比。听起来简单,但它是你从"我觉得应该进了"到"我确认进了"的分界线。
本章的核心:发版不是"运行一条命令",而是一套可复现、可验证的流程。
21.1 为什么 Android 一定要签名
21.1.0 从零开始:你写的 App,怎么证明是你写的?
先别管"签名方案 v1/v2/v3"这些名词。我们从最朴素的问题出发。
你辛辛苦苦写了一个 App,想发布到应用商店让用户下载。现在问题来了:
应用商店和用户手机,凭什么相信这个 App 是你写的,而不是别人冒充你、或者中途被人改过的?
答案就是签名(signing)。它的工作方式分两步:
- 你发布时 :用你自己保管的一把"私钥"(private key,只有你知道的秘密钥匙)给 App 算一个专属签名;
- 用户安装时 :Android 系统用对应的"公钥 "(public key,公开可验证)去核对这个签名。
- 对得上 → 系统确认"这个 App 确实是私钥的主人发布的、内容没被改过",放行;
- 对不上 → 系统拒绝安装。
💡 类比:古代的玉玺
皇帝下一道圣旨,末尾要盖上玉玺 (这就是"签名"------玉玺只有皇帝有,相当于"私钥")。
大臣接到圣旨,看到玉玺的印文,就知道:
① 这是皇帝的命令 (验证身份------确实是"发布者"本人);
② 圣旨上的字一个都没被改过(防篡改------谁敢改一个字,玉玺盖出来的印文就和原文对不上了)。
你的 App 就像圣旨,私钥就像玉玺,公钥就像"玉玺印文的标准样本"------
大臣(Android 系统)拿样本一对,是真的就照办,是假的就退回去。
那为什么 keystore 丢了就再也不能更新 App?
这里有一条铁律:同一个 App 的所有版本,签名必须一致。
- 你发布 v1.0 时用 keystore A 签名;
- 发布 v2.0 时,也必须用 keystore A 签名;
- 如果你换了 keystore B,系统核对时发现"v2.0 的签名和 v1.0 对不上",会直接判定:
"这不是同一个 App,请先把旧的卸载。"
也就是说:keystore 一旦丢失或泄露,你就失去了"继续更新这个 App"的资格 ------
老用户无法覆盖升级(只能卸载重装、数据全丢),应用商店也会拒绝"签名不一致的更新"。
想继续服务用户,只能换个包名当作"全新 App"重新发布(详见 21.4、练习 4)。
⚠️ 记住这个直觉再往下读:签名 = 你的 App 在 Android 世界里的"身份证"。
身份证丢了,系统就不再认你是"你"了。
21.1.1 签名的三个作用
| 作用 | 说明 |
|---|---|
| ① 身份标识 | 系统靠签名区分"这是不是同一个 App" |
| ② 防篡改 | 签名覆盖包内容,改一个字节就验不过 |
| ③ 升级控制 | 只有同一签名的包才能覆盖安装 |
第 ③ 条最关键:
用户装了
CN=llama.android签名的 v1.4。你出了 v1.5------如果签名用的是别的密钥 ,
系统安装时会报签名冲突 ,用户必须先卸载再装(数据全丢)。
⚠️ 这就是 21.4 要讲的"keystore 比代码更重要"的根源:签名决定了"你能不能继续服务已有用户"。
21.1.2 不签名的包根本装不上
从 Android 7(API 24)起,未签名的 APK 一律拒绝安装 。
所以你不可能"先发一个不签名的包试试"。
21.2 四种签名方案
Android 历史上发展出四套签名方案。理解它们的关键是**"覆盖了什么范围"**。
💡 类比:信封的封口方式
- v1 像"在每张纸上分别签字"------信封里有 100 张纸,你在每张纸角上都签一次名。
慢,而且只签了纸、没签信封本身:理论上别人可以抽换某几张纸而不被发现。- v2 像"在整个信封的封口处贴一条封条"------信封整个封死,
任何人只要动过信封里的任何东西,封条就碎了。又快又严。- v3 像"允许你在不破坏信封的前提下,换一种新封条样式 "(密钥轮换)------
旧封条和新封条之间有官方交接记录,用户依然认得出"这还是同一家寄的"。这个类比在"签名块到底塞在 APK 的哪个字节位置"这种细节上不精确,
但"保护范围从小到大"这个核心区别是对的。
21.2.1 对比表
| 方案 | 引入版本 | 覆盖范围 | 特点 |
|---|---|---|---|
| v1(JAR 签名) | Android 1.0 | 逐个文件的条目签名 | 老方案;只保护文件内容,不保护 APK 结构(比如可以往里面加文件) |
| v2(APK Signature Scheme v2) | Android 7.0(API 24) | 整个 APK(更完整) | 验证更快;覆盖 zip 结构与元数据 |
| v3 | Android 9(API 28) | 整个 APK + 签名元数据 | 支持密钥轮换;可记录"新密钥由旧密钥授权" |
| v4 | Android 11(API 30) | 增量读取用的独立签名文件 | 优化"增量安装",但依赖 DM 文件,部分厂商安装器不支持 |
21.2.2 逐个理解
v1(JAR 签名)
把 APK 当普通 JAR 处理:每个文件单独算摘要、签名。
问题:只保护"文件内容",不保护"APK 这个 zip 本身" ------
理论上可以在不破坏签名的情况下往 APK 里塞新文件 (这叫 Janus 漏洞)。
→ 所以有了 v2。
v2
对整个 APK 文件(除了签名块自己)算一个摘要并签名。
覆盖了 zip 结构,堵住了 v1 的漏洞。验证也更快(一次算完整文件摘要 vs 逐个文件)。
v3
在 v2 基础上,签名块里额外包含"签名的元数据" ------最关键的是密钥轮换信息:
旧密钥 A 签发 → 授权 新密钥 B → 以后可以用 B 签
为什么需要轮换?
因为密钥可能泄露、或需要按公司规范更换。
没有 v3,换了密钥 = 所有用户必须卸载重装 。
有 v3,可以平滑过渡(但需要新版本先用旧密钥授权)。
v4
为"增量安装(Incremental FS)"优化的独立签名文件 (.apk.idsig)。
它让 Android 11+ 能边下载边安装 。
但 它依赖一个额外的 .idsig 文件,有些厂商的安装器不认 ------
所以本工程关了它。
21.2.3 和 minSdk 的关系(关键)
规则 :只要设备的 Android 版本支持某个方案,就能验签。
你的 minSdk |
最低设备版本 | 哪些方案有效 |
|---|---|---|
| ≥ 28(Android 9) | 全部支持 v3 | v3 单独就够 |
| 24--27 | 支持 v2,不支持 v3 | v2 + v3 都加(v2 兜底) |
| < 24 | 只支持 v1 | 必须加 v1 |
本工程 minSdk = 33 (≥ 28)→ 只需 v3。
📝 最小示例:build 脚本里的签名开关长什么样
先看一个和工程无关的最小骨架 (📝 示例,需自行验证),
理解"v1/v2 开关"在 Gradle 里是怎么写的:
kotlinandroid { signingConfigs { create("release") { storeFile = file("my-release-key.jks") // keystore 文件位置(保险柜) storePassword = "你的keystore密码" // 保险柜密码 keyAlias = "my-key" // 保险柜里那把钥匙的名字 keyPassword = "你的别名密码" // 这把钥匙的密码 enableV1Signing = false // 老设备(Android 7 之前)才需要 enableV2Signing = true // Android 7+ 推荐 enableV3Signing = true // Android 9+,支持换密钥 enableV4Signing = false // 增量安装用,本工程关掉 } } }读法:
storeFile / storePassword是"保险柜在哪、保险柜密码是什么";keyAlias / keyPassword是"保险柜里用哪一把钥匙、那把钥匙的密码";enableV*Signing就是"这封信要用哪种封口方式"。⚠️ Groovy DSL(
build.gradle,不是.kts)里历史上叫v1SigningEnabled/v2SigningEnabled,本工程用的是 Kotlin DSL(
.kts),所以叫enableV1Signing/enableV2Signing------指的是同一回事,只是语序不同。现在别深究,知道有两种写法即可。
21.2.4 为什么 v1 也关了
✅ 取自本工程 ------ app/build.gradle.kts:59-67
kotlin
// 显式启用 v2 + v3 签名
// v1 (JAR signing) 对 minSdk >= 30 的设备非必需,AGP 默认会根据 minSdk 自动裁剪
// v2: Android 7.0 (API 24) 引入,所有现代设备都支持
// v3: Android 9 (API 28) 引入,支持密钥轮换;HyperOS/MIUI 最新版对 v3 签名有更严格要求
enableV1Signing = false
enableV2Signing = true
enableV3Signing = true
// v4: Android 11 (API 30) 引入,增量安装优化,但依赖 DM 文件,某些厂商安装器可能不支持
enableV4Signing = false
注释里还提到一个现实约束:
v3: ......HyperOS/MIUI 最新版对 v3 签名有更严格要求
这是国产 ROM 的真实情况------某些厂商的安装器会额外检查签名方案 。
这也是为什么本工程特意把 v3 打开(虽然 minSdk 33 会自动带上,但显式声明更清楚)。
21.3 一个反直觉的现象:配置里写了 v2,产物里却没有
21.3.1 现象
✅ 取自本工程 runbook 的实测记录 ------ 运行命令.txt 第 231-235 行
(7) 签名方案只有 v3
build.gradle.kts 写了 enableV2Signing = true,但实测产物里没有 v2 块
(v1=false, v2=false, v3=true,build-tools 35/36 交叉验证一致)。
minSdk 33 >= 28,v3 单独足够,安装不受影响;但若以后把 minSdk 降到
28 以下就需要补 v2。
注意两个细节:
- 配置文件里
enableV2Signing = true; - 但实测
v2=false------"配置里写了" ≠ "产物里有"; - 而且用了两个 build-tools 版本(35 / 36)交叉验证,结论一致。
21.3.2 为什么会这样
AGP 会根据 minSdk 自动裁剪。
minSdk = 33 时,AGP 知道"所有目标设备都支持 v3",
于是v2 就是冗余的 (v3 覆盖了 v2 的保障)------所以它不生成 v2 块。
📌 这是一个"工具比你聪明"的例子 :
你写的配置是"意图",工具会结合上下文做"优化"。
所以最终要看产物,而不是看配置。
这也解释了 ch20 的
buildTypes里为什么强调"配置里写了 ≠ 产物里有"------AGP 很会做这种自动裁剪。
21.3.3 怎么确认
用 apksigner verify --verbose:
bash
%BT%\apksigner.bat verify --verbose --print-certs 离线AI-release.apk
预期输出(✅ 本工程实测):
Verifies
Verified using v1 scheme (JAR signing): false
Verified using v2 scheme (APK Signature Scheme v2): false
Verified using v3 scheme (APK Signature Scheme v3): true
Signer #1 certificate DN: CN=llama.android, OU=Release, O=llama.android, C=CN
Signer #1 certificate SHA-256 digest: bdef6b3af5445ddf105ef119273dab4c31bc28b9730e37a8170450120f5b9976
📌
v1=false, v2=false是正常的 (因为 minSdk 33)。不要看到 false 就以为"签名没签上" ------看
v3 = true就够了。
21.4 keystore:比源码更重要的资产
21.4.1 为什么
💡 类比:家里的保险柜
keystore(
.jks文件)就像家里的保险柜:
- 保险柜里放着玉玺(私钥)------它才是真正能盖章的东西;
- 保险柜有两道密码:一道开柜门(storePassword),一道开保险箱抽屉(keyPassword);
- 保险柜丢了 → 玉玺拿不出来,你再也盖不了章;
- 密码忘了 → 保险柜打不开,玉玺同样拿不出来;
- 保险柜被小偷偷走了 → 小偷拿着你的玉玺,可以以你的名义发假圣旨 ,
用户看到"玉玺是真的",会以为假 App 是你发布的。所以 keystore 文件和它的密码,一个都不能丢、一个都不能让外人拿到。
✅ 取自本工程 runbook ------ 运行命令.txt 第 124 行
注意:release-key.jks + keystore.properties 必须备份:丢了就永远无法再更新这个 App。
"永远无法更新"是什么意思?
用户装了 v1.4(签名密钥 A)
↓
你换了新电脑,密钥 A 丢了
↓
你用新密钥 B 做 v1.5
↓
用户升级 → 系统验签:B ≠ A → 拒绝安装(签名冲突)
↓
用户必须卸载 v1.4(数据全丢)才能装 v1.5
对已上架应用更严重 :应用商店(Google Play / 国内商店)会拒绝
"签名与已有版本不一致"的更新------你的 App 就再也无法更新了,只能下架重发。
21.4.2 要备份什么
| 文件 | 内容 | 重要性 |
|---|---|---|
release-key.jks |
密钥库本体(私钥在里面) | ★★★★★ |
keystore.properties |
密钥库密码、别名、别名密码 | ★★★★★ |
| 密码(记在别处) | 万一 properties 也丢了 | ★★★★ |
⚠️ 本项目实测的一个细节 (本工程踩过):
release-key.jks是 PKCS12 格式 ,而 PKCS12 会忽略单独设置的 key password ------实际使用 store password 来保护密钥 。
所以
keystore.properties里的keyPassword必须等于storePassword,否则会报密码错误。
21.4.2.1 📝 自己创建一个 keystore 长什么样
如果你要从零创建一个 keystore,Android 官方提供的命令是 keytool(📝 示例,需自行验证):
bash
keytool -genkeypair -v \
-keystore my-release-key.jks \
-keyalg RSA -keysize 2048 \
-validity 10000 \
-alias my-key
逐个参数读:
| 参数 | 大白话 |
|---|---|
-genkeypair |
"帮我新生成一对密钥(公钥+私钥)" |
-keystore my-release-key.jks |
把这对密钥装进 my-release-key.jks 这个保险柜文件 |
-keyalg RSA |
用 RSA 算法(最常用的非对称算法,现在别深究,知道是"标准算法"即可) |
-keysize 2048 |
密钥长度 2048 位(安全性够) |
-validity 10000 |
有效期 10000 天(约 27 年,应用商店要求至少 25 年) |
-alias my-key |
给这把钥匙起个名字叫 my-key,以后签名时用这个名字找它 |
跑完会让你输 keystore 密码 和 key 密码------这两个密码就是上面保险柜的两道密码,务必记下来。
⚠️ 本工程的
release-key.jks是用这条思路生成的,但具体命令已写在 runbook 里,不要在这里照抄参数 ------你只需要理解"每个参数在保险柜类比里对应什么"。
21.4.2.2 保管建议(零基础版)
| 做什么 | 为什么 |
|---|---|
| keystore 文件备份到至少两个地方(比如云盘 + U 盘) | 单台电脑坏了/丢了,备份还在 |
| 密码用密码管理器保存(不要只记在脑子里) | 人脑不可靠,三年后你大概率想不起来 |
| 不要把 keystore 提交到 Git | 一旦进了版本库,就算删了也有历史记录(见 21.4.3) |
| 把**签名指纹(SHA-256)**写进发版 runbook | 每次发版对一下,防止"悄悄换了密钥" |
| 换电脑前,先把 keystore 和密码都拷过去再删旧机器 | 这是最常见的"我以为备份了其实没备份"事故 |
📌 一句话总结 :keystore 的备份要做到------
文件在两个地方、密码在另外一个地方、Git 里一个字都没有。
21.4.3 绝对不能提交到版本库
✅ 取自本工程 ------ .gitignore(本工程已加)
keystore.properties
*.jks
*.keystore
💡 一旦提交上去:
- 即使是私有仓库,也可能被协作方、CI 日志、镜像泄露;
- 密钥泄露 = 别人可以冒充你发布"你的 App"(这是很严重的安全事故);
- 而且改不回来 ------密钥一旦泄露,只能换密钥 + 让所有用户重装。
所以:密钥文件永远不进版本库,只做加密备份。
21.5 本工程的签名配置
✅ 取自本工程、已编译验证 ------ app/build.gradle.kts:41-69
kotlin
signingConfigs {
create("release") {
val keystorePropsFile = rootProject.file("keystore.properties")
val props = mutableMapOf<String, String>()
if (keystorePropsFile.exists()) {
keystorePropsFile.readLines().forEach { raw ->
val line = raw.trim()
if (line.isNotEmpty() && !line.startsWith("#") && line.contains("=")) {
val eq = line.indexOf('=')
props[line.substring(0, eq).trim()] = line.substring(eq + 1).trim()
}
}
}
storeFile = rootProject.file(props.getValue("storeFile"))
storePassword = props.getValue("storePassword")
keyAlias = props.getValue("keyAlias")
keyPassword = props.getValue("keyPassword")
enableV1Signing = false
enableV2Signing = true
enableV3Signing = true
enableV4Signing = false
}
}
21.5.1 keystore.properties 的四个键
✅ 取自本工程 runbook ------ 运行命令.txt 第 115-118 行(已脱敏)
storeFile=release-key.jks ← 相对 llama.android 目录
storePassword=******
keyAlias=******
keyPassword=******
| 键 | 含义 | 注意 |
|---|---|---|
storeFile |
密钥库文件相对路径 | 相对 llama.android 目录 |
storePassword |
密钥库密码 | ★ 机密 |
keyAlias |
密钥别名 | 本工程是 llama(见 runbook) |
keyPassword |
别名密码 | ★ PKCS12 下必须等于 storePassword(21.4.2) |
21.5.2 为什么手写解析而不用 java.util.Properties
那段 readLines().forEach { ... } 看起来啰嗦,但是刻意的:
📌 实测原因 :Kotlin DSL 里
java.util.Properties解析不稳(报
Unresolved reference 'util')。所以改用纯 Kotlin 的
readLines()手动解析------稳定、可读、零依赖。
逐行看这段解析器做了什么:
| 行 | 做什么 |
|---|---|
raw.trim() |
去掉首尾空白 |
line.isNotEmpty() |
跳过空行 |
!line.startsWith("#") |
跳过注释行 |
line.contains("=") |
必须有等号 |
line.indexOf('=') |
找第一个等号的位置 |
substring(0, eq) / substring(eq + 1) |
切开 key 和 value,再各自 trim |
这就是一个 properties 文件的最简解析器------20 行不到,逻辑清晰。
💡 为什么用
indexOf而不是split("=")?因为 value 里可能包含
=(比如 base64 密码)。
split("=")会把它切断,indexOf只切第一个。这是个常被忽略的细节。
21.6 完整发版流程
本工程把全流程写进了 运行命令.txt(runbook)。发版不是一条命令,而是五步。
21.6.1 第 ① 步:升版本号
✅ 取自本工程 ------ app/build.gradle.kts:16-17
kotlin
versionCode = 7
versionName = "1.6"
| 字段 | 含义 | 规则 |
|---|---|---|
versionCode |
给系统看的整数 | 必须递增;用户能否升级看它 |
versionName |
给人看的字符串 | 随便写(1.4、2.0-beta),只用于展示 |
⚠️ 忘了升
versionCode会怎样?用户装不上更新------系统认为"已安装的版本 >= 新版本",直接跳过。
这是发版最常见的低级错误。
21.6.2 第 ② 步:构建
bash
cd llamacpptest\llama.android
gradlew :app:assembleRelease
签名在这一步自动完成(✅ runbook 第 111 行):
★ 签名在 assembleRelease 内部自动完成,正常情况下你不需要单独跑 apksigner。
产物路径(ch20 讲过,取决于有没有用注入参数):
| 情况 | 路径 |
|---|---|
| 不带注入(当前推荐) | app/build/outputs/apk/release/离线AI-release.apk |
| 带注入(已弃用) | app/build/intermediates/apk/release/离线AI-release.apk |
21.6.3 第 ③ 步:校验(见 21.7)
21.6.4 第 ④ 步:复制并命名
✅ 取自本工程 runbook ------ 第 183-190 行
命名约定:app-release-v<versionName>-code<versionCode>-signed.apk
copy /Y llama.android\app\build\outputs\apk\release\离线AI-release.apk ^
离线AI-release-v1.6-code7-signed.apk
根目录同时只保留最新一版,旧版本的 APK 删掉(源码可重现,留着只是占空间)。
命名规范的价值 :
离线AI-release-v1.6-code7-signed.apk 这一串里包含了版本名 + 版本码 + 已签名 三个信息。
一周后你自己能一眼看出这是哪一版------这就是规范的意义。
📌 "根目录只留最新一版" :因为源码可重现,旧包留着只占空间。
但如果某个版本已发布给用户,建议另行归档(用户可能还需要旧版)。
21.6.5 第 ⑤ 步:归档
- APK 复制到工作区根目录(方便取用);
- 旧版本删除;
- 保留:源码 + keystore(这个永远不能删!)。
21.7 校验清单(每次发版都做)
这是本章最实用的部分。 runbook 第 142-176 行给了完整清单。
21.7.1 准备
bash
set BT=C:\dev\Android\Sdk\build-tools\36.0.0
set APK=离线AI-release-v1.6-code7-signed.apk
21.7.2 ① 版本号 / ABI / 应用名
这是什么 :aapt2(Android Asset Packaging Tool 2)是 Android 的资源打包/读取工具 。
dump badging 这条命令会把 APK 里"写在名片上的信息"原样念给你听------
包名、版本号、支持哪些 CPU 架构、App 名字叫什么。
为什么必须查 :发布前第一件事就是确认"这个包上写的名字,和我以为的一致 "。
包名错了 → 用户升级到一个"新 App";版本号没升 → 用户装不上;ABI 错了 → 真机上崩。
这些问题装上手机才能发现就晚了,用 aapt2 一行命令就能在发版前拦住。
💡 类比:查快递单号
aapt2 dump badging就像你拿到包裹先看一眼面单------收件人是不是你、地址对不对、寄的是哪个东西。面单上的字对不上,就别拆开了。
bash
%BT%\aapt2.exe dump badging %APK% | findstr /B "package: native-code application-label:"
期望(✅ 本工程实测):
package: name='work.ai4easy.offlineai' versionCode='7' versionName='1.6' ...
application: label='离线AI'
native-code: 'arm64-v8a'
看三件事:
versionCode/versionName是不是你改的那一版;native-code只列出arm64-v8a(ch20 讲的半残切片就靠这条发现);label是不是"离线AI"。
21.7.3 ② 签名方案与签名者
这是什么 :apksigner 是 Android 官方的签名/验签工具 。
verify 就是"验封条"------它会实际去核对 APK 的签名块,告诉你:
① 用了 v1/v2/v3 哪种方案;② 签名证书是谁(DN);③ 证书的指纹(SHA-256)。
为什么必须查 :如果签名无效,应用商店直接拒上架,用户安装时直接报错 。
而且这一步还能确认"我用的是 release 密钥,不是 debug 密钥 "------
debug 密钥签的包会被国产 ROM 拦截(21.8.3)。
💡 类比:验封条
apksigner verify就像快递到了先看封条------封条是原厂的、没断过、上面的印文和厂家备案的一致,才能放心签收。
封条断过 / 印文对不上 → 这包裹不能要。
bash
%BT%\apksigner.bat verify --verbose --print-certs %APK%
期望:
Verifies
Verified using v1 scheme (JAR signing): false
Verified using v2 scheme (APK Signature Scheme v2): false
Verified using v3 scheme (APK Signature Scheme v3): true
Signer #1 certificate DN: CN=llama.android, OU=Release, O=llama.android, C=CN
Signer #1 certificate SHA-256 digest: bdef6b3af5445ddf105ef119273dab4c31bc28b9730e37a8170450120f5b9976
关键 :Signer DN 必须是 CN=llama.android, OU=Release ,
不能是 CN=Android Debug(那是 debug 密钥,ch02 讲过会被厂商拦截)。
📌 记下这个 SHA-256 :
bdef6b3a...b9976是本工程的签名指纹 。每次发版都应该一样------如果变了,说明你不小心换了密钥(严重问题!)。
21.7.4 ③ 确认 ABI 切片完整
bash
unzip -l %APK% | findstr "lib/"
期望(✅ 本工程实测):
只有 lib/arm64-v8a/,且 libai-chat.so、libllama.so、libllama-common.so、
libggml*.so、libomp.so 齐全(共 15 个条目)
这一步专治"半残切片" (ch20):
不能出现"某个 lib/xxx/ 目录存在却没有 libai-chat.so"。
💡 为什么这条校验必须有?
因为半残切片的症状在 arm64 真机上完全正常 ------
只有校验"x86_64 目录里有没有 libai-chat.so"才能发现。
校验的价值就在于发现"你自己测试时看不到的问题"。
21.7.5 ④ 【关键】确认修复真的进了包
这是本章最有价值的一条。
✅ 取自本工程 runbook ------ 第 163-172 行
# 4.4 【关键】确认修复真的进了包:APK 内 .so 与构建产物 MD5 必须一致
# (构建产物路径里的哈希用通配符代)
certutil -hashfile lib\build\intermediates\cxx\Release\<hash>\obj\arm64-v8a\libai-chat.so MD5
# APK 内取出来算:
# PowerShell:
# Add-Type -A System.IO.Compression.FileSystem
# $z=[IO.Compression.ZipFile]::OpenRead((Resolve-Path $APK))
# $e=$z.Entries | ? { $_.FullName -eq 'lib/arm64-v8a/libai-chat.so' }
# [IO.Compression.ZipFileExtensions]::ExtractToFile($e,"$env:TEMP\chk.so",$true); $z.Dispose()
# certutil -hashfile "$env:TEMP\chk.so" MD5
逻辑:
💡 "哈希 / MD5 是什么?(0 基础版解释)
哈希(hash,也叫"指纹")= 把任意一段数据,算出一个固定长度的短字符串 。
比如一个 70MB 的
.so文件,MD5 哈希出来只有 32 个十六进制字符:
c2f781e487c410e721ee785808329712。关键性质:
- 内容变一个字节,哈希就完全不同(不是"差一点",是整个字符串大变样);
- 不同文件几乎不可能算出同一个哈希(撞库概率极低)。
所以"两个文件的 MD5 相同" ≈ "这两个文件内容一模一样"。
这就是为什么它能用来确认"APK 里的 .so 和磁盘上的 .so 是同一个"。
① 算构建产物(磁盘上那个 .so)的 MD5
② 从 APK 里把同一个 .so 抠出来,算 MD5
③ 两者必须一致
为什么必须做?
因为**"我改了代码" ≠ "包里有新代码"**。可能的原因:
| 情况 | 后果 |
|---|---|
Gradle 用了缓存的旧 .so |
你的修复没进包 |
| 构建失败但你没注意 | 用的是上一次的产物 |
| 路径搞错(ch20 讲的产物两路径) | 你校验的是旧包 |
具体输入 → 逐步执行推演(0 基础版)
场景:你刚修了一个 native bug,跑了 gradlew :app:assembleRelease,把新 APK 装到手机上------
结果 bug 还在。这时候你需要系统性排查,而不是"再试一次"。
可能的三个原因,按概率从高到低:
| # | 可能原因 | 怎么发现 |
|---|---|---|
| ① | 你手机上装的还是旧 APK------新 APK 根本没覆盖成功(签名冲突、versionCode 没升) | 卸载重装一次看 bug 还在不在 |
| ② | 新 APK 里的代码不是最新的 ------Gradle/CMake 用了缓存的旧 .so,你以为重编了其实没编 |
MD5 比对(下面这条) |
| ③ | 你改的代码没被编译进去------文件没保存、或改的是另一个 flavor/variant 的同名文件 | 检查 IDE 标签页有没有"未保存"标记;确认改的是 release variant 走的那份源码 |
用哈希比对定位 ② 的完整步骤(Windows 实操,和 runbook 等价):
powershell
# 第 1 步:从新 APK 里把 libai-chat.so 抠到临时文件
Add-Type -A System.IO.Compression.FileSystem
$z=[IO.Compression.ZipFile]::OpenRead((Resolve-Path $APK))
$e=$z.Entries | ? { $_.FullName -eq 'lib/arm64-v8a/libai-chat.so' }
[IO.Compression.ZipFileExtensions]::ExtractToFile($e,"$env:TEMP\chk_in_apk.so",$true); $z.Dispose()
# 第 2 步:算 APK 里这份 .so 的 MD5
certutil -hashfile "$env:TEMP\chk_in_apk.so" MD5
# 第 3 步:算磁盘上刚编出来的那份 .so 的 MD5(<hash> 是 8 位目录名,用通配符)
certutil -hashfile lib\build\intermediates\cxx\Release\*\obj\arm64-v8a\libai-chat.so MD5
# 第 4 步:两个 MD5 必须完全一致
判断规则:
- 两个 MD5 一致 → APK 里的就是你刚编出来的版本,修复确实进了包。bug 还在说明问题在 ① 或 ③;
- 两个 MD5 不一致 → 构建根本没把新代码打进去。执行:
bash
gradlew clean
gradlew :app:assembleRelease
clean 会删掉所有中间产物,强制全部重编。编完再比一次。
💡 类比:查商品批号
你买了一瓶药,店员说"这是刚出厂的新批次",但你怀疑是旧批次换了包装。
你不会把药吃下去再判断------你会翻过来看瓶身上的批号 ,
跟厂家官网公布的"新批次批号"一对:
- 批号一致 → 确实是新批次;
- 批号对不上 → 店员在骗你(或者包装印错了)。
MD5 就是
.so文件的"批号"。不要靠运行 App 去猜"修复进没进包",直接对批号。
✅ 本工程实测 :libai-chat.so 的 MD5 = c2f781e487c410e721ee785808329712,
APK 内的一致 → 修复确实进包。
📝 在 macOS / Linux 上 ,用
unzip -p更方便:
bashunzip -p 离线AI-release.apk lib/arm64-v8a/libai-chat.so | md5sum md5sum lib/build/intermediates/cxx/Release/*/obj/arm64-v8a/libai-chat.so(Windows PowerShell 的写法见上面 runbook,稍长但等价。)
21.7.6 ⑤ 确认没有被标记 testOnly
bash
%BT%\aapt2.exe dump xmltree --file AndroidManifest.xml %APK% | findstr /I "testOnly"
期望 :无输出(正常)。
如果有输出 → APK 带了 testOnly 标记 → 系统安装器报:
-15 INSTALL_FAILED_TEST_ONLY
根因 (ch20 讲过):用了 -Pandroid.injected.build.abi 注入参数。
解决 :去掉注入参数,只用 abiFilters。
21.7.7 校验清单汇总
| # | 命令 | 检查什么 |
|---|---|---|
| ① | aapt2 dump badging |
版本号 / ABI / 应用名 |
| ② | apksigner verify --print-certs |
签名方案 + 签名者是不是 release 密钥 |
| ③ | `unzip -l | grep lib/` |
| ④ | MD5 比对 .so | 修复是否真的进包 |
| ⑤ | `aapt2 dump xmltree | grep testOnly` |
五条全过,才叫"可以发"。
21.8 覆盖安装的坑
21.8.1 签名冲突
✅ 取自本工程 runbook ------ 第 237-239 行
(8) 首次安装
若设备上已装过同包名 work.ai4easy.offlineai 且签名不同(例如旧的 debug 包),
必须先卸载再装 release 包。
操作(✅ runbook 第 137-139 行):
bash
adb uninstall work.ai4easy.offlineai
adb install -r 离线AI-release-v1.6-code7-signed.apk
⚠️ 卸载会清掉 App 的所有数据 (DataStore 里的会话记录、导入的模型文件)。
所以尽量用同一个密钥------这是 21.4 强调 keystore 重要性的现实原因。
21.8.2 versionCode 没升
现象 :adb install -r 报"已安装更高版本"或直接跳过。
解决 :升 versionCode(21.6.1)。
21.8.3 国产 ROM 的额外拦截
ch02 提过:某些厂商 ROM(如小米 HyperOS)的安全中心会拦截 debug 签名包 ,
或者要求 v3 签名(21.2.4 的注释也提到)。
所以:
- 一定要用 release 密钥;
- 一定要确认
OU=Release(21.7.3)。
21.9 常见错误与排查
| 现象 / 报错 | 原因 | 解决 |
|---|---|---|
INSTALL_FAILED_UPDATE_INCOMPATIBLE(签名冲突) |
新旧包签名不同 | 卸载旧的再装;长期方案是守住 keystore |
-15 INSTALL_FAILED_TEST_ONLY |
用了注入参数(ch20) | 去掉 -Pandroid.injected.build.abi |
| 装不上、提示"版本更高" | 忘了升 versionCode |
递增它(21.6.1) |
apksigner 报 DOES NOT VERIFY |
包被改过 / 签名失败 | 重新构建;检查构建日志有没有签名报错 |
签名者是 CN=Android Debug |
用了 debug 密钥 | 检查 signingConfig 是否指向 release(:84) |
| 报密码错误 | PKCS12 下 keyPassword ≠ storePassword |
让两者相等(21.4.2) |
keystore.properties 找不到 |
文件没创建(它不进版本库) | 按 21.5.1 创建,并妥善备份 |
| 改了代码但包里还是旧的 | 构建缓存 / 构建失败 / 路径搞错 | 用 21.7.5 的 MD5 比对验证 |
native-code 有两个 ABI |
半残切片(ch20) | 去掉注入参数 |
| 用户升级后崩/丢数据 | 换了签名 → 用户必须卸载重装 | 守住 keystore;或提前公告 |
21.10 动手验证:完整跑一遍校验清单
21.10.1 前提
已经有一个构建好的 release APK(21.6.2)。
21.10.2 按 21.7 跑五条
bash
set BT=C:\dev\Android\Sdk\build-tools\36.0.0
set APK=app\build\outputs\apk\release\离线AI-release.apk
%BT%\aapt2.exe dump badging %APK% | findstr /B "package: native-code application-label:"
%BT%\apksigner.bat verify --verbose --print-certs %APK%
unzip -l %APK% | findstr "lib/"
%BT%\aapt2.exe dump xmltree --file AndroidManifest.xml %APK% | findstr /I "testOnly"
逐条对照 21.7 的期望输出。
如果五条全对,说明这个包是可交付的。
21.10.3 做一次"修复是否进包"的验证(推荐)
- 在
ai_chat.cpp的某个LOGi里改一个字(比如加个标记); gradlew :app:assembleRelease;- 按 21.7.5 比对
libai-chat.so的 MD5;- MD5 应该变了(因为你改了代码);
- 然后从 APK 里抠出来的也应该是同一个新 MD5;
- 把改动改回去。
这个实验能让你彻底掌握"确认修复进包"的方法 ------
以后每次发版你都会习惯性地做这一步。
21.10.4 验证签名指纹没变
记录 Signer #1 certificate SHA-256 digest 的值(本工程是 bdef6b3a...b9976)。
每次发版后对比 ------如果变了,说明密钥换了,必须立刻查原因。
💡 建议把签名指纹写进项目的发版文档 (本工程写在
运行命令.txt第 122 行)。这样任何人发版后都能对一下,防止"悄悄换密钥"这种重大事故。
21.11 小结
| 概念 | 一句话 | 工程示例 |
|---|---|---|
| 为什么要签名 | 身份 + 防篡改 + 决定能否升级 | 21.1 |
| v1(JAR) | 逐文件签名,不保护 zip 结构 | 本工程关 |
| v2 | 整个 APK 签名 | 配置开了,但 minSdk 33 时 AGP 自动裁剪 |
| v3 | v2 + 密钥轮换 | 本工程实际生效的方案 |
| v4 | 增量安装(需 .idsig) |
本工程关(厂商兼容性) |
| 方案 vs minSdk | minSdk ≥ 28 → v3 单独够 | minSdk = 33 |
| 配置 ≠ 产物 | AGP 会按 minSdk 自动裁剪 | "写了 v2 但产物无 v2"(21.3) |
| keystore | 丢了就永远无法更新 | 必须备份 + 永不提交 |
| PKCS12 特点 | 忽略单独 keypass → 两个密码必须相同 | 21.4.2 |
signingConfigs |
读 keystore.properties |
:41-69(手写解析器) |
indexOf('=') |
value 可能含 =,不能 split |
21.5.2 |
versionCode vs versionName |
系统看前者、人看后者 | :16-17 |
| 校验五条 | badging / apksigner / lib / MD5 / testOnly | 21.7 |
| MD5 比对 | 确认修复真的进包 | 21.7.5 |
| 签名指纹 | 应每次一致,变了必查 | bdef6b3a...b9976 |
| 覆盖安装 | 同包名不同签名 → 必须先卸载 | 21.8.1 |
21.12 本章你学会了什么
逐条自测。任何一条打不了勾,回到对应小节再看一遍。
- 我能说出签名的三个作用,以及为什么"不签名装不上"。
- 我能区分 v1/v2/v3/v4,并说清各自覆盖什么范围。
- 我能解释"签名方案与 minSdk 的关系"。
- 我知道本工程为什么只需 v3。
- 我理解"配置里写了 v2 但产物里没有"为什么正常。
- 我能解释 keystore 丢失为什么是"永远无法更新"。
- 我知道该备份哪些东西(jks + properties + 密码)。
- 我知道 PKCS12 下两个密码必须相同。
- 我理解为什么密钥绝不能提交到版本库。
- 我能读懂
signingConfigs里那段手写的 properties 解析。 - 我知道为什么用
indexOf('=')而不是split("=")。 - 我说得出
versionCode和versionName的区别,以及忘了升前者会怎样。 - 我能独立跑完五条校验,并逐条判断结果是否正常。
- 我会用 MD5 比对确认"修复真的进包"。
- 我知道签名指纹应该每次一致,以及不一致意味着什么。
21.13 练习
练习 1:为什么"配置里写了 v2"却"产物里没有 v2"
请解释这个现象的成因,并说明"该怎么正确判断签名方案"。
解题思路提示
AGP 知道你的 minSdk 吗?知道的话它会做什么优化?
配置是"意图"还是"最终结果"?
参考答案要点
成因 :
AGP 知道 minSdk = 33------这意味着所有目标设备都支持 v3 。
而 v3 的保障覆盖了 v2(v3 是 v2 的超集)。
所以 AGP 认为:再加一个 v2 块是冗余的 ,于是不生成它。
"配置"和"产物"的关系:
你写的
build.gradle.kts是意图(intent) ;AGP 会结合上下文(minSdk、AGP 版本等)做优化 ,产出最终结果 。
所以要判断真实情况,必须看产物。
正确判断方法:
bash
apksigner verify --verbose --print-certs 离线AI-release.apk
看输出里的 Verified using vN scheme: true/false。
延伸:这不是个例
AGP 的"自动裁剪/优化"还有很多,比如:
- 根据
minSdk决定要不要加某些<uses-sdk>属性; - 自动去掉不需要的资源;
- 自动处理
debug/release的差异。
所以"看产物"是发版校验的核心原则 ------
本工程 21.7 的五条校验,本质上都是"看产物而不是看配置"。
什么时候需要补 v2?
如果 minSdk 降到 28 以下(比如 24),就有设备只支持 v2 而不支持 v3,
那时 AGP 就会自动把 v2 也加上(不用你改配置)。
练习 2:为什么"忘了升 versionCode"是灾难性的
请解释:如果只改了 versionName 而没改 versionCode,用户安装新版会发生什么?
解题思路提示
系统靠哪个字段判断"这是不是更新"?
versionCode 是整数还是字符串?
参考答案要点
现象 :用户装不上新版本。
原因 :
系统的升级判定只看 versionCode(整数):
已安装:versionCode = 7
新包: versionCode = 7 ← 没变
结果: 系统认为"版本相同或更旧" → 拒绝安装 / 跳过
表现可能是:
adb install -r报 "已安装" 或直接跳过;- 应用商店拒绝上架更新;
- 用户手动安装也可能被系统拒绝("已有更高版本")。
为什么 versionName 改了没用?
因为 versionName("1.4")是给人看的字符串 ,
系统不解析它 ------它可以是 "1.4"、"v1.4-final"、"春季版",
系统完全不知道哪个"更大"。
正确做法:
kotlin
versionCode = 6 // ← 必须递增(整数)
versionName = "1.5" // ← 给人看
versionCode 的递增规则:
- 只能递增,不能减小,也不能重复;
- 建议每次发版 +1(简单不易错);
- 不要"跳着用"(比如 5 → 100),除非你有明确的版本策略。
实践建议 :
把"改版本号"做成发版清单的第一项(本工程 runbook 的流程就是先改版本号)。
人最容易忘的就是这一步------因为它"看起来不重要"。
练习 3:设计"确认修复真的进包"的验证
假设你修了一个 native 侧的 bug,重新构建了 APK。
请设计一个不依赖运行 App 的验证方法,确认修复进了包。
解题思路提示
修复在 C++ 里 → 体现在哪个文件里?
怎么证明"APK 里的这个文件"和"你刚编出来的"是同一个?
参考答案要点
方法:比对 MD5 哈希(本工程 runbook 第 163-172 行的做法)。
步骤:
① 算构建产物的哈希
bash
certutil -hashfile lib\build\intermediates\cxx\Release\<hash>\obj\arm64-v8a\libai-chat.so MD5
注意
<hash>是 8 位目录名,会随 CMake 参数变化------别写死,用通配符或先查一下。
② 从 APK 里把同一个文件抠出来
bash
# PowerShell(Windows)
Add-Type -A System.IO.Compression.FileSystem
$z=[IO.Compression.ZipFile]::OpenRead((Resolve-Path $APK))
$e=$z.Entries | ? { $_.FullName -eq 'lib/arm64-v8a/libai-chat.so' }
[IO.Compression.ZipFileExtensions]::ExtractToFile($e,"$env:TEMP\chk.so",$true); $z.Dispose()
certutil -hashfile "$env:TEMP\chk.so" MD5
# 或者 macOS/Linux 一行搞定
unzip -p 离线AI-release.apk lib/arm64-v8a/libai-chat.so | md5sum
③ 两个哈希必须一致
为什么这个方法可靠?
- MD5 是内容哈希------内容变一个字节,哈希就不同;
- 所以"两个哈希相同" ⟺ "两边是同一个文件"。
这个方法能发现什么问题?
| 问题 | 为什么能发现 |
|---|---|
Gradle 用了缓存的旧 .so |
产物哈希等于旧版本的(你记得旧哈希的话) |
| 构建其实失败了,用的是上次的产物 | 时间戳/哈希对不上 |
| 校验时路径写错,看的是旧包 | 从 APK 抠出来的哈希是旧的 |
so 被误替换 |
哈希不同 |
更强的做法:
- 改代码前先记下旧哈希,改完再比------如果不一致就说明真的重新编了;
- 把关键
.so的哈希写进发版记录,作为"这一版长什么样"的指纹; - 对所有 native 库都做(不只
libai-chat.so),因为 llama.cpp 的库也可能没更新。
延伸:APK 本身也可以比对
bash
certutil -hashfile 离线AI-release.apk MD5
但注意 :APK 的 MD5 每次构建都可能不同 (因为签名有时间戳、压缩可能有随机性),
所以 APK 级别的哈希不适合做"内容一致性"验证 ------
要验证具体文件,就比具体文件的哈希。
💡 这个练习的价值 :
它让你从"我觉得应该进了"升级到"我能证明进了 "。
这是工程能力的一个分界线------
可验证的结论才是可靠的结论。
练习 4:keystore 丢了怎么办
假设你的密钥库真的丢了(电脑坏了、备份也没了)。请分析:
有哪些补救方案?各自代价是什么?
解题思路提示
用户已经装了旧签名的版本。你能让他们"无缝升级"吗?
应用商店会怎么处理?
参考答案要点
先说结论:如果密钥真的丢了,没有让老用户"无缝升级"的办法。
方案一:换新密钥,让用户卸载重装
- 做法:用新密钥签名,通过商店/官网发布,并公告"请卸载旧版后重新安装"。
- 代价:
- 用户必须手动卸载(清掉所有数据);
- 大量用户会流失(嫌麻烦);
- 应用商店可能直接拒绝(很多商店要求"签名一致才能更新",你可能只能下架重发)。
方案二:改包名(applicationId)重新发布
- 做法:换成
work.ai4easy.offlineai2,作为"新 App"发布。 - 代价:
- 用户数据完全不迁移;
- 商店里变成两个 App(旧的要下架);
- 排行榜、评论、下载量全部归零。
方案三(如果用了 Google Play 的 App Signing)
- 如果启用了 Play App Signing ,上传密钥 丢了可以找 Google 重置上传密钥
(因为真正的签名密钥在 Google 手里,你上传的只是"上传密钥")。 - 这是目前唯一能真正补救的方案 ------但需要你一开始就用了 App Signing。
所以正确的做法是"预防",而不是"补救":
| 措施 | 说明 |
|---|---|
| 多处备份 | 本工程 runbook 明确要求:"release-key.jks + keystore.properties 必须备份" |
| 备份要离线 | 不能只放在同一台电脑上(电脑坏了就一起没了) |
| 密码单独记 | properties 丢了还有密码能重建 |
| 不要提交版本库 | 提交了就等于"备份给了所有能看仓库的人"(安全风险) |
| 记录签名指纹 | 本工程把 SHA-256 写进 runbook,便于核对 |
本工程的具体做法(可作为模板):
release-key.jks+keystore.properties在llama.android/下;.gitignore排除它们;- runbook 里写明"必须备份"和签名指纹。
哲学层面:
密钥管理是"一次性投入、永久受益"的事 ------
花十分钟备份,能避免"整个 App 报废"这种不可逆事故。
也正是因为它不可逆 ,所以它比任何代码 bug 都严重 ------
代码 bug 可以改,密钥丢了没法改。
练习 5:写一个"一条命令出包 + 校验"的脚本
参考 21.6 和 21.7,写一个脚本,把"升版本 → 构建 → 校验 → 命名"串起来。
解题思路提示
注意几点:
① 版本号从哪读?(可以从 build.gradle.kts 里 grep)
② 校验失败要中断 ,不能继续;
③ 用 set -e(bash)或检查 %ERRORLEVEL%(Windows)。
参考答案要点
PowerShell 版本(📝 示例,需按你的环境调整):
powershell
param(
[string]$SdkDir = "C:\dev\Android\Sdk",
[string]$ProjDir = "llamacpptest\llama.android"
)
$ErrorActionPreference = "Stop"
$BT = "$SdkDir\build-tools\36.0.0"
Set-Location $ProjDir
# ① 从 build.gradle.kts 读版本号
$gradle = Get-Content "app\build.gradle.kts" -Raw
$code = [regex]::Match($gradle, 'versionCode\s*=\s*(\d+)').Groups[1].Value
$name = [regex]::Match($gradle, 'versionName\s*=\s*"([^"]+)"').Groups[1].Value
Write-Host "Building v$name (code $code) ..."
# ② 构建
& .\gradlew.bat :app:assembleRelease
if ($LASTEXITCODE -ne 0) { throw "BUILD FAILED" }
$apk = "app\build\outputs\apk\release\离线AI-release.apk"
if (-not (Test-Path $apk)) { throw "APK not found: $apk" }
# ③ 校验 ①:版本号 + ABI
$badging = & "$BT\aapt2.exe" dump badging $apk
if ($badging -notmatch "versionCode='$code'") { throw "versionCode mismatch!" }
if ($badging -notmatch "versionName='$name'") { throw "versionName mismatch!" }
if ($badging -notmatch "native-code: 'arm64-v8a'\s*$") { throw "ABI mismatch (expect arm64-v8a only)" }
Write-Host "[OK] version + ABI"
# ④ 校验 ②:签名
$sign = & "$BT\apksigner.bat" verify --verbose --print-certs $apk | Out-String
if ($sign -notmatch "v3 scheme.*true") { throw "v3 signature missing!" }
if ($sign -notmatch "CN=llama\.android") { throw "NOT signed with release key!" }
Write-Host "[OK] signature"
# ⑤ 校验 ⑤:testOnly
$tree = & "$BT\aapt2.exe" dump xmltree --file AndroidManifest.xml $apk | Out-String
if ($tree -match "testOnly") { throw "APK is marked testOnly!" }
Write-Host "[OK] not testOnly"
# ⑥ 命名 + 复制
$out = "app-release-v$name-code$code-signed.apk"
Copy-Item $apk $out -Force
Write-Host "DONE -> $out"
要点:
$ErrorActionPreference = "Stop"------ 任何错误都中断(关键);- 校验失败要
throw,不能"打印警告然后继续"; - 版本号从构建文件读,避免手写不一致;
- ABI 校验要精确 (
'arm64-v8a'后面应直接结束,不能还有别的); - 输出带
[OK]标记,便于快速扫一眼哪些检查过了。
建议再加两条:
- MD5 比对(21.7.5)------确认修复进包;
- 签名指纹对比 (21.7.3)------和记录的
bdef6b3a...比。
这个练习的价值:
它把"手动流程"变成"自动化 + 断言 "。
手动流程依赖"人记得每一步",自动化流程依赖"脚本会替你检查"。
一旦校验脚本化,你就再也不会"忘了跑某一条校验"------
而且校验失败会直接拦住发版,比"发出去才发现"强太多。
本工程已经有一份手动的 runbook (
运行命令.txt),把它脚本化就是很自然的下一步。
21.14 自测题(附答案)
一、判断对错
- 不签名的 APK 也能安装。
- keystore 丢了,可以用新密钥继续给老用户发布更新。
- 本工程
minSdk = 33,所以 v3 签名单独就够。 - 可以直接用 APK 的 MD5 来验证"内容一致性"。
二、选择
- 本工程实际生效 的签名方案是?
A. v1 B. v2 C. v3 D. v4 - 忘了升
versionCode会怎样?
A. 崩溃 B. 编译失败 C. 用户装不上更新 D. 无影响 - 要确认"修复真的进了包 ",应该比对什么?
A. APK 的 MD5 B. APK 内.so与构建产物的 MD5 C. 文件大小 D. 时间戳
三、简答
- 为什么说 keystore 丢了"就永远无法更新这个 App"?
- 为什么会出现"配置里写了
enableV2Signing = true,产物里却没有 v2"? - 发版校验的五条清单是哪五条?
答案与解析
一、判断
- ❌ 错 。从 Android 7(API 24)起,未签名的 APK 一律拒绝安装(21.1.2)。
- ❌ 错 。签名不同 → 系统认为"不是同一个 App " →
用户必须先卸载 (数据全丢)才能装新包;
应用商店也会拒绝"签名不一致的更新"(21.4.1)。 - ✅ 对 。
minSdk ≥ 28时全部目标设备都支持 v3,
所以 v3 单独足够(21.2.3)。 - ❌ 错 。APK 内部有签名时间戳 、压缩可能有随机性,
所以每次构建的 APK 哈希都可能不同 ,不适合做内容一致性验证。
要验证具体文件,就比具体文件的哈希(21 练习 3)。
二、选择
- C(v3) 。配置里 v2/v3 都开了,但 AGP 按
minSdk裁剪掉了 v2(21.3)。 - C 。系统的升级判定只看
versionCode(整数) ;
没升 → 系统认为"版本相同或更旧"→ 拒绝安装/跳过(21.6.1)。 - B 。把 APK 里的
.so抠出来,和构建产物的 MD5 比------
一致才说明"包里的就是我刚编出来的"(21.7.5)。
三、简答(要点)
- 因为系统靠签名判断"这是不是同一个 App" 。
换了密钥 → 老用户的设备认为"签名不符" → 拒绝覆盖安装 ;
用户只能卸载重装 (数据全丢)。
商店层面更严重:会拒绝"签名不一致的更新",你的 App 就再也无法更新了(21.4.1)。 - 因为 AGP 会按
minSdk自动裁剪 :
minSdk = 33(≥28)时,v3 完全覆盖了 v2 的保障 ,
所以 AGP 认为 v2 是冗余的 ,就不生成它。
→ 一般规律:配置是"意图",产物是"结果",工具会做优化 。
所以判断真实情况必须看产物(21.3)。 - 五条(21.7):
①aapt2 dump badging------ 版本号 / ABI / 应用名
②apksigner verify --print-certs------ 签名方案 + 签名者(必须是OU=Release)
③unzip -l | grep lib/------ ABI 切片是否完整(防半残)
④ MD5 比对.so------ 确认修复真的进了包
⑤aapt2 dump xmltree | grep testOnly------ 有没有被标记testOnly
五条全过,才叫"可以发"。
评分建议 :第 2、7、9 题是本章核心。
第 7 题(MD5 比对)是"可验证习惯"的具体体现------建议养成每次发版都做的习惯。
下一章 :ch22 诊断方法论:三个真实事故的根因分析。
这一章你把"发版"做成了一个可验证的流程 。
下一章回到"怎么定位问题 "------这是全书的方法论总结 :
把前面三个真实事故(多轮对话崩坏、长对话输出乱、错误提示看不见)
抽象成一套通用的排查框架:复现 → 隔离变量 → 最小对照实验 → 修正 → 回归验证 。
学完它,你就有了一套"遇到没见过的 bug 也能查"的方法。