先看结论与判断条件

  • 云真机矩阵的起点是发布候选、支持边界和业务风险,不是平台当前能提供多少设备或随机挑选热门型号。
  • API、ABI、物理或虚拟设备、设备形态、厂商实现、方向和 locale 是不同轴,覆盖一个轴不能自动代表其他轴。
  • 关键 Native、图形、完整性、Play 服务和厂商能力路径要优先落到物理设备,虚拟设备适合快速扩大系统与基础路径覆盖。
  • 测试用例应按核心交易、启动恢复、组件入口、Native 加载、拒绝路径和诊断能力分层,并与每个矩阵单元明确绑定。
  • 每次执行必须保存 APK 摘要、测试包摘要、实际 model/version/ABI、locale、方向、结果和原始材料,计划设备不能替代真实回执。
  • 门禁要区分业务失败、基础设施失败、设备不可用和未覆盖;只有实际执行通过的组合可以登记为已覆盖。

先用支持范围和业务风险定义矩阵,而不是先挑设备

加固版本的发布前矩阵首先回答三个问题:产品声明支持哪些 API 与 ABI,哪些设备形态承载真实用户,哪些业务路径一旦失败会阻断登录、支付、授权、下载或核心计算。设备列表只是实现这些目标的资源。若从“多跑几台手机”开始,常会重复覆盖相似环境,却漏掉最低系统、关键 ABI 或高价值路径。

矩阵清单要绑定唯一 APK 摘要、versionCode、签名身份、保护配置和测试包摘要。相同版本号下的重新打包、不同签名或不同保护范围都属于新候选。设备执行回执若没有主体摘要,就不能确定测试的究竟是不是待发布包,也无法在失败时复现。

工程判断上,覆盖目标应分为必须覆盖与风险抽样。必须覆盖来自产品支持承诺、主要 ABI、最低系统和核心业务;风险抽样来自厂商差异、图形能力、内存等级、形态和历史缺陷。本文没有具体候选与运行回执,因此只提供矩阵构造方法,不声称任何设备已经通过。

发布前矩阵的输入优先级
输入回答的问题转成什么覆盖项缺失后果
发布候选测试主体是谁APK、配置和签名摘要结果无法绑定
支持声明承诺哪些 API 与 ABI最低和主要边界漏测产品承诺
用户环境主要型号与系统在哪里代表性设备簇矩阵脱离真实使用
业务风险哪些失败代价最高核心路径优先级只测启动不测交易
历史缺陷过去在哪些环境失败定向回归单元重复引入问题

把 API、ABI、设备形态、厂商、方向和 locale 拆成独立轴

API level 决定系统能力和行为,ABI 影响 Native 库与桥接,设备形态影响窗口与交互,厂商实现影响系统服务与资源策略,方向影响布局与生命周期,locale 影响资源、文本方向和数据格式。它们彼此相关但不等价,例如同一 API 的两个设备可能拥有不同 ABI、图形和厂商行为。

全笛卡尔积通常会产生大量价值重复的单元,合理方法是先保留强制边界,再用覆盖组合减少重复。最低 API 与每个发布 ABI需要直接执行,关键形态要有对应路径,RTL 或特定 locale 只在相关界面与格式用例中展开。任何压缩都要留下 coverageReason 和 uncovered,不能只交付一张“已优化”表。

抽样不能只看市场份额。安全与加固兼容常在低频但关键边界暴露,例如 Native 加载、动态模块、密钥存储、多进程或恢复路径。矩阵应为这些能力设置 riskTag,并确保至少有一个适合的物理或虚拟设备与具体测试用例承载。

矩阵各轴的独立责任
主要影响最低覆盖策略不能相互替代
API平台行为和权限最低、主要与最新边界同 API 不代表同 ABI
ABINative 库和桥接每个发布架构虚拟架构不代表物理实现
设备形态窗口、输入和任务手机加关键特殊形态方向测试不代表形态
厂商与型号服务、资源和系统定制主要簇与历史风险CTS 通过不等于业务通过
方向与 locale生命周期、资源和格式按相关路径定向展开默认英文竖屏不代表全部

