先看结论与判断条件
- AAB 的结构正确不等于任意设备收到的 APK 组合正确,验收对象必须包含按设备规格派生的 base 与各类 split。
- 代表设备应覆盖真实用户中的 ABI、系统版本、语言、密度和条件模块边界,而不是只选一台最新旗舰设备。
- bundletool 运行必须固定版本、AAB 摘要、设备规格和签名类别,输出 APK set 与静态报告都要可追溯。
- 每组派生 APK 都应检查包名、versionCode、证书、base、feature、ABI、资源配置和文件摘要的一致性。
- 本地调试签名派生适合结构与流程检查,不能替代 Play App Signing、测试轨道交付和实际下载产物的身份回执。
- 上线结论必须把静态派生、安装升级、冷启动和关键业务路径分开记录;缺少真实候选时不能宣称商店兼容通过。
AAB 不是设备最终运行的文件
Android App Bundle format 将应用内容组织为 base、feature、配置和资产模块。发布平台根据设备 ABI、语言、屏幕密度和模块交付条件生成或选择 APK,设备安装的是这组派生产物。测试 AAB 容器能被构建工具解析,只说明上传输入具备基本结构,不能说明目标设备上的代码、资源和 Native 库组合正确。
派生阶段会暴露中间目录看不到的问题。例如某个第三方 SO 只进入一个 ABI split,语言资源依赖了未交付的 feature,或条件模块在边界设备上被错误选择。通用 APK 可能把所有资源打在一起而掩盖缺项,多文件安装则会按真实配置分离。发布门禁需要直接观察设备相关 APK set,而不是只阅读模块配置。
验收应把 AAB 定义为根候选,将每个 device_spec_digest 对应的 APK set 定义为派生候选。二者通过输入摘要、bundletool 版本和生成参数关联。静态、安装和设备回执都引用派生候选身份,避免把一组设备的通过结果复制到另一组 ABI 或系统版本。
| 对象 | 主要用途 | 上线前可检查 | 不能单独证明 |
|---|---|---|---|
| AAB | 向交付平台提供模块化发布输入 | 模块、资源、Manifest 和签名上传边界 | 设备实际安装集合正确 |
| APK set | 保存为一类设备生成的 APK 组合 | 成员、变体、摘要和签名类别 | 商店实际向用户交付同一字节 |
| base APK | 承载基础应用身份、代码和资源 | 包名、版本、清单和依赖入口 | 配置 split 全部齐全 |
| 配置 split | 承载 ABI、语言或密度内容 | 选择条件、文件内容和归属模块 | 所有设备都会安装该 split |
| feature split | 承载动态或条件功能模块 | 交付条件、依赖和资源闭合 | 按需状态机与业务路径正确 |
| 设备安装集合 | 形成特定设备的实际包状态 | 安装前后 split、版本和证书 | 未测试设备同样兼容 |
先建立能解释覆盖范围的代表设备清单
代表设备不是随意挑几台机器,而是把用户范围转换成可辩护的配置组合。清单至少记录设备型号或规格类别、Android 版本、ABI、屏幕密度、locale、硬件特性和相关模块条件。选择理由要能对应用户占比、最低支持范围、高风险依赖或历史故障,不应只以云平台当前有空闲设备为准。
Firebase Test Lab Android matrices 将一次矩阵表示为设备型号、OS、方向和 locale 等组合,任一执行失败都会影响矩阵结论。设备目录与容量会变化,因此计划中要保存请求的配置和实际执行回执。若某个目标型号不可用,可以选择具备相同关键条件的替代设备,但必须记录差异,不能悄悄把缺口标成通过。
对于派生 APK,ABI 与系统版本通常优先级最高,语言和密度用于覆盖资源 split,条件 feature 则需要命中和不命中两侧样本。设备清单应避免笛卡尔积失控,可使用成对覆盖与风险加权压缩数量,但每个发布声明都必须限定到实际样本。没有覆盖的厂商特性、页面尺寸或系统边界应列为待验证。
| 维度 | 影响的派生内容 | 最低样本原则 | 缺口记录 |
|---|---|---|---|
| ABI | Native 配置 split 与可执行库 | 每个正式支持 ABI 至少一组 | 未测试架构不得宣称通过 |
| Android 版本 | 平台 API、签名方案与安装行为 | 最低、主流和目标新版本 | 系统边界单独列出 |
| locale | 语言资源 split | 默认语言和关键市场语言 | 回退行为需明确 |
| 屏幕密度 | 密度资源 split | 覆盖主要资源分档 | 仅结构检查不等于视觉通过 |
| 硬件特性 | 条件 feature 或能力模块 | 命中与不命中各有样本 | 替代设备差异可追溯 |
| 页面尺寸 | Native 库链接与打包兼容 | 按支持目标选取代表环境 | 静态检查与运行证据分开 |
| 设备来源 | 云真机、实验室与真实用户条件 | 关键路径尽量交叉验证 | 容量变化不改写原计划 |
让 bundletool 派生过程可重现
Build and test Android App Bundles 与 bundletool 文档提供从 AAB 构建 APK set、获取设备规格、按设备选择和安装 APK 的路径。发布流水线应固定 bundletool 版本、Java 运行环境、输入 AAB SHA-256、设备规格文件、输出路径和签名类别。相同输入重复生成后若成员或摘要变化,应先解释工具或参数差异。
设备规格文件本身也是证据输入。自动采集的规格要绑定来源设备和采集时间,人工维护的规格要经过 schema 校验并记录责任人。不要在生成失败时删掉不支持字段继续尝试,因为这会改变派生条件。输出 APK set 需要保存整体摘要、成员清单和每个 APK 摘要,后续测试只接受该集合。
bundletool 在未提供发布签名信息时可能使用调试签名。为了避免在公开脚本和日志中处理生产密钥,结构预检可以采用调试签名并明确标记 evidence_tier;正式候选则应在受控签名环境生成或从测试轨道获取。两类结果不能合并为一个“签名通过”,也不能用本地证书替代用户最终获得的应用签名证书。
- 保存输入 AAB 的 SHA-256 与版本身份
- 固定 bundletool 和 Java 运行环境版本
- 每个设备规格文件都有摘要与选择理由
- 输出 APK set 和内部所有 APK 分别求摘要
- 调试签名派生与发布签名候选明确分级
- 生成失败不通过删字段或换参数静默绕过
- 所有静态和设备测试引用同一派生集合
逐组核对模块、ABI、资源和签名
静态验收先确认每个 APK set 至少有 base,并且所有成员的包名、versionCode 和签名身份符合候选策略。随后按设备规格检查 ABI、语言、密度和 feature 成员是否与选择结果一致。文件名只用于定位,真实身份要从 APK 内 Manifest、splitName、签名报告和摘要读取。未知、多余或重复成员都应阻止后续安装。
ABI 检查不能只看目录名称。需要枚举每个派生 APK 中的 lib/<abi>/*.so,与产品支持矩阵和依赖清单比较;对于无 Native 库的应用,也要确认没有第三方依赖意外引入架构限制。资源检查要验证目标 locale 和密度的资源 split 存在,并通过安装后的界面与关键资源路径确认引用闭合。
模块依赖需要从实际派生结果反查。安装时 feature 是否进入目标集合、按需 feature 是否保持独立、feature 的配置 split 是否随其交付,都应在清单中可见。静态成员正确只说明交付结构可辩护,不证明模块状态机、首次加载或卸载流程正确;相关业务路径仍需设备回归。
| 门禁 | 检查输入 | 合格条件 | 后续证据 |
|---|---|---|---|
| base 身份 | Manifest、版本和摘要 | 恰好一个预期 base | 安装后包状态 |
| 签名一致性 | 全部成员签名验证报告 | 符合当前证书和连续性策略 | 测试轨道实际证书 |
| ABI 选择 | 设备 ABI 与 SO 清单 | 只含目标可用架构且依赖闭合 | Native 装载回归 |
| 语言选择 | locale 与资源 split | 目标语言或明确回退存在 | 界面和业务文案检查 |
| 密度选择 | 设备密度与资源 split | 目标资源档位合理 | 视觉与资源加载检查 |
| feature 选择 | 交付条件与模块成员 | 安装时、条件和按需边界符合设计 | 模块状态机回归 |
| 文件摘要 | APK set 元数据与实际文件 | 每个成员字节可追溯 | 下游测试防错配 |
安装、升级和启动路径要分开验收
派生 APK 的静态集合通过后,至少执行全新安装、从当前正式版本升级和冷启动。全新安装验证 base 与配置 split 能形成有效包状态;升级验证包名、versionCode、签名连续性和旧 split 处理;冷启动验证 Application、Provider、资源与 Native 初始化。只看到安装命令成功或首页出现,不能代表关键功能路径闭合。
条件与动态 feature 需要额外路径。安装时 feature 在满足和不满足条件的规格上都应观察;按需 feature 要覆盖未安装、请求中、安装后、进程重启和失败重试。派生 APK 验收可以确认模块成员是否正确生成,但不替代这些状态转换。测试用例应关联模块名、设备规格、APK set 摘要和应用最终状态。
升级测试必须使用真实可接受的基线。临时 Debug 包与发布候选可能签名不连续,覆盖安装成功也可能依赖清除数据。应从受控归档或测试轨道取得基线,记录安装前后的版本、证书、split 列表和应用数据结果。若某次测试通过了清除数据的全新安装,报告不能将其标成升级通过。
| 路径 | 前置状态 | 必须观察 | 常见误判 |
|---|---|---|---|
| 全新安装 | 设备无该应用 | 全部 split、版本、签名与首次启动 | 只记录安装工具退出码 |
| 正式基线升级 | 已安装受控旧版本 | 更新连续性、数据与旧 split 处理 | 清除数据后当作升级 |
| 冷启动 | 目标集合已安装且进程未运行 | Application、Provider、资源和 Native 初始化 | 进入首页即认为全部功能正常 |
| 语言切换 | 目标语言资源已派生 | 重启与配置变化后的资源闭合 | 只看系统设置变化 |
| 条件 feature | 命中与不命中设备各一组 | 成员选择和 base 降级 | 未交付即默认测试通过 |
| 按需 feature | 初始未安装模块 | 请求、安装、重启、失败和重试 | 模块页面打开一次即可 |
| 回滚恢复 | 新候选失败且旧候选可用 | 明确回滚策略和数据兼容 | 直接覆盖旧文件不留证据 |
区分本地签名、Play App Signing 与交付回执
Sign your Android app 文档区分应用签名密钥、上传密钥和 Play App Signing 责任。上传 AAB 可以由上传密钥签名,用户最终获得的 APK 则由应用签名密钥处理。上线前报告应明确每个 APK set 的签名阶段:本地 Debug 结构派生、受控发布签名候选、测试轨道下载样本或正式交付样本。
本地派生无法完整模拟商店处理。即使模块和 ABI 选择符合预期,最终签名证书、派生产物字节与交付规则仍需要外部回执确认。最稳妥的流程是本地门禁尽早发现结构问题,再上传到受控测试轨道,从代表设备下载实际产物并重跑身份、成员和安装检查。只有真实回执存在,才能把证据等级提升为交付验证。
不要把生产密钥放进公开脚本或普通 CI 日志。正式签名派生应在隔离签名环境完成,输出只保存证书摘要、签名方案、候选摘要和验证结果。签名验证成功不证明模块选择或运行兼容;同样,设备测试通过也不能证明签名材料管理正确。两条证据链最终通过相同派生身份关联。
- 上传 AAB 身份与上传密钥责任明确记录
- 本地 Debug APK set 仅用于结构与流程支持证据
- 正式候选不在公开脚本中暴露签名材料
- 测试轨道下载样本重新提取证书和摘要
- 商店样本与本地派生差异有专门比对记录
- 签名、模块选择与运行兼容分别判定
- 缺少外部回执时不宣称生产交付通过
用脚本为代表设备生成并检查 APK set
下面的 Python 示例接收 bundletool JAR、候选 AAB、代表设备计划 JSON 和输出目录。计划中每个设备条目包含安全名称、device_spec 路径和 required_modules。脚本为每个规格运行 bundletool build-apks,检查输出归档中存在 APK、base 和要求模块,并生成包含 AAB、规格与 APK set 摘要的静态报告。
示例未提供 keystore 参数,因此生成结果只适合本地结构检查,不能冒充正式发布签名候选。required_modules 通过 APK set 中的成员名称做预检,生产门禁还应解压实际 APK,解析 Manifest、splitName、ABI、资源和签名。脚本不会安装应用,也不会调用云真机,因此输出不是设备兼容或商店交付回执。
代码会创建独立输出文件,但不会修改输入 AAB、设备规格或其他候选。所有输入路径来自命令行和计划文件,缺失、重复、命令失败、归档损坏、没有 base 或要求模块缺失都会非零退出。输出目录应属于本次受控构建,发布流水线再把报告与后续设备矩阵结果关联。
#!/usr/bin/env python3
import hashlib
import json
import re
import subprocess
import sys
import zipfile
from pathlib import Path
SAFE_NAME = re.compile(r"^[a-z0-9][a-z0-9_-]{1,48}$")
def sha256(path):
digest = hashlib.sha256()
with path.open("rb") as stream:
for block in iter(lambda: stream.read(1024 * 1024), b""):
digest.update(block)
return digest.hexdigest()
def fail(message, code):
print(message, file=sys.stderr)
raise SystemExit(code)
def load_plan(path):
try:
value = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
fail("cannot read plan: " + str(exc), 3)
if not isinstance(value, dict) or not isinstance(value.get("devices"), list):
fail("invalid device plan", 4)
return value
def build_for_device(jar, bundle, output_dir, device):
name = device.get("name")
if not isinstance(name, str) or not SAFE_NAME.fullmatch(name):
fail("unsafe device name", 5)
spec = Path(device.get("device_spec", ""))
if not spec.is_file():
fail("device spec missing: " + name, 6)
required = device.get("required_modules")
if not isinstance(required, list) or not required or not all(isinstance(x, str) for x in required):
fail("invalid required_modules: " + name, 7)
if len(required) != len(set(required)):
fail("duplicate required module: " + name, 8)
output = output_dir / (name + ".apks")
command = [
"java", "-jar", str(jar), "build-apks",
"--bundle=" + str(bundle),
"--output=" + str(output),
"--device-spec=" + str(spec),
"--overwrite",
]
try:
subprocess.run(command, check=True)
except (OSError, subprocess.CalledProcessError) as exc:
fail("bundletool failed for " + name + ": " + str(exc), 9)
try:
with zipfile.ZipFile(output) as archive:
members = sorted(x for x in archive.namelist() if x.endswith(".apk"))
except (OSError, zipfile.BadZipFile) as exc:
fail("invalid APK set for " + name + ": " + str(exc), 10)
if not members or not any("base" in Path(x).name for x in members):
fail("base APK missing: " + name, 11)
for module in required:
if not any(module in Path(x).name for x in members):
fail("required module missing: " + module, 12)
return {
"name": name,
"device_spec_sha256": sha256(spec),
"apk_set_sha256": sha256(output),
"apk_members": members,
"evidence_tier": "local-structure-only",
}
def main():
if len(sys.argv) != 5:
fail("Usage: derive_apks.py BUNDLETOOL_JAR AAB PLAN_JSON OUTPUT_DIR", 2)
jar = Path(sys.argv[1])
bundle = Path(sys.argv[2])
plan_path = Path(sys.argv[3])
output_dir = Path(sys.argv[4])
if not jar.is_file() or not bundle.is_file() or not plan_path.is_file():
print("required input file missing", file=sys.stderr)
raise SystemExit(2)
output_dir.mkdir(parents=True, exist_ok=True)
plan = load_plan(plan_path)
devices = plan["devices"]
if not devices:
fail("device plan is empty", 13)
names = [item.get("name") for item in devices if isinstance(item, dict)]
if len(names) != len(devices) or len(names) != len(set(names)):
fail("device names must be unique", 14)
results = [build_for_device(jar, bundle, output_dir, item) for item in devices]
report = {"aab_sha256": sha256(bundle), "devices": results}
print(json.dumps(report, sort_keys=True, indent=2, ensure_ascii=False))
if __name__ == "__main__":
main()用派生、设备和轨道三层证据做发布决定
发布证据包先固定 AAB 摘要,再按代表设备保存 device_spec、bundletool 参数、APK set 摘要、成员清单和静态门禁。设备层保存实际安装集合、签名、版本、冷启动与关键业务结果。轨道层保存上传身份、平台处理结果和下载样本。每条记录都能反查根 AAB 和派生候选,才不会把不同设备或签名阶段的结果混在一起。
负向用例应验证门禁真的会挡住不完整派生。可以使用公开安全测试应用,删除要求模块、替换 ABI split、混入另一 APK set 的成员、修改设备规格、使用错误签名类别或让安装中断,确认静态与设备门禁在预期层失败。报告只公开失败类别和恢复原则,不需要暴露生产包名、证书、内部路径或真实设备标识。
当前没有具体 AAB、设备规格、APK set、测试矩阵和轨道下载回执,因此本文只能给出方法和代码,不能宣称某个 AAB 已通过商店上线或全部设备兼容。项目实施时,AAB、bundletool、签名、模块交付规则或代表设备清单变化都应触发重新派生与重验,并保留未覆盖范围。
| 证据层 | 最低内容 | 合格条件 | 不能推出 |
|---|---|---|---|
| 根候选 | AAB 摘要、版本和上传身份 | 所有派生记录引用同一 AAB | 设备实际安装正确 |
| 代表设备 | 规格摘要、选择理由和覆盖范围 | 关键 ABI、系统与配置边界可解释 | 全部用户设备覆盖 |
| 本地派生 | bundletool 版本、参数和 APK set 摘要 | 成员与设备规格一致 | Play 实际交付相同 |
| 静态门禁 | base、feature、ABI、资源、版本和签名类别 | 无缺失、混搭或未知成员 | 应用运行兼容 |
| 设备回归 | 安装、升级、启动和关键路径 | 同一派生候选在指定环境通过 | 其他环境同样通过 |
| 测试矩阵 | 实际型号、OS、locale 与执行结果 | 全部要求样本有真实回执 | 云目录覆盖所有厂商差异 |
| 测试轨道 | 平台处理与下载样本身份 | 实际证书和成员重新核对 | 正式用户已全部收到 |
| 变化触发器 | AAB、工具、签名、模块规则和设备计划 | 任何变化都重新派生与重验 | 旧证据可自动迁移 |
事实依据与适用边界
以下内容区分官方事实、本文工程判断和不能外推的范围,避免把设计建议写成未经验证的产品结论。
| 本文判断 | 事实或工程依据 | 适用限制 |
|---|---|---|
| Android App Bundle 可以通过 bundletool 和测试流程构建 APK set、按设备配置选择 APK 并执行安装验证。 | Build and test Android App Bundles | 本地生成和测试不能完全替代 Play 平台处理、签名和真实交付回执。 |
| bundletool 可以从 AAB 生成、检查并部署按设备配置选择的 APK set。 | bundletool | 未显式提供发布签名信息时的本地结果可能使用调试签名,不能冒充正式候选。 |
| AAB 由 base、feature、配置与资产模块组成,设备最终安装的是按配置派生的 APK 集。 | Android App Bundle format | AAB 容器结构正确不证明任意设备派生集合和业务路径正确。 |
| Android 发布需要区分应用签名密钥、上传密钥、证书和 Play App Signing 场景中的责任。 | Sign your Android app | 发布文档不能确认某个实际 APK set 已使用项目批准的生产证书。 |
| Firebase Test Lab Android 测试矩阵由设备、系统、方向和 locale 等维度组成,执行结果需要按矩阵判断。 | Firebase Test Lab Android matrices | 云测试矩阵必须依据真实用户和最低支持范围设计,平台执行不等于业务场景自动覆盖。 |
| Test Lab 可用设备目录、稳定性和容量会变化,测试计划需要记录实际执行的型号与系统版本。 | Test Lab available devices | 设备在目录中可用不代表它覆盖目标应用的全部厂商特性和硬件差异。 |
| 工程判断:上传 AAB 应为每个代表设备生成可追溯 APK set,并分别完成静态、安装和关键路径验收。 | 工程判断:基于设备定向交付、派生产物身份和证据分层原则 | 代表设备数量、模块门禁与轨道策略需要按项目用户范围和风险确定。 |
| 当前没有具体 AAB、设备规格、APK set、测试矩阵和轨道回执,不能断言任何候选已完成上线验收。 | 项目证据尚未接入 | 文章仅提供公开安全的方法、代码和证据矩阵,不包含客户案例、性能数字或实测通过结论。 |
工程常见问题
AAB 能成功上传,为什么还要检查派生 APK?
上传校验只覆盖 AAB 作为交付输入的部分结构。用户运行的是平台按设备 ABI、语言、密度和模块条件派生的 APK 集。只有检查实际成员并在代表设备安装,才能发现缺少 SO、资源错配、feature 选择和 split 安装路径问题。
使用 universal APK 测试是否可以替代设备相关 APK set?
不能完全替代。universal APK 适合快速功能支持检查,但会把多种资源和架构合并,可能掩盖真实 split 选择和多文件安装问题。发布验收应至少为关键设备规格生成定向 APK set,并保留成员清单和设备回执。
本地 bundletool 生成结果与 Play 下载结果有什么区别?
本地结果受工具版本、设备规格和签名参数影响,未提供发布签名时还可能使用调试证书。Play 会执行自己的处理、应用签名和交付选择。应把本地结果当作前置门禁,再从测试轨道下载样本复核证书、成员和运行。
代表设备只覆盖不同 Android 版本是否足够?
通常不够。派生结果还受 ABI、locale、密度、硬件特性和条件模块影响。应从真实用户范围和高风险依赖选择配置组合,并明确未覆盖的厂商特性。云平台可用什么设备不能反向决定产品支持范围。
静态检查派生 APK 后还需要哪些设备测试?
至少包括全新安装、从受控正式基线升级、冷启动和关键业务路径。存在条件或按需 feature 时,还要覆盖命中与不命中、下载、安装、重启和失败重试。所有结果必须绑定同一 APK set 摘要和设备规格。
怎样证明测试矩阵真正使用了计划中的派生 APK?
为每个 device_spec 保存 APK set 摘要和内部成员摘要,上传测试前重算,执行回执记录实际设备型号、OS、locale 和候选身份。测试轨道样本还需重新提取包名、版本和证书,任何身份不一致都不能聚合结果。