先看结论与判断条件
- 上传 AAB 或 APK 的文件大小、设备实际获得的 split 集合、安装会话暂存需求和安装后占用是不同指标,不能用一个数字替代。
- 每条低存储回执必须绑定 applicationId、versionCode、签名证书、候选摘要、派生 APK set 摘要和设备配置,避免把不同包的结果混在一起。
- 空间档位应围绕产品明确支持的最低可用空间和当前候选估算需求建立,不应凭经验写一个适用于所有设备的固定阈值。
- PackageInstaller 状态码只能说明安装会话的结果类别;失败阶段、系统消息、设备状态、清理复测和原始包对照共同决定是否与加固相关。
- AAB 发布需要同时验证本地 bundletool 派生集和受控测试轨道交付,调试签名生成的 APK set 不能冒充发布候选。
- 门禁至少区分阻断、需要复核和边界内通过,并把支持空间内失败、身份不一致、回执缺失及清理后仍失败列为明确阻断条件。
门禁先回答支持边界内能否完成安装
低存储测试的目标不是证明应用在任何剩余空间下都能安装,而是验证产品公开支持边界内,发布候选能否完成下载后的安装、更新和首次启动。团队需要先声明支持的最低可用空间条件、覆盖的安装方式以及是否包含升级路径。没有业务支持边界,测试只能得到零散现象,无法决定该阻断发布还是提示用户清理空间。
门禁需要两个对照端点。充足空间档位用于证明候选、签名、split 和测试流程本身可用;接近支持边界的档位用于观察空间压力。若充足空间也失败,应先查候选身份、签名、版本、设备兼容和安装流程,不能标成低存储问题。若仅低档失败,仍要清理设备并复测同一候选,排除残留会话、旧版本数据和临时文件影响。
发布结论应面向具体渠道和候选。直接分发单 APK、企业分发 split APK、应用商店 AAB 派生交付会产生不同文件集合和安装路径。一次 USB 安装成功不能替代商店测试轨道,一次本地失败也不能代表所有线上派生包。门禁记录必须写清 delivery_mode、安装工具、是否更新安装、当前版本和目标版本。
| 容量对象 | 实际含义 | 取得方式 | 不能推出的结论 |
|---|---|---|---|
| 上传包大小 | 提交渠道的 AAB 或单 APK 文件字节 | 对最终上传文件计算摘要和大小 | 不等于设备下载量或安装所需空间 |
| 派生 APK 集 | 设备配置对应的 base 与 split 文件集合 | 由交付渠道或 bundletool 生成并清点 | 不等于系统安装会话峰值 |
| 安装会话空间 | PackageInstaller 暂存、校验和提交所需空间 | 记录会话前后空间与阶段状态 | 不能只从压缩文件大小精确推算 |
| 安装后占用 | 代码、资源、优化产物和应用数据占用 | 在安装完成及首次启动后测量 | 不代表安装前所需临时余量 |
| 设备可用空间 | 测试时文件系统报告的剩余容量 | 每轮安装前后从同一设备采集 | 不能跨设备直接等同存储压力 |
| 支持空间边界 | 产品承诺测试与支持的最低条件 | 由发布策略版本化声明 | 不是 Android 对所有应用的统一阈值 |
把候选身份、更新身份和派生集绑在一起
安装回执首先要回答测的是哪个包。最低记录包括 applicationId、versionCode、签名证书摘要、上传产物摘要、构建变体、构建时间和渠道。更新测试还要记录设备已安装版本的相同字段。Android 更新要求应用身份、可接受的版本号以及兼容签名或有效轮换证明满足平台条件,因此签名冲突或版本冲突不能归到存储空间。
AAB 场景还要记录派生 APK set 身份。不同设备的 ABI、屏幕密度、语言和 SDK 条件可能得到不同 split 组合;base 与各 split 需要保持包名、versionCode 与签名证书一致。测试若只保存上传 AAB 摘要,却不保存设备实际安装的文件清单和摘要,出现失败时无法判断是空间压力、派生错误还是 split 身份不一致。
候选身份不能靠文件名维持。release-final.aab、release-final-2.aab 和聊天工具中的重命名文件都可能指向不同字节。门禁应以摘要和签名身份为准,并把构建证明、派生参数和测试结果绑定到相同 subject。任何人工重新签名、重新生成或换包复测都产生新候选,旧结果只能保留为历史对照,不能继续标记当前候选通过。
- 记录上传 AAB 或 APK 的 SHA-256 摘要与文件大小
- 记录 applicationId、versionCode、variant 和签名证书摘要
- 更新场景同时登记设备上旧版本的应用身份
- AAB 场景保存设备规格与实际 base、split 清单
- 每个派生 APK set 具有独立摘要和生成参数
- 任何换包、重签或重新派生都会创建新的测试对象
空间档位围绕支持条件设计,不猜通用阈值
发布策略应提供 minimum_supported_free_bytes 或等价的可审核条件,并说明它在安装前何时测量。测试可以围绕该边界建立充足空间、边界以上、边界附近和边界以下档位,但具体字节值来自产品承诺与候选测量,不从其他应用照搬。大体积资源、Native 库、split 数量和系统实现都会改变空间需求,单一固定倍数也不能代表所有设备。
每个档位要从可复现的设备状态开始。先卸载或保留旧版本要与用例一致,清除已放弃的安装会话,恢复指定数据快照,再通过受控填充文件把可用空间调整到目标范围。填充文件只服务测试环境,不能覆盖系统目录或用户真实数据。达到档位后立即记录 available_before、测量来源与时间,避免后台清理或同步任务悄悄改变条件。
低于支持边界的测试仍有价值,它可以验证错误提示、恢复路径和用户清理后的再次安装,但不应与支持边界内的发布通过率混为一项。若低于边界失败、清理后在边界以上成功,可以记录为边界行为;若边界以上仍稳定失败,则进入发布阻断。没有声明边界或无法稳定构造档位时,门禁应标记证据不足,而不是默认放行。
| 档位 | 测试目的 | 必要观察 | 门禁含义 |
|---|---|---|---|
| 充足空间控制档 | 证明候选和安装流程基本可用 | 会话成功、身份正确、首次启动可达 | 失败时先阻断并排查非空间因素 |
| 支持边界以上档 | 验证公开支持条件 | 前后空间、阶段、状态码、同候选复测 | 稳定失败直接阻断 |
| 支持边界附近档 | 观察临界行为和测量波动 | 多次重置、实际 split 集、结果稳定性 | 不确定时阻断并补证 |
| 支持边界以下档 | 验证提示、清理和恢复体验 | 失败类别、清理动作、再次安装 | 不单独证明候选不可发布 |
| 清理恢复档 | 排除残留会话和旧数据影响 | 清理前后身份相同、空间变化、复测结果 | 仍失败则升级为阻断证据 |
| 更新安装档 | 验证旧版本到当前候选 | 旧新版本身份、用户数据、会话结果 | 与全新安装分别判定 |
把 PackageInstaller 会话阶段和结果完整保存
PackageInstaller 会话把待安装文件写入 session,再由 commit 提交。测试记录应区分创建会话、写入文件、关闭写入、提交、等待用户动作和最终结果,不能只保存命令行最后一行。对于 split 安装,所有文件必须进入同一合法会话,base、包名、versionCode 和签名保持一致。漏写某个 split 或混入不同签名文件会失败,但这不是低存储因果证据。
结果回调至少保存状态码、状态消息、会话 ID、安装包身份、时间戳和测试阶段。`STATUS_FAILURE_STORAGE` 是重要线索,但仍要核对测量时点、设备配额、会话残留、用户数据和实际派生集;其他 failure status 则可能指向无效包、冲突、不兼容、系统阻止或会话中止。文章与报告应使用平台返回的原始分类,避免把所有非成功状态改写成“空间不足”。
安装成功也不是门禁终点。系统提交完成后,需要确认目标 applicationId 与 versionCode 已安装、签名身份符合预期,并执行最小首次启动或升级后数据访问断言。低空间可能让安装会话成功,却让首次运行时资源解压、数据库创建或业务缓存失败。这些属于安装后可用性证据,应与 PackageInstaller 状态分开记录,避免用启动失败反推安装会话失败。
- 保存 session 创建、文件写入、commit 与最终回调阶段
- base 和所有 split 在同一会话内且身份一致
- 保留原始状态码、状态消息、会话 ID 和时间戳
- STATUS_FAILURE_STORAGE 不会被直接写成加固根因
- 成功后核对已安装 applicationId、versionCode 和签名
- 首次启动与升级数据断言单独记录,不覆盖安装状态
清理复测和原始包对照决定是否与加固相关
出现失败后,第一步不是立刻换一个新包,而是保存现场。记录 available_before、会话状态、写入字节、派生文件清单、系统消息和剩余会话,再按预定义动作清理测试填充文件、已放弃会话或旧安装数据。清理后必须使用相同候选、相同签名、相同安装路径和相同设备复测;若同时重新构建,就失去了判断空间因素的直接对照。
若清理空间后同一候选成功,只能说明失败与当前设备空间状态相关,还不能说明加固导致额外空间压力。要判断是否与加固变化相关,需要用同一源码、变体、签名和交付方式构建原始对照,并对两者的实际派生集、会话阶段和空间档位做差分。原始包与加固包都失败,可能是设备或流程边界;只有加固候选在支持档稳定失败,才形成需要继续定位的新增回归。
对照也不能只看上传文件字节差。AAB 的设备派生集合、压缩方式、Native 库选择和安装时优化会改变实际路径。应比较同一设备规格下的 base 与 split 总量、会话写入量、安装前后空间、结果码和首次启动状态。即使发现加固候选更大,也需要具体候选实测才能定义门槛,不能从体积差直接推导某个配置是根因。
| 观察 | 可接受判断 | 下一步证据 | 发布处理 |
|---|---|---|---|
| 充足空间也失败 | 问题不满足低存储特异性 | 核对身份、签名、版本、split 和流程 | 阻断 |
| 支持边界以上失败,清理后仍失败 | 当前候选不满足声明边界 | 同候选重复与原始包差分 | 阻断 |
| 支持边界以下失败,清理后成功 | 观察到可恢复的边界行为 | 验证提示与恢复路径 | 按支持策略评审 |
| 原始包和加固包同档都失败 | 不能归因为加固新增回归 | 复核设备、渠道和档位设计 | 阻断流程并补证 |
| 仅加固候选在支持档失败 | 存在与当前加固候选相关的新增回归 | 缩小配置、派生和会话阶段 | 阻断 |
| 回执缺少摘要或空间测量 | 实验对象或条件不可验证 | 重新采集完整同候选证据 | 不允许放行 |
AAB 要同时核对本地派生和测试轨道
bundletool 可以从 AAB 生成 APK set,并按设备规格选择和部署 APK。它适合在发布前重现 base 与 split 组合,检查某一 ABI、密度、语言和 SDK 条件下实际涉及哪些文件。门禁应保存 bundletool 版本、设备规格、AAB 摘要、生成参数、签名参数和 APK set 摘要,这样低存储结果才可以回到明确的派生输入。
签名是本地派生最常见的证据陷阱之一。若生成时没有显式提供发布签名,工具可能使用调试签名;这种 APK set 可以帮助检查结构,却不能冒充商店或生产更新候选。更新测试必须保持 applicationId 与兼容签名关系,并使用可接受的 versionCode。调试包能安装、发布包失败时,应先查签名和更新身份,而不是把差异解释为空间。
本地生成不能完全替代应用商店的服务端处理与设备交付回执。发布门禁可以先用本地派生覆盖已知设备规格,再把最终 AAB 放入受控测试轨道,记录真实设备获得的版本和安装结果。两类证据都绑定同一上传 AAB,但各自说明不同环节;只有测试轨道成功,不能证明所有设备 split 已覆盖,本地矩阵也不能证明线上交付完全一致。
- 记录 bundletool 版本、设备规格和 AAB 摘要
- 保存生成参数、签名参数和 APK set 摘要
- 调试签名派生集不会被标记为发布候选
- 本地派生与测试轨道结果分别保存证据边界
- 同一设备规格核对 base 与全部 split 文件清单
- 线上抽样不足时明确记录尚未覆盖的配置
用只读脚本汇总候选、空间档位和失败码
下面的 Python 脚本读取脱敏的 PackageInstaller 会话结果,按 candidate_sha256、storage_tier 和 status_code 聚合发布表。输入还必须提供 minimum_supported_free_bytes,脚本据此判断失败发生在支持边界内还是边界外。它不执行安装、不清理设备、不修改回执,只检查结构、候选身份、空间数值和结果枚举。
每条记录需要包含会话前可用空间、支持阈值、session 阶段、状态码、结果、是否完成清理复测及已安装身份校验。若充足控制档失败、支持边界内出现失败、同组候选摘要不一致、结果不确定、缺少清理复测或成功后身份未核对,输出 gate 都会标为 BLOCK。边界以下失败且清理后恢复,则输出 REVIEW,交由支持策略判断提示和恢复体验。
脚本不会根据状态码猜测加固根因,也不会把样本数量转换成成功率承诺。真实流水线还应保存原始回调、文件摘要和不可变时间戳,并确保设备空间测量来自同一轮会话。输出表只说明现有回执能否满足门禁,不代表未测试设备通过,更不能替代 Test Lab 或实体设备上的安装与首次启动。
import json
import sys
from collections import defaultdict
from pathlib import Path
VALID_RESULTS = {"success", "failed", "inconclusive"}
REQUIRED = {
"candidate_sha256",
"storage_tier",
"available_before_bytes",
"minimum_supported_free_bytes",
"stage",
"status_code",
"result",
"cleanup_retested",
"installed_identity_verified",
}
def fail(message):
raise SystemExit(f"invalid install receipt: {message}")
if len(sys.argv) != 2:
raise SystemExit("usage: python install_gate.py RECEIPTS_JSON")
receipt_path = Path(sys.argv[1]).resolve()
if not receipt_path.is_file():
fail(f"file not found: {receipt_path}")
data = json.loads(receipt_path.read_text(encoding="utf-8"))
records = data.get("records")
if not isinstance(records, list) or not records:
fail("records must be a non-empty list")
rows = defaultdict(list)
for index, record in enumerate(records):
if not isinstance(record, dict):
fail(f"record {index} must be an object")
missing = sorted(REQUIRED.difference(record))
if missing:
fail(f"record {index} missing {missing}")
candidate = record["candidate_sha256"]
tier = record["storage_tier"]
result = record["result"]
available = record["available_before_bytes"]
minimum = record["minimum_supported_free_bytes"]
if not isinstance(candidate, str) or len(candidate) != 64:
fail(f"record {index} has invalid candidate_sha256")
if not isinstance(tier, str) or not tier:
fail(f"record {index} has invalid storage_tier")
if result not in VALID_RESULTS:
fail(f"record {index} has invalid result")
if not isinstance(available, int) or not isinstance(minimum, int) or available < 0 or minimum < 0:
fail(f"record {index} has invalid storage bytes")
key = (candidate, tier, str(record["status_code"]))
rows[key].append(record)
report = []
for (candidate, tier, status_code), group in sorted(rows.items()):
support_fail = any(item["result"] == "failed" and item["available_before_bytes"] >= item["minimum_supported_free_bytes"] for item in group)
inconclusive = any(item["result"] == "inconclusive" for item in group)
missing_retest = any(item["result"] == "failed" and not item["cleanup_retested"] for item in group)
bad_success = any(item["result"] == "success" and not item["installed_identity_verified"] for item in group)
if support_fail or inconclusive or missing_retest or bad_success:
gate = "BLOCK"
elif any(item["result"] == "failed" for item in group):
gate = "REVIEW"
else:
gate = "PASS"
report.append({
"candidate_sha256": candidate,
"storage_tier": tier,
"status_code": status_code,
"sessions": len(group),
"gate": gate,
})
print(json.dumps({"gate_rows": report}, ensure_ascii=False, indent=2))把阻断条件、恢复步骤和上线边界写进发布规则
一个可执行门禁需要明确阻断条件:充足空间控制档失败;支持边界内稳定失败;候选、签名或派生集身份不一致;PackageInstaller 最终状态缺失;清理后同一候选仍失败;更新安装破坏身份连续;成功后无法核对已安装版本;测试结果长期不确定。命中任一项时,发布记录应说明缺哪条证据,而不是只给一个红色状态。
通过条件同样要具体。最终候选在声明支持的空间档位完成目标安装方式,PackageInstaller 状态成功,已安装身份正确,首次启动或升级断言可达,清理复测规则无异常,并在目标设备范围内取得回执。设备矩阵由型号、系统、方向和 locale 等维度组成,实际选择应依据用户与最低支持范围;一个矩阵成功不能宣称所有设备都已覆盖。
门禁关闭后仍要保留发布边界,包括未测设备、未测渠道、最低空间条件、AAB 派生覆盖和升级起点。需要排查真实加固包的低存储安装问题时,可通过御盾中央平台提交脱敏候选摘要、签名摘要、派生文件清单、空间档位、PackageInstaller 原始状态、清理复测和原始包对照,先确认失败阶段,再决定是否调整加固范围。
- 阻断项写明候选、档位、阶段、状态码和缺失证据
- 支持边界内安装成功并核对最终应用身份
- 全新安装与更新安装分别取得结果
- 首次启动或升级断言不会被安装成功状态替代
- 目标设备范围与未覆盖配置进入发布说明
- 所有登录、申请和进一步评估动作进入御盾中央平台
事实依据与适用边界
以下内容区分官方事实、本文工程判断和不能外推的范围,避免把设计建议写成未经验证的产品结论。
| 本文判断 | 事实或工程依据 | 适用限制 |
|---|---|---|
| split 安装要求 base、packageName、versionCode 与签名证书保持一致。 | Android PackageInstaller 描述安装会话和 split APK 的一致性约束,并提供会话结果状态。 | API 约束不能证明应用商店服务器最终生成的全部 split 已被测试,也不能把所有失败归为空间不足。 |
| Android 应用更新需要保持应用身份、可接受的版本号和兼容签名关系。 | How Android app updates work 说明 applicationId、versionCode、签名证书及轮换证明对更新身份的要求。 | 平台更新条件不等于业务防重放策略,也不证明加固候选在低存储设备上能够安装。 |
| AAB 派生 APK 可以通过本地工具和测试轨道进行发布前验证。 | Build and test Android App Bundles 说明使用 bundletool、本地部署和测试轨道验证 app bundle 交付。 | 本地生成不能完全替代应用商店线上交付回执,单次轨道测试也不覆盖所有设备配置。 |
| bundletool 可以从 AAB 生成并检查按设备配置选择的 APK set。 | bundletool 文档说明 build-apks、设备规格选择、APK set 检查和安装等工具能力。 | 未显式使用发布签名时生成物可能采用调试身份,不能当作最终发布候选的更新证据。 |
| 设备测试矩阵由型号、系统、方向和 locale 等维度共同定义。 | Firebase Test Lab Android matrices 说明 Android 测试矩阵的设备与配置维度以及矩阵执行结果。 | 云端可用设备会变化,矩阵仍需依据真实用户和最低支持范围选择,不能代表所有 Android 设备。 |
| 依赖真实 Android 运行时、组件和系统 API 的安装后语义应使用设备端测试验证。 | Android instrumented tests 说明 instrumented test 在 Android 环境中运行并可访问真实框架与组件。 | 单一设备通过不能代表完整 API、ABI、厂商和存储实现,也不能替代商店交付验证。 |
| 低存储发布档位应围绕产品声明的最低支持条件与当前候选建立。 | 工程判断:上传大小、派生 APK 集、安装会话峰值与安装后占用不同,统一固定阈值会掩盖设备和渠道差异。 | 具体支持字节数、测量时点和恢复动作必须由项目策略与真实候选测量确认,文章不提供通用阈值。 |
| 一次存储类安装失败不足以证明加固是根因。 | 工程判断:需要同候选清理复测、充足空间端点、原始包差分、派生集身份和会话阶段共同排除其他变量。 | 没有项目回执时不能声明某个加固配置增加了安装空间、造成失败或已经通过修复验证。 |
工程常见问题
加固后 AAB 文件变大,就一定更容易在低存储设备安装失败吗?
不能直接这样判断。上传 AAB、设备派生 split、安装会话临时空间和安装后占用不同,需要用同一候选与设备档位记录真实会话。
出现 STATUS_FAILURE_STORAGE 是否可以直接阻断加固方案?
可以先阻断当前发布,但不能直接归因于加固。还要核对空间测量、候选身份、split、会话阶段、清理复测和原始包对照。
低存储门禁应该设置多少剩余空间才合理?
没有适合所有应用和设备的通用数字。应基于产品支持声明、当前候选派生集和设备测量定义最低条件,并版本化保存。
使用 bundletool 本地安装成功,能否证明 Play 商店安装也会成功?
不能。它能重现特定设备规格下的派生与安装,但线上服务端处理和实际交付仍需通过受控测试轨道取得回执。
清理空间后安装成功,能否确认问题已经关闭?
只能确认当前失败可随空间状态恢复。还需在声明支持边界内复测、核对首次启动,并判断原始包与加固包是否存在新增差异。
排查低存储安装失败需要提供哪些材料?
提供候选与签名摘要、applicationId、versionCode、派生 APK 清单、设备空间档位、PackageInstaller 状态、清理复测和原始包对照。