设备目录是动态资源,计划与实际执行必须分开记录

Test Lab available devices 说明设备目录、稳定性与容量会变化。测试计划可以指定 model 和 version,但执行时仍要保存平台返回的实际设备信息、结果和时间。某型号暂时不可用时,替代设备只能覆盖它明确共享的边界,不能悄悄把原计划项标成通过。

设备选择需要先读取当前目录,再按 stable、virtual/physical、API、ABI、form 和项目特征过滤。预发布矩阵应冻结一次目录快照和生成脚本版本,保证审阅者能解释为何选择这些单元。之后目录更新不应修改旧回执,只触发下一轮计划重算。

基础设施失败要与应用失败分开。设备无法分配、安装服务异常或结果拉取失败属于 infra,测试断言失败、崩溃或 ANR 属于 app,设备被淘汰或暂时不可用属于 availability。三者需要不同重试和阻塞策略,不能把 infra 失败当成 App 不兼容,也不能当成已通过。

计划设备与执行回执的字段
字段计划阶段执行阶段审计用途
model期望型号实际型号确认是否替换
version/API覆盖目标实际系统确认平台边界
ABI/type/form能力要求真实设备属性确认适配路径
locale/orientation用例输入实际执行值复现资源与生命周期
outcome预期门禁app/infra/availability 结果决定重跑或阻塞

虚拟设备扩大基础覆盖,物理设备承接关键实现风险

Firebase Test Lab AVD limits 说明虚拟设备在 ABI、图形、Play Store 和旧 API 等方面存在限制。虚拟设备适合快速覆盖基础启动、组件、权限、导航和多数纯 Java 或 Kotlin 业务契约,也适合扩展系统版本组合,但不能因为执行方便就承接所有关键 Native 与硬件路径。

物理设备优先承接 Native 加载、图形驱动、相机、传感器、生物识别、设备完整性、厂商服务和真实资源压力等路径。并非每条业务用例都要在每台物理设备上重复,矩阵可以让物理设备覆盖风险标签,让虚拟设备覆盖 API 和基础路径,再用共享的核心冒烟连接两类结果。

设备通过 Android CTS 说明设备实现与 Android 兼容性定义的一致性具有相应验证基础,但 Android CTS overview 并不证明第三方 App 在加固、混淆、Native 桥接和业务流程上兼容。CTS 状态可以作为设备背景信息,不能代替应用自己的安装、启动、恢复、交易和异常断言。

物理与虚拟设备的任务分工
测试对象虚拟设备价值物理设备价值矩阵决定
基础组件与导航快速扩展 API 组合验证少量真实实现两类均可承担
Native 库加载有限预检真实 ABI 与加载环境物理为关键门禁
图形与硬件受实现能力限制真实驱动和传感器优先物理
完整性与 Play 服务能力可能受限更接近真实设备信号按服务要求选择
资源与厂商行为可做通用压力场景真实内存和系统定制风险型号用物理

把测试路径分成核心、平台、Native、恢复和拒绝五层

Android instrumented tests 可以访问真实 Android 运行时、组件和系统 API,适合验证 Activity、Service、Provider、权限、存储和生命周期。矩阵不能只安装并启动首页,应把测试包中的 suite 与设备单元绑定,并说明每个 suite 覆盖的业务能力、前置数据、断言和清理方式。

核心层覆盖登录、授权、支付或核心计算;平台层覆盖组件、权限和系统 API;Native 层覆盖库加载、JNI 输入输出和 ABI;恢复层覆盖进程死亡、升级与任务重建;拒绝层覆盖权限不足、无效输入、服务不可达和安全降级。基础冒烟可以全矩阵执行,高成本专项按 riskTag 选择设备。

