先看结论与判断条件
- 云真机矩阵的起点是发布候选、支持边界和业务风险,不是平台当前能提供多少设备或随机挑选热门型号。
- 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 |
| ABI | Native 库和桥接 | 每个发布架构 | 虚拟架构不代表物理实现 |
| 设备形态 | 窗口、输入和任务 | 手机加关键特殊形态 | 方向测试不代表形态 |
| 厂商与型号 | 服务、资源和系统定制 | 主要簇与历史风险 | 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、当前设备目录和门禁规则,再从御盾中央平台提交申请。