先看结论与判断条件

  • 比较前先锁定源码提交、构建参数、依赖、签名、渠道配置和产物摘要,除保护处理外的差异都会污染归因。
  • 原始包与加固包必须执行相同业务断言,并按用例、设备、系统、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进入配对分类
进程崩溃或 ANRruntime_failure绑定退出与 trace
只有一侧有执行incomparable补齐或明确缺口

用五类结果阻止加固包失败被自动归因

完成配对后,最重要的是分类而不是汇总通过率。加固包失败而原始包通过,属于新增失败,需要优先复现;两侧都失败,属于共同失败,说明基线已存在缺陷、夹具持续有问题或环境共同受影响;原始包失败而加固包通过,是原包独有失败,但不能据此宣传加固改善;两侧都通过,是共同通过;字段不一致或缺少执行,则是不可比。分类名称要直接写进报告,避免下游只看到一个红色总数。

共同失败仍然值得处理,但责任路径不同。先比较失败类型、错误码、堆栈和时间线:两侧同一断言、同一根因可归到原有缺陷;同一用例但错误表现不同,可能是两个问题,不能简单合并。原包独有失败也要检查是否来自顺序、缓存或偶然恢复。差分回归解决的是“变化是否新增”,不是决定缺陷是否值得修复,也不是证明保护产生性能或质量收益。

新增失败只是归因入口,不是最终结论。它可能由签名变化、渠道配置、资源打包、服务限流、设备状态或测试顺序引起。接下来需要在同一候选和同一矩阵单元上稳定复现,再缩小到最小保护策略、模块或运行路径。若关闭某策略后失败消失,也只建立相关性;还需核对构建身份和重复性,才能形成可提交的兼容性问题。

原始包与加固包的结果分类矩阵
原始包加固包分类允许的结论
通过失败新增失败进入同条件复现与归因
失败失败共同失败基线已有或共同环境问题
失败通过原包独有失败记录但不宣传改善
通过通过共同通过当前组合断言通过
缺失任意不可比补齐执行或明确缺口
环境错误任意不可比按环境策略重跑

崩溃、ANR 与启动差异必须使用各自的证据

功能断言失败可以由测试框架直接给出期望与实际,崩溃和 ANR 则需要进程级材料。ApplicationExitInfo 能提供历史退出原因和相关 trace,但每条记录要与候选摘要、进程名、发生时间、设备和测试步骤关联。若原始包与加固包都退出,应比较退出类型和匹配符号后的栈,而不是只比较“测试失败”。平台版本不提供某项诊断时应记录缺失,不能借用另一设备的 trace。

启动差分也不能用一次秒表结果定性。Android 启动文档区分 TTID 与 TTFD,前者关注首帧,后者关注应用声明的完全绘制点;冷、温、热状态和测量条件也会影响结果。两侧必须使用相同启动状态、设备温度管理、数据准备和测量工具,并保留原始样本。单次较慢可能是调度噪声,不足以证明加固造成性能退化,更不能从单台设备外推全部用户。

不同失败类型可以共用候选身份和配对键,但不能共用一个模糊状态。建议结果模型把 assertion_failure、crash、anr、startup_metric、infra_error 分开,并为每类附对应证据引用。比较器只决定两侧状态关系,不替代崩溃符号化、ANR 根因或统计分析。这样新增失败清单能把负责人路由到正确诊断流程,而不会让一段统一脚本假装解决所有兼容问题。

不同结果类型的证据要求
结果类型主证据配对条件禁止做法
业务断言失败期望、实际与步骤同测试和数据版本只看进程存活
崩溃退出原因与符号化栈同候选、设备和时间窗跨候选借栈
ANRANR 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,固定冷温热状态、设备、数据和测量工具,保留多次原始样本,再用适合的统计与项目预算判断。

差分回归全部共同通过是否代表加固完全兼容?

不代表。它只说明列出的候选、矩阵和业务断言通过;未覆盖的设备、路径、生命周期和持续负载仍然未知,结论不得外推。

想用自己的 App 验证?

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

继续阅读: App 加固 PoC 如何形成发布结论