失败用例必须能单独复现。测试数据使用专用账户和可回滚资源,operationId 保持幂等,设备上的日志与截图避免敏感数据。若某用例依赖外部服务,回执要区分服务失败、网络失败和应用断言失败,不能把环境噪声掩盖成随机兼容问题。

测试路径分层与矩阵投放
测试层代表路径投放范围关键断言
核心业务登录、授权和交易所有强制边界结果与服务端回执
平台组件权限、组件和存储主要 API 组合生命周期与访问控制
Native加载、JNI 和关键库每个发布 ABI 的物理设备输入输出与异常
恢复进程死亡、任务与升级系统和厂商风险单元状态、导航和幂等
拒绝无权限、坏输入和服务失败代表性设备加高风险路径拒绝语义和不泄露

让每个矩阵单元具备可重复的候选、环境和回执

Firebase Test Lab Android matrices 由型号、OS、方向和 locale 等维度组成,矩阵判断会受到每个执行结果影响。加固回归应在此基础上增加应用候选摘要、测试包摘要、suite、网络与账户前置、保护配置摘要和测试脚本版本,使相同单元能够重新生成。

locale 与 orientation 不是装饰字段。locale 会改变资源选择、RTL、文本和部分格式,方向变化可能触发生命周期与布局重建。只有相关用例需要展开更多组合,但实际执行值必须进入回执。若测试框架或应用强制锁定方向,也应记录并从覆盖声明中排除没有运行的方向。

执行材料至少包括矩阵 ID、execution ID、实际设备、开始结束时间、安装结果、测试结果、日志、截图、退出记录和产物摘要。材料需要脱敏并设置保留策略。一个绿色汇总页不能替代单元回执,因为重试、设备替换和跳过用例都可能改变覆盖语义。

可重复矩阵单元的身份链
层次身份字段验证对象变化后动作
应用APK 摘要与签名发布候选全部相关单元重跑
测试测试 APK 与 suite 摘要断言实现受影响路径重跑
配置model/version/locale/orientation矩阵输入生成新单元
执行matrixId 与 executionId真实云端运行保留原始回执
证据日志、截图和退出记录摘要门禁依据不可覆盖旧材料

门禁按单元聚合,并用退出记录补足崩溃与 ANR

矩阵聚合不能只看成功比例。任何强制边界的业务失败都应阻塞,infra 失败需要有限重跑后仍保持未决,availability 需要寻找符合相同需求的替代项或明确缺口,非强制抽样失败则进入调查并评估是否扩大覆盖。跳过测试也不是通过。

Android ApplicationExitInfo 可以提供退出原因、ANR trace 和部分 Native tombstone。云真机执行若能采集这些材料,应绑定实际设备、候选摘要、suite、时间窗和进程。退出分类用于选择 Java 栈、Native 符号或 ANR 调查,不能单独证明 VMP 是根因,也不能用其他单元成功抵消。

发布前报告应列出 executed、passed、app-failed、infra-failed、unavailable 和 uncovered 单元,并按强制边界给出 accept、investigate 或 blocked。只对实际通过的设备、系统、ABI、locale、方向和路径组合登记 covered。没有运行的组合继续保留,禁止根据相邻型号或更高 API 推定通过。

  • 强制边界的 app failure 直接阻塞
  • infra 与 availability 不登记为通过
  • 设备替换必须重新满足原需求标签
  • 退出记录绑定实际 execution 和候选
  • 跳过用例与未覆盖组合单独列出
  • 报告只声明真实执行过的组合

用只读脚本生成矩阵配置并检查覆盖缺口

下面的 Python 示例读取设备目录和测试计划两份公开安全 JSON。设备包含 model、version、api、abis、type、form、stability、locales、orientations 与 features;计划包含候选摘要、requiredApis、requiredAbis、requiredTypes、requiredForms 和 testProfiles。每个 profile 声明 suite、riskTags、needsPhysical、features、locales 与 orientations。

