先看结论与判断条件

  • 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 与派生 APK 承担的不同角色
对象主要用途上线前可检查不能单独证明
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 则需要命中和不命中两侧样本。设备清单应避免笛卡尔积失控,可使用成对覆盖与风险加权压缩数量,但每个发布声明都必须限定到实际样本。没有覆盖的厂商特性、页面尺寸或系统边界应列为待验证。

代表设备清单的选择维度
维度影响的派生内容最低样本原则缺口记录
ABINative 配置 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 是否随其交付,都应在清单中可见。静态成员正确只说明交付结构可辩护,不证明模块状态机、首次加载或卸载流程正确;相关业务路径仍需设备回归。

每个派生 APK set 的静态门禁
门禁检查输入合格条件后续证据
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 列表和应用数据结果。若某次测试通过了清除数据的全新安装,报告不能将其标成升级通过。

派生 APK 的设备回归路径
路径前置状态必须观察常见误判
全新安装设备无该应用全部 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 或要求模块缺失都会非零退出。输出目录应属于本次受控构建,发布流水线再把报告与后续设备矩阵结果关联。

代表设备 APK set 生成与静态成员检查器
#!/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 上线前的派生 APK 证据矩阵
证据层最低内容合格条件不能推出
根候选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 formatAAB 容器结构正确不证明任意设备派生集合和业务路径正确。
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 和候选身份。测试轨道样本还需重新提取包名、版本和证书,任何身份不一致都不能聚合结果。

想用自己的 App 验证?

提交候选包、目标系统和关键业务路径,申请御盾 PoC 与兼容性评估。

继续阅读: APK、DEX 与签名完整性如何一起验收