ch21 签名、校验与发版

学习目标

读完本章,你应当能够:

  1. 说清"为什么 Android 一定要签名",以及 v1 / v2 / v3 / v4 四种方案的区别;
  2. 解释签名方案与 minSdk 的关系,以及本工程"配置里写了 v2、产物却只有 v3"为什么正常;
  3. 理解 keystore 丢失 = 永远无法更新这个 App,并知道该备份什么;
  4. 看懂本工程的 signingConfigs(含一处因 Kotlin DSL 限制而手写的解析代码);
  5. 独立跑完一遍完整发版流程:改版本号 → 构建 → 校验 → 命名 → 归档;
  6. 会用 apksigner verify / aapt2 dump badging 校验一个 APK;
  7. 掌握"如何确认一次修复真的进了包" ------比对 APK 内 .so 与构建产物的 MD5 哈希
  8. 知道覆盖安装的常见坑,避免"用户升级后崩在旧代码上"。

前置 :ch20(构建体系)

对应源码 / 文档

  • app/build.gradle.kts:41-69signingConfigs
  • keystore.properties(项目根,已 gitignore,切勿提交
  • 运行命令.txt(工程根的发版 runbook,本章大量引用它的实测输出)
  • 校验工具:$ANDROID_SDK/build-tools/36.0.0/apksigner.bataapt2.exe
    ⚠️ 安全声明

本章只讲签名机制与命令 。真实的密钥库密码、别名密码、密钥文件内容属于机密

保存在项目根 keystore.properties(已在 .gitignore 中)。

本书任何位置都不会写出真实密码。

你也应当把它们当作"比源码更重要的资产"对待------原因见 21.4。
🧭 读本章前,请先确认

  • 前置:ch20(知道 APK 是怎么来的)。
  • 需要的基础 :知道"哈希 / 指纹 "是什么
    (一句话:内容的"指纹",内容变一个字节,指纹就不同);会用命令行。
  • 本章会出现的生词 :签名、keystore 、v1/v2/v3/v4 方案、versionCode
    apksigneraapt2testOnly、覆盖安装。一起查 附录 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)。它的工作方式分两步:

  1. 你发布时 :用你自己保管的一把"私钥"(private key,只有你知道的秘密钥匙)给 App 算一个专属签名;
  2. 用户安装时 :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 里是怎么写的:

kotlin 复制代码
android {
    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.jksPKCS12 格式 ,而 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.42.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'

看三件事

  1. versionCode / versionName 是不是你改的那一版;
  2. native-code 只列出 arm64-v8a(ch20 讲的半残切片就靠这条发现);
  3. 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 更方便:

bash 复制代码
unzip -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)
apksignerDOES 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 做一次"修复是否进包"的验证(推荐)

  1. ai_chat.cpp 的某个 LOGi 里改一个字(比如加个标记);
  2. gradlew :app:assembleRelease
  3. 按 21.7.5 比对 libai-chat.so 的 MD5;
    • MD5 应该变了(因为你改了代码);
    • 然后从 APK 里抠出来的也应该是同一个新 MD5
  4. 把改动改回去。

这个实验能让你彻底掌握"确认修复进包"的方法 ------

以后每次发版你都会习惯性地做这一步。

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("=")
  • 我说得出 versionCodeversionName 的区别,以及忘了升前者会怎样。
  • 我能独立跑完五条校验,并逐条判断结果是否正常。
  • 我会用 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 被误替换 哈希不同

更强的做法

  1. 改代码前先记下旧哈希,改完再比------如果不一致就说明真的重新编了;
  2. 把关键 .so 的哈希写进发版记录,作为"这一版长什么样"的指纹;
  3. 所有 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.propertiesllama.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"

要点

  1. $ErrorActionPreference = "Stop" ------ 任何错误都中断(关键);
  2. 校验失败要 throw,不能"打印警告然后继续";
  3. 版本号从构建文件读,避免手写不一致;
  4. ABI 校验要精确'arm64-v8a' 后面应直接结束,不能还有别的);
  5. 输出带 [OK] 标记,便于快速扫一眼哪些检查过了。

建议再加两条

  • MD5 比对(21.7.5)------确认修复进包;
  • 签名指纹对比 (21.7.3)------和记录的 bdef6b3a... 比。