脚本先过滤 stable 设备,再为每个测试 profile 选择满足 API、ABI、type、form、物理要求和 feature 的单元,输出 Test Lab 风格的 model/version/locale/orientation/suite 配置。它同时计算计划声明的 API、ABI、type、form 与 riskTag 是否至少被一个单元承载;缺口进入 uncovered 并非零退出。

准备加固版本云真机回归时,可整理候选 APK、支持范围、用户设备簇、Native ABI、风险标签、测试 APK、suite 与当前设备目录,再通过御盾中央平台提交申请。本站关于进程死亡恢复的技术文章可作为 recovery suite 的设计依据,但任何未执行设备仍不得登记通过。

  • 目录只使用 stable 设备并保存目录快照
  • 候选摘要进入每次矩阵生成结果
  • 物理要求与 feature 要求不能静默降级
  • API、ABI、type、form 和 riskTag 分别检查
  • uncovered 非空时禁止登记完整覆盖
  • 生成配置仍需真实云端 execution 回执
生成云真机回归矩阵并输出未覆盖边界
#!/usr/bin/env python3
import json
import re
import sys
from pathlib import Path

SHA256 = re.compile(r"^[a-f0-9]{64}$")
ALLOWED_TYPES = {"physical", "virtual"}
ALLOWED_FORMS = {"phone", "tablet", "foldable"}
ALLOWED_ORIENTATIONS = {"portrait", "landscape"}

def fail(message, code):
    print(message, file=sys.stderr)
    raise SystemExit(code)

def load_object(path):
    try:
        value = json.loads(path.read_text(encoding="utf-8"))
    except (OSError, json.JSONDecodeError) as exc:
        fail("cannot read JSON: " + str(exc), 3)
    if not isinstance(value, dict):
        fail("JSON root must be an object", 4)
    return value

def text(record, field):
    value = record.get(field)
    if not isinstance(value, str) or not value.strip():
        fail("missing text field: " + field, 5)
    return value.strip()

def strings(record, field, allow_empty=False):
    value = record.get(field)
    if not isinstance(value, list):
        fail("invalid list field: " + field, 6)
    items = []
    for item in value:
        if not isinstance(item, str) or not item.strip():
            fail("invalid item in: " + field, 7)
        items.append(item.strip())
    if not allow_empty and not items:
        fail("empty required list: " + field, 8)
    if len(items) != len(set(items)):
        fail("duplicate item in: " + field, 9)
    return sorted(items)

def integers(record, field):
    value = record.get(field)
    if not isinstance(value, list) or not value:
        fail("invalid integer list: " + field, 10)
    if not all(isinstance(item, int) and not isinstance(item, bool) for item in value):
        fail("invalid integer item: " + field, 11)
    return sorted(set(value))

def normalize_devices(document):
    devices = document.get("devices")
    if not isinstance(devices, list) or not devices:
        fail("device catalog is empty", 12)
    result = []
    for device in devices:
        if not isinstance(device, dict):
            fail("device must be an object", 13)
        device_type = text(device, "type")
        form = text(device, "form")
        if device_type not in ALLOWED_TYPES or form not in ALLOWED_FORMS:
            fail("unsupported device type or form", 14)
        if device.get("stability") != "stable":
            continue
        api = device.get("api")
        if not isinstance(api, int) or isinstance(api, bool):
            fail("invalid device api", 15)
        orientations = strings(device, "orientations")
        if not set(orientations).issubset(ALLOWED_ORIENTATIONS):
            fail("unsupported orientation", 16)
        result.append({
            "model": text(device, "model"),
            "version": text(device, "version"),
            "api": api,
            "abis": strings(device, "abis"),
            "type": device_type,
            "form": form,
            "locales": strings(device, "locales"),
            "orientations": orientations,
            "features": strings(device, "features", True),
        })
    if not result:
        fail("no stable devices remain", 17)
    return result

