先看结论与判断条件
- 比较前先锁定源码提交、构建参数、依赖、签名、渠道配置和产物摘要,除保护处理外的差异都会污染归因。
- 原始包与加固包必须执行相同业务断言,并按用例、设备、系统、ABI、locale、方向和数据版本组成配对键。
- 结果至少分为新增失败、共同失败、原包独有失败、共同通过与不可比项,不能把所有加固包失败都叫作加固回归。
- 设备离线、权限弹窗、账号状态、网络抖动和测试数据漂移属于环境或夹具问题,应独立重试并留下不可比回执。
- 崩溃、ANR、启动变慢和功能断言需要不同证据,ApplicationExitInfo 与启动指标只能在同候选和同条件下解释。
- 发布结论绑定两份候选摘要、测试矩阵、比较器版本与原始回执;单次通过、静态构建或一台设备不能证明全面兼容。
先证明两份候选只在计划中的处理上不同
差分回归最常见的错误不是测试覆盖不足,而是拿了两份不可比的包。原始包来自源码提交甲,加固包却由提交乙构建;原始包使用测试签名,加固包换成商业签名;渠道、服务地址、资源压缩、混淆规则或依赖锁文件也不同。此时新增崩溃可能来自任何变动,比较结果不能归因到加固。测试开始前应生成候选身份证明,列出源码版本、构建变体、外部参数、依赖材料、签名证书摘要和最终 APK 或 AAB 摘要。
原始候选应当是加固输入本身,而不是研发重新构建出的一个“看起来相同”版本。若加固工具接受 APK,比较基线就是该输入 APK;若流程从构建系统直接生成保护候选,则需要明确两条流水线的共同材料和唯一差异。任何重新签名、zip 对齐或渠道注入都要在身份记录中出现,并判断它是否属于本轮计划变量。缺少精确候选时,应停止差分归因,而不是用版本号和文件名代替摘要。
SLSA provenance 与 in-toto Statement 提供的是绑定产物主体和声明负载的方法,不会自动证明运行结果正确。项目可以借鉴其思想:让测试报告引用两份候选的强摘要,让构建证明记录 builder、buildType、外部参数和材料,再由可信流程签署。这样至少能阻止报告与包错配。若证明内容由错误或不可信的主体生成,格式正确仍不能替代门禁核验。
| 维度 | 原始包记录 | 加固包记录 | 不一致时处理 |
|---|---|---|---|
| 源码与依赖 | 提交和锁文件摘要 | 同一材料引用 | 停止归因并重建 |
| 构建变体 | flavor、buildType、flags | 除保护外保持一致 | 标记不可比 |
| 签名身份 | 证书摘要和方案 | 计划签名摘要 | 单独验证签名影响 |
| 渠道配置 | 资源与服务配置摘要 | 相同配置摘要 | 拆出渠道变量 |
| 产物主体 | APK 或 AAB 强摘要 | 保护候选强摘要 | 拒绝只用文件名 |
| 工具参数 | 无保护或基线参数 | 保护策略摘要 | 进入本轮变量清单 |
用同一业务断言和配对键建立比较单位
差分比较的最小单位不是一份测试报告,而是一对可对应的执行。每个执行至少要有 testId、deviceModel、osVersion、ABI、locale、orientation、dataVersion、candidateDigest 和 attemptId。原始包与加固包只有在除 candidateDigest 外的配对字段一致时,才可以比较结果。若一侧在不同系统、不同语言或不同测试数据上运行,即使 testId 相同也只能标记不可比,不能把缺失的一侧当作失败或通过。
业务断言必须相同且能够验证结果,而不是只检查页面能打开或进程没有崩溃。登录用例要验证身份状态和错误分支,文件处理要验证输出结构与失败语义,JNI 路径要验证输入输出和异常清理,后台任务则验证最终状态而非调度瞬间。instrumented test 可以访问 Android 组件和运行时,因此适合执行这类平台相关断言,但测试夹具和服务替身也要保持一致。
用例版本要进入配对键或测试包摘要。若加固包执行了修订后的脚本,原始包仍用旧断言,两侧结果不能直接合并。测试团队可以先对原始包跑一次候选用例集,确认夹具可用并记录原有失败,再冻结测试代码、数据和设备选择,最后对加固包执行相同内容。冻结不等于永久不改;需要修复测试时,应给两侧都重跑并产生新的比较批次。
| 字段 | 是否必须相同 | 作用 | 缺失处理 |
|---|---|---|---|
| testId 与用例版本 | 必须 | 绑定同一业务断言 | 不可比 |
| 设备与系统 | 必须 | 控制运行时差异 | 不可比 |
| ABI | 必须 | 控制 Native 产物路径 | 不可比 |
| locale 与方向 | 必须 | 控制资源和布局路径 | 不可比 |
| 测试数据版本 | 必须 | 控制输入与服务响应 | 不可比 |
| candidateDigest | 必须不同且已记录 | 区分两份候选 | 拒绝入账 |
设备矩阵要成对执行,并把环境噪声单独隔离
设备矩阵不应只写成“覆盖主流手机”。型号、OS、方向和 locale 都会形成独立执行,Native 项目还要显式加入 ABI、图形后端或硬件能力。矩阵选择依据最低支持范围、真实用户分布和高风险路径,而不是云平台当前有哪些设备。Firebase Test Lab 能组织 Android 测试矩阵,但云端执行仍受设备可用性、网络和平台容量影响,矩阵完成不等于每个执行都得到可比较的业务结果。
两份候选最好在接近的时间窗、相同设备池和相同夹具版本上成对执行。若先跑原始包,几天后再跑加固包,服务数据、权限状态和设备镜像可能已经变化。可采用按矩阵单元交替执行,或在设备重置后按随机顺序运行两份候选,减少顺序效应。若测试本身依赖缓存、首次权限或冷启动,必须在用例前明确清理状态,并记录实际启动类型。
环境失败不能偷偷重标为产品失败。设备离线、安装超时、测试服务不可达、账号过期、系统弹窗遮挡和夹具崩溃都应进入 infra_error 或 fixture_error。重试策略只对这些已分类错误生效,并且两侧遵守相同次数和条件。重试后仍无法取得配对结果,就输出不可比项和具体缺失字段。自动把最终一次结果覆盖前序记录,会掩盖不稳定性并给归因制造偏差。
| 现象 | 类别 | 是否进入产品差分 | 后续动作 |
|---|---|---|---|
| 设备离线或分配失败 | infra_error | 否 | 按同策略重试 |
| 服务替身不可达 | fixture_error | 否 | 修复夹具后两侧重跑 |
| 权限状态不一致 | setup_error | 否 | 重置设备状态 |
| 业务断言失败 | test_failure | 是 | 进入配对分类 |
| 进程崩溃或 ANR | runtime_failure | 是 | 绑定退出与 trace |
| 只有一侧有执行 | incomparable | 否 | 补齐或明确缺口 |
用五类结果阻止加固包失败被自动归因
完成配对后,最重要的是分类而不是汇总通过率。加固包失败而原始包通过,属于新增失败,需要优先复现;两侧都失败,属于共同失败,说明基线已存在缺陷、夹具持续有问题或环境共同受影响;原始包失败而加固包通过,是原包独有失败,但不能据此宣传加固改善;两侧都通过,是共同通过;字段不一致或缺少执行,则是不可比。分类名称要直接写进报告,避免下游只看到一个红色总数。
共同失败仍然值得处理,但责任路径不同。先比较失败类型、错误码、堆栈和时间线:两侧同一断言、同一根因可归到原有缺陷;同一用例但错误表现不同,可能是两个问题,不能简单合并。原包独有失败也要检查是否来自顺序、缓存或偶然恢复。差分回归解决的是“变化是否新增”,不是决定缺陷是否值得修复,也不是证明保护产生性能或质量收益。
新增失败只是归因入口,不是最终结论。它可能由签名变化、渠道配置、资源打包、服务限流、设备状态或测试顺序引起。接下来需要在同一候选和同一矩阵单元上稳定复现,再缩小到最小保护策略、模块或运行路径。若关闭某策略后失败消失,也只建立相关性;还需核对构建身份和重复性,才能形成可提交的兼容性问题。
| 原始包 | 加固包 | 分类 | 允许的结论 |
|---|---|---|---|
| 通过 | 失败 | 新增失败 | 进入同条件复现与归因 |
| 失败 | 失败 | 共同失败 | 基线已有或共同环境问题 |
| 失败 | 通过 | 原包独有失败 | 记录但不宣传改善 |
| 通过 | 通过 | 共同通过 | 当前组合断言通过 |
| 缺失 | 任意 | 不可比 | 补齐执行或明确缺口 |
| 环境错误 | 任意 | 不可比 | 按环境策略重跑 |
崩溃、ANR 与启动差异必须使用各自的证据
功能断言失败可以由测试框架直接给出期望与实际,崩溃和 ANR 则需要进程级材料。ApplicationExitInfo 能提供历史退出原因和相关 trace,但每条记录要与候选摘要、进程名、发生时间、设备和测试步骤关联。若原始包与加固包都退出,应比较退出类型和匹配符号后的栈,而不是只比较“测试失败”。平台版本不提供某项诊断时应记录缺失,不能借用另一设备的 trace。
启动差分也不能用一次秒表结果定性。Android 启动文档区分 TTID 与 TTFD,前者关注首帧,后者关注应用声明的完全绘制点;冷、温、热状态和测量条件也会影响结果。两侧必须使用相同启动状态、设备温度管理、数据准备和测量工具,并保留原始样本。单次较慢可能是调度噪声,不足以证明加固造成性能退化,更不能从单台设备外推全部用户。
不同失败类型可以共用候选身份和配对键,但不能共用一个模糊状态。建议结果模型把 assertion_failure、crash、anr、startup_metric、infra_error 分开,并为每类附对应证据引用。比较器只决定两侧状态关系,不替代崩溃符号化、ANR 根因或统计分析。这样新增失败清单能把负责人路由到正确诊断流程,而不会让一段统一脚本假装解决所有兼容问题。
| 结果类型 | 主证据 | 配对条件 | 禁止做法 |
|---|---|---|---|
| 业务断言失败 | 期望、实际与步骤 | 同测试和数据版本 | 只看进程存活 |
| 崩溃 | 退出原因与符号化栈 | 同候选、设备和时间窗 | 跨候选借栈 |
| ANR | ANR trace 与组件上下文 | 同路径和进程 | 把顶部帧当根因 |
| TTID | 首帧测量样本 | 同启动状态与设备 | 单次样本定性 |
| TTFD | 报告完全绘制点 | 同业务就绪定义 | 与 TTID 混为一项 |
| 环境失败 | 基础设施回执 | 同重试策略 | 计入产品失败率 |
新增失败要经过复现、缩小变量和反向验证
新增失败进入调查后,先在同一加固候选、同一设备镜像和同一测试数据上重复执行,确认它不是偶发噪声。随后在不改变源码与签名身份的前提下缩小保护变量,例如仅调整明确相关的策略或模块,生成新的可追溯候选。每份候选都要有独立摘要和构建记录。直接在设备上替换 SO、改包或重签名会破坏候选身份,得到的现象不能作为原发布包的闭环证据。
缩小变量后还需要反向验证。若关闭某策略后通过,再恢复相同策略应重新出现同类失败;若错误无法重复,记录为不稳定并继续采集,不能写成已修复。对于共同失败,修复业务或测试夹具后,两份候选都应重跑,防止基线变化被当作保护兼容改善。任何重试都保留 attemptId 和原始结果,最终报告展示稳定性,而不是只挑选符合预期的一次。
归因记录至少包含新增失败分类、复现步骤、候选摘要、设备单元、证据文件、最小变量和仍未排除的解释。结论语言应与证据等级一致:可以写“在该候选与设备组合稳定出现”,不能写“全部设备都会失败”;可以写“与某策略开关相关”,不能在没有代码和运行证据时写“策略必然是根因”。这种边界能让研发与加固团队快速复核,也避免把原有缺陷推给错误责任方。
| 阶段 | 固定项 | 允许变化 | 出口条件 |
|---|---|---|---|
| 确认新增 | 配对键与两份候选 | 无 | 原始通过、加固失败 |
| 稳定复现 | 加固候选和环境 | attemptId | 同类失败重复出现 |
| 缩小变量 | 源码、签名与设备 | 单一策略或模块 | 失败随最小变量变化 |
| 反向验证 | 测试与环境 | 恢复原策略 | 现象可再次出现 |
| 修复验证 | 问题候选的后继构建 | 明确修复 | 旧断言和负向用例通过 |
| 扩大矩阵 | 修复候选 | 目标设备范围 | 逐组合留下回执 |
发布门禁必须同时保留原始结果与不可变比较回执
发布门禁不应只保存一张汇总表。需要保留原始包执行结果、加固包执行结果、矩阵定义、测试包摘要、数据版本、比较器版本和生成的分类清单。每个文件计算摘要,最终比较回执引用这些输入并标记生成时间。若任一输入变化,旧回执不再代表当前候选。SLSA 与 in-toto 的主体绑定思想适合这里,但声明仍要由可信执行者产生并经过项目门禁核对。
门禁策略应显式定义哪些类别阻断发布。新增失败通常阻断,除非有经过授权且有移除条件的例外;不可比项是否阻断取决于它是否位于必测矩阵,但不能静默忽略;共同失败进入既有缺陷治理,并在风险评审中可见;原包独有失败也保留,防止后续基线修复改变比较结果。共同通过只说明列出的断言和组合通过,不代表未测试路径安全。
报告要便于再次执行。读者应能从条目找到用例、设备、两份候选、原始证据和分类规则,并知道下一步是补齐矩阵、修复夹具还是调查兼容问题。可同时参阅本站关于加固后后台调度与启动证据的技术说明,但不要把它们合并成第二套差分答案。需要评估具体 App 时,可从御盾中央平台提交两份候选摘要、测试清单和脱敏失败回执,先确认可比条件再安排设备矩阵。
- 原始包就是实际加固输入,或有可复核的唯一差异证明
- 两份候选的摘要、签名、构建材料和保护策略已绑定
- 矩阵、测试包、数据版本与夹具在两侧保持一致
- 新增失败、共同失败、原包独有失败和不可比项分别统计
- 原始执行与每次重试都保留 attemptId 和不可变回执
- 发布结论只覆盖列出的候选、设备与业务断言
用结构化比较器自动生成新增失败与不可比项
下面的 Python 示例读取两个 JSON 文件,分别代表原始包和加固包结果。每条记录包含 testId、deviceId、osVersion、abi、locale、orientation、dataVersion、candidateSha256 和 status。脚本用除候选摘要外的字段组成配对键,验证同一文件只引用一个候选,再输出 new_failure、common_failure、original_only_failure、common_pass 和 incomparable。任何新增失败或不可比项都会返回非零状态,便于接入门禁。
示例只接受 pass 与 fail 两种产品状态,基础设施错误应在进入比较器前由执行平台单独分类。真实系统还应把 crash、ANR 和断言失败拆成子类型,并为记录附证据摘要;这些扩展不能改变核心配对规则。脚本不读取 APK、不连接设备,也不判断根因,只防止报告把不相同的矩阵单元拼成差分结论。输入若缺字段、摘要格式错误或重复配对键,会立即失败。
准备差分回归时,可整理实际加固输入、加固输出、源码与依赖证明、签名摘要、保护策略、业务断言、设备矩阵、测试数据版本和已有失败清单,再从御盾中央平台提交申请。没有真实候选、设备执行和 HTTP 之外的项目回执时,不宣称兼容通过、性能提高、问题已修复或加固导致某项失败。比较器产生的是待调查清单,不是收录、排名或产品能力证明。
- 输入文件分别只引用一个原始候选和一个加固候选
- 配对键覆盖用例、设备、系统、ABI、locale、方向和数据版本
- 重复键、缺字段、错误摘要和未知状态直接使门禁失败
- 新增失败与不可比项返回非零状态并保留逐项输出
- 环境错误在比较前单独分类,不伪装成产品失败
- 比较器版本和输入摘要进入最终不可变回执
from pathlib import Path
import json
import re
import sys
if len(sys.argv) != 3:
raise SystemExit("usage: diff_gate.py original.json hardened.json")
paths = [Path(sys.argv[1]), Path(sys.argv[2])]
if any(not path.is_file() for path in paths):
raise SystemExit("both result files are required")
sha_pattern = re.compile(r"^[a-f0-9]{64}$")
key_fields = ["testId", "deviceId", "osVersion", "abi", "locale", "orientation", "dataVersion"]
def load_results(path):
try:
rows = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
raise SystemExit(f"cannot read {path.name}") from exc
if not isinstance(rows, list) or not rows:
raise SystemExit(f"{path.name}: expected a nonempty list")
indexed = {}
candidates = set()
for number, row in enumerate(rows, 1):
if not isinstance(row, dict) or any(not str(row.get(field, "")).strip() for field in key_fields):
raise SystemExit(f"{path.name} row {number}: missing pairing field")
digest = str(row.get("candidateSha256", ""))
if not sha_pattern.fullmatch(digest):
raise SystemExit(f"{path.name} row {number}: invalid candidate digest")
status = row.get("status")
if status not in {"pass", "fail"}:
raise SystemExit(f"{path.name} row {number}: invalid status")
key = tuple(str(row[field]) for field in key_fields)
if key in indexed:
raise SystemExit(f"{path.name} row {number}: duplicate pairing key")
indexed[key] = status
candidates.add(digest)
if len(candidates) != 1:
raise SystemExit(f"{path.name}: multiple candidates")
return indexed, candidates.pop()
original, original_sha = load_results(paths[0])
hardened, hardened_sha = load_results(paths[1])
if original_sha == hardened_sha:
raise SystemExit("candidate digests must differ")
all_keys = sorted(set(original) | set(hardened))
counts = {name: 0 for name in ["new_failure", "common_failure", "original_only_failure", "common_pass", "incomparable"]}
for key in all_keys:
left, right = original.get(key), hardened.get(key)
if left is None or right is None:
category = "incomparable"
elif left == "pass" and right == "fail":
category = "new_failure"
elif left == "fail" and right == "fail":
category = "common_failure"
elif left == "fail" and right == "pass":
category = "original_only_failure"
else:
category = "common_pass"
counts[category] += 1
print(category, *key, sep="\t")
print(json.dumps(counts, sort_keys=True))
raise SystemExit(1 if counts["new_failure"] or counts["incomparable"] else 0)事实依据与适用边界
以下内容区分官方事实、本文工程判断和不能外推的范围,避免把设计建议写成未经验证的产品结论。
| 本文判断 | 事实或工程依据 | 适用限制 |
|---|---|---|
| 依赖 Android 组件、系统 API 和真实运行时的业务语义应由设备端测试执行。 | Android instrumented tests 说明 instrumented test 在 Android 设备环境中运行并访问平台能力。 | 单一设备或模拟设备通过不能代表全部 API、ABI、厂商设备和真实用户路径。 |
| 设备型号、OS、方向与 locale 会组成独立矩阵执行,执行失败会影响矩阵判断。 | Firebase Test Lab Android matrices 说明 Android 测试矩阵配置和执行结果组织方式。 | 云真机平台不替项目决定用户分布、最低支持范围、ABI 风险和业务断言。 |
| 进程退出记录可以为差分中的崩溃或 ANR 补充退出原因与诊断材料。 | Android ApplicationExitInfo 描述历史退出原因、ANR trace 和部分平台版本的 Native 诊断内容。 | 记录必须关联候选、进程、时间窗和测试路径,不能跨设备或跨构建借用。 |
| 启动比较需要区分首帧和完全绘制,并固定启动状态与测量条件。 | Android app startup time 区分 TTID、TTFD 与冷温热启动等测量语义。 | 单次耗时不能代表分布,也不能单独证明差异由加固产生。 |
| 构建证明应把产物主体与构建者、构建类型、参数和依赖材料关联。 | SLSA Provenance v1.1 定义 provenance 中 subject、builder、buildType、externalParameters 与 resolvedDependencies 等结构。 | provenance 证明记录的构建陈述,不替代运行时兼容、安全效果或业务正确性测试。 |
| 测试回执需要与候选摘要和有类型的声明负载绑定,防止报告与包错配。 | in-toto Attestation Statement v1 定义 subject 与 predicateType 等通用声明封装。 | 声明格式正确不保证内容真实,仍需要可信签名者、输入摘要和门禁核验。 |
| 原始包和加固包只有在配对键一致时,结果才具备差分可比性。 | 工程判断:固定用例、设备、系统、ABI、locale、方向和数据版本可以隔离候选变量。 | 配对不能排除未记录的环境差异,基础设施与夹具事件仍需独立回执。 |
| 加固包新增失败只是调查入口,不能在缺少稳定复现和最小变量验证时直接归因。 | 工程判断:新增失败还可能来自签名、渠道、数据、设备状态、顺序和测试不稳定。 | 只有同候选重复、缩小变量和反向验证形成回执后,才能写受限的相关或根因结论。 |
工程常见问题
原始包与加固包版本号相同,是否就可以直接比较?
不可以。还要核对实际加固输入、源码与依赖、构建变体、签名、渠道配置和产物摘要;版本号与文件名不能证明两份候选只差保护处理。
加固包失败而原始包通过,能否立即判定为加固问题?
只能先分类为新增失败。还需在同候选与同环境稳定复现,排除签名、数据、设备状态和测试顺序,再通过最小变量与反向验证收窄归因。
两份包都失败的用例应该怎样处理?
记录为共同失败,再比较失败类型与证据。它可能是原有产品缺陷、共同环境问题或两个不同错误,不能直接从加固兼容清单删除。
设备离线或测试服务不可达是否算加固包失败?
不算产品失败。应标记 infra_error 或 fixture_error,按两侧一致的策略重试;无法补齐配对结果时输出不可比项和具体缺口。
启动耗时能否只跑一次原始包和一次加固包?
不能据此定性。要区分 TTID 与 TTFD,固定冷温热状态、设备、数据和测量工具,保留多次原始样本,再用适合的统计与项目预算判断。
差分回归全部共同通过是否代表加固完全兼容?
不代表。它只说明列出的候选、矩阵和业务断言通过;未覆盖的设备、路径、生命周期和持续负载仍然未知,结论不得外推。