这个练习的价值

它把"手动流程"变成"自动化 + 断言 "。

手动流程依赖"人记得每一步",自动化流程依赖"脚本会替你检查"

一旦校验脚本化,你就再也不会"忘了跑某一条校验"------

而且校验失败会直接拦住发版,比"发出去才发现"强太多。

本工程已经有一份手动的 runbook运行命令.txt),

把它脚本化就是很自然的下一步。


21.14 自测题(附答案)

一、判断对错

  1. 不签名的 APK 也能安装。
  2. keystore 丢了,可以用新密钥继续给老用户发布更新。
  3. 本工程 minSdk = 33,所以 v3 签名单独就够
  4. 可以直接用 APK 的 MD5 来验证"内容一致性"。

二、选择

  1. 本工程实际生效 的签名方案是?
    A. v1 B. v2 C. v3 D. v4
  2. 忘了升 versionCode 会怎样?
    A. 崩溃 B. 编译失败 C. 用户装不上更新 D. 无影响
  3. 要确认"修复真的进了包 ",应该比对什么?
    A. APK 的 MD5 B. APK 内 .so 与构建产物的 MD5 C. 文件大小 D. 时间戳

三、简答

  1. 为什么说 keystore 丢了"就永远无法更新这个 App"?
  2. 为什么会出现"配置里写了 enableV2Signing = true,产物里却没有 v2"?
  3. 发版校验的五条清单是哪五条?

答案与解析

一、判断

  1. 。从 Android 7(API 24)起,未签名的 APK 一律拒绝安装(21.1.2)。
  2. 。签名不同 → 系统认为"不是同一个 App " →
    用户必须先卸载 (数据全丢)才能装新包;
    应用商店也会拒绝"签名不一致的更新"(21.4.1)。
  3. minSdk ≥ 28 时全部目标设备都支持 v3,
    所以 v3 单独足够(21.2.3)。
  4. 。APK 内部有签名时间戳 、压缩可能有随机性,
    所以每次构建的 APK 哈希都可能不同 ,不适合做内容一致性验证。
    要验证具体文件,就比具体文件的哈希(21 练习 3)。

二、选择

  1. C(v3) 。配置里 v2/v3 都开了,但 AGP 按 minSdk 裁剪掉了 v2(21.3)。
  2. C 。系统的升级判定只看 versionCode(整数)
    没升 → 系统认为"版本相同或更旧"→ 拒绝安装/跳过(21.6.1)。
  3. B 。把 APK 里的 .so 抠出来,和构建产物的 MD5 比------
    一致才说明"包里的就是我刚编出来的"(21.7.5)。

三、简答(要点)

  1. 因为系统靠签名判断"这是不是同一个 App"
    换了密钥 → 老用户的设备认为"签名不符" → 拒绝覆盖安装
    用户只能卸载重装 (数据全丢)。
    商店层面更严重:会拒绝"签名不一致的更新",你的 App 就再也无法更新了(21.4.1)。
  2. 因为 AGP 会按 minSdk 自动裁剪
    minSdk = 33(≥28)时,v3 完全覆盖了 v2 的保障
    所以 AGP 认为 v2 是冗余的 ,就不生成它。
    → 一般规律:配置是"意图",产物是"结果",工具会做优化
    所以判断真实情况必须看产物(21.3)。
  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 也能查"的方法。

相关推荐
JMchen2 小时前
属性动画原理与高级动画实现
android·kotlin·canvas
Android打工仔2 小时前
Kotlin 协程源码解析:协程是如何切换线程的?
android·kotlin
ai2work4 小时前
附录 D 速查索引与阅读指南
kotlin
ai2work4 小时前
ch22 诊断方法论:三个真实事故的根因分析
kotlin
Kapaseker5 小时前
你有搞明白 Volatile 什么意思吗?
android·kotlin
alexhilton14 小时前
藏在设备上的秘密,终究藏不住
android·kotlin·android jetpack
ai2work19 小时前
ch15 加载模型与初始化上下文
kotlin
hai_android20 小时前
Kotlin / Android 常用函数使用示例手册
android·java·kotlin
hai_android21 小时前
Android MeasureSpec 详解
android·java·kotlin