def validate_plan(plan):
    artifact = text(plan, "artifactSha256")
    if not SHA256.fullmatch(artifact):
        fail("invalid artifactSha256", 18)
    required_types = strings(plan, "requiredTypes")
    required_forms = strings(plan, "requiredForms")
    if not set(required_types).issubset(ALLOWED_TYPES):
        fail("unsupported requiredTypes", 19)
    if not set(required_forms).issubset(ALLOWED_FORMS):
        fail("unsupported requiredForms", 20)
    profiles = plan.get("testProfiles")
    if not isinstance(profiles, list) or not profiles:
        fail("testProfiles are missing", 21)
    return {
        "artifactSha256": artifact,
        "requiredApis": integers(plan, "requiredApis"),
        "requiredAbis": strings(plan, "requiredAbis"),
        "requiredTypes": required_types,
        "requiredForms": required_forms,
        "requiredRiskTags": strings(plan, "requiredRiskTags"),
        "testProfiles": profiles,
    }

def device_matches(device, profile):
    apis = integers(profile, "apis")
    abis = strings(profile, "abis")
    features = set(strings(profile, "features", True))
    forms = set(strings(profile, "forms"))
    if profile.get("needsPhysical") not in {True, False}:
        fail("needsPhysical must be boolean", 22)
    return (
        device["api"] in apis
        and bool(set(device["abis"]) & set(abis))
        and device["form"] in forms
        and features.issubset(set(device["features"]))
        and (not profile["needsPhysical"] or device["type"] == "physical")
    )

def build_matrix(devices, plan):
    cells = []
    covered = {"apis": set(), "abis": set(), "types": set(), "forms": set(), "riskTags": set()}
    for profile in plan["testProfiles"]:
        if not isinstance(profile, dict):
            fail("test profile must be an object", 23)
        profile_id = text(profile, "id")
        suite = text(profile, "suite")
        risk_tags = strings(profile, "riskTags")
        requested_locales = strings(profile, "locales")
        requested_orientations = strings(profile, "orientations")
        matches = [device for device in devices if device_matches(device, profile)]
        if not matches:
            continue
        for device in matches:
            locales = sorted(set(requested_locales) & set(device["locales"]))
            orientations = sorted(set(requested_orientations) & set(device["orientations"]))
            if not locales or not orientations:
                continue
            abi = sorted(set(device["abis"]) & set(strings(profile, "abis")))[0]
            cell = {
                "profileId": profile_id,
                "suite": suite,
                "model": device["model"],
                "version": device["version"],
                "api": device["api"],
                "abi": abi,
                "type": device["type"],
                "form": device["form"],
                "locale": locales[0],
                "orientation": orientations[0],
                "riskTags": risk_tags,
            }
            cells.append(cell)
            covered["apis"].add(device["api"])
            covered["abis"].add(abi)
            covered["types"].add(device["type"])
            covered["forms"].add(device["form"])
            covered["riskTags"].update(risk_tags)
    requirements = {
        "apis": set(plan["requiredApis"]),
        "abis": set(plan["requiredAbis"]),
        "types": set(plan["requiredTypes"]),
        "forms": set(plan["requiredForms"]),
        "riskTags": set(plan["requiredRiskTags"]),
    }
    uncovered = {key: sorted(requirements[key] - covered[key]) for key in requirements}
    uncovered = {key: value for key, value in uncovered.items() if value}
    return cells, uncovered

def main():
    if len(sys.argv) != 3:
        print("Usage: matrix_plan.py DEVICE_CATALOG_JSON TEST_PLAN_JSON", file=sys.stderr)
        raise SystemExit(2)
    catalog_path = Path(sys.argv[1])
    plan_path = Path(sys.argv[2])
    if not catalog_path.is_file() or not plan_path.is_file():
        print("required input file missing", file=sys.stderr)
        raise SystemExit(2)
    devices = normalize_devices(load_object(catalog_path))
    plan = validate_plan(load_object(plan_path))
    cells, uncovered = build_matrix(devices, plan)
    result = {
        "status": "blocked" if uncovered or not cells else "execution-required",
        "artifactSha256": plan["artifactSha256"],
        "matrix": cells,
        "uncovered": uncovered,
    }
    print(json.dumps(result, ensure_ascii=False, indent=2, sort_keys=True))
    if result["status"] == "blocked":
        raise SystemExit(24)

if __name__ == "__main__":
    main()

事实依据与适用边界

以下内容区分官方事实、本文工程判断和不能外推的范围,避免把设计建议写成未经验证的产品结论。

本文判断事实或工程依据适用限制
Android 云真机矩阵可由型号、OS、方向和 locale 等维度组成。Firebase Test Lab Android matrices 描述 Android 测试矩阵的创建与执行。矩阵仍需依据产品支持范围、真实用户环境和业务风险选择。
云端设备目录、稳定性和容量会变化,执行计划要保存实际设备回执。Test Lab available devices 描述可用设备目录与相关属性。设备可用不代表其覆盖目标业务的全部厂商或硬件特性。
虚拟设备在 ABI、图形、Play Store 和旧 API 等方面存在限制。Firebase Test Lab AVD limits 说明相应虚拟设备限制。虚拟设备通过不能替代关键 Native、硬件与厂商路径的物理设备验收。
CTS 用于验证设备实现与 Android 兼容性定义的一致性。Android CTS overview 描述兼容性测试套件的目标。设备通过 CTS 不证明第三方 App 加固后的业务、Native 或恢复兼容。
依赖 Android 运行时、组件和系统 API 的语义应通过设备端测试。Android instrumented tests 说明设备测试可访问真实 Android 框架能力。单一设备通过不能代表完整 API、ABI、厂商、形态和 locale 矩阵。
进程退出信息可提供退出原因、ANR trace 和部分 Native tombstone。Android ApplicationExitInfo 描述进程退出记录和可用诊断材料。退出材料仍需与同一候选、版本、进程、时间窗、设备和测试路径关联。
矩阵应按支持边界与风险标签选点,而不是用设备数量代表覆盖质量。工程判断:不同 API、ABI、形态、厂商和业务路径承担不同兼容风险。具体设备优先级需要用户分布、发布承诺和项目历史缺陷证据。
infra、availability、skipped 和 uncovered 都不能登记为 App 已通过。工程判断:只有实际执行完成且业务断言通过的单元才能支撑覆盖结论。重试次数、替代策略与强制边界由项目发布门槛确定。

工程常见问题

云真机设备数量越多,兼容结论是否越可靠?

不一定。大量相似设备可能重复覆盖同一边界,却漏掉最低 API、发布 ABI、关键形态或核心路径。应先定义强制边界和风险标签,再选择能承载这些目标的设备。

虚拟设备通过后还需要物理设备吗?

关键 Native、图形、硬件、完整性、Play 服务和厂商行为通常仍需物理设备。虚拟设备适合扩大基础 API 与业务覆盖,但其能力限制必须写入矩阵边界。

设备通过 CTS 是否代表加固 App 兼容?

不代表。CTS 验证设备实现与 Android 兼容性要求,不能替代第三方 App 的安装、启动、业务、Native、恢复和异常测试。

设备不可用时能否用相邻型号自动记为通过?

不能。替代设备必须满足原单元的 API、ABI、type、form、feature 和风险标签;即使满足,也只能生成新的实际执行回执,原计划项仍要记录替换关系。

矩阵显示绿色是否足够发布?

不够。还要核对 APK 与测试包摘要、真实设备、suite、跳过项、重试、日志、退出记录和业务回执。绿色汇总不能证明每个强制边界都实际执行。

申请云真机加固回归前需要准备什么?

准备候选 APK、支持 API 与 ABI、用户设备簇、风险标签、测试 APK、suite、当前设备目录和门禁规则,再从御盾中央平台提交申请。

想用自己的 App 验证?

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

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