15.3 兼容性与真机测试矩阵

把「在哪些设备上测、每台测什么、风险怎么加权」摆成一张可统计的真机兼容测试矩阵:设备档位、屏幕、内存、输入方式、已知风险与必测项写进一份配置,再用仿真统计屏幕/输入/内存三维覆盖度并按风险等级加权算出必测负担,避免「只在旗舰机上测过」的假安全感。

前两节把「逻辑对不对」和「链路可不可复现」都变成了自动断言。但这两层都跑在同一台机器上——你的开发机。真机上跑的是另一回事:同一段代码在 8G 旗舰机上流畅,在 2G 入门机上可能一进游戏就被系统回收;同一套布局在标准屏上整齐,在挖孔屏上可能被摄像头遮住按钮。

这一节讲真机兼容测试矩阵。注意边界:第 10 章那张平台能力兼容矩阵 管的是平台 API 差异(登录 / 支付 / 广告在各平台怎么调),本节管的是设备硬件档位差异(屏幕 / 内存 / 输入方式)——一个横向比平台,一个纵向比机型。站内 测试专题 里有通用的兼容性测试方法论,本节只落地小游戏这一形态:用一份配置把设备档位摆成可统计、可估工时的矩阵。

为什么「只在旗舰机上测过」是假安全感

小游戏的用户分布是长尾的:头部机型占一部分,剩下大量是旧机、低端机、各种奇怪分辨率。开发机往往是高配,于是最常见的上线事故是——「我这好好的啊」。真机测试的目的不是「全测一遍」(机型无穷无尽),而是用有限的测试名额覆盖最大的风险:挑出少数代表机型,每台覆盖一档硬件能力,再按「这台出错概率 × 出错后果」排优先级。

这里的「风险」要拆成两半:一半是硬件能力(内存小、GPU 弱、CPU 慢),一半是形态差异(挖孔屏、灵动岛、大屏、鼠标 vs 触摸)。前者决定「跑不跑得动」,后者决定「画得对不对、点得准不准」。矩阵必须两种都覆盖,只测其中一半,另一半就会在某个意想不到的机型上翻车。

D 层:真机测试矩阵

下面这段是 D 层(静态层)产物,一份真机兼容测试矩阵配置。每台设备记六个字段:档位(low / mid / high)、屏幕与屏幕分档、内存、输入方式、已知风险清单、必测项清单。风险等级用整数权重(low=1 / medium=3 / high=7)表达,供后续加权。它没有代码可跑,只做 schema 与 lint 校验。

必须说清楚:矩阵里的机型档位是公开可观察的分档(观察时点 2026-10),不是实测;内存 / 屏幕是档位代表值,真实机型分布须以后台数据核对(X)。风险权重与必测项则是本模型自设的输入,不是平台数据。

{
  "version": 1,
  "game": "merge-island",
  "seed": 20261012,
  "note": "真机兼容测试矩阵。机型档位是「公开可观察」的分档(观察时点 2026-10),不是实测;内存/屏幕为档位代表值,真实机型分布须以后台数据核对(X)。风险权重与必测项是本模型自设的输入。",
  "riskWeight": { "low": 1, "medium": 3, "high": 7 },
  "screenBuckets": ["narrow", "standard", "wide", "tablet"],
  "inputMethods": ["touch", "mouse", "gamepad"],
  "memoryBucketsMB": [2048, 4096, 8192],
  "devices": [
    { "id": "android-low-2g", "name": "低端安卓 2G", "tier": "low", "screen": "720x1280", "bucket": "narrow",
      "memoryMB": 2048, "input": ["touch"], "riskLevel": "high",
      "risks": ["内存紧张易被系统回收", "首包大、冷启动慢", "弱网下云存档超时"],
      "mustTest": ["冷启动首包", "弱网云存档", "低内存降级", "掉帧卡顿采样"] },
    { "id": "android-mid-4g", "name": "中端安卓 4G", "tier": "mid", "screen": "1080x1920", "bucket": "standard",
      "memoryMB": 4096, "input": ["touch"], "riskLevel": "medium",
      "risks": ["刘海/挖孔遮挡安全区", "多任务切换后台恢复"],
      "mustTest": ["安全区适配", "前后台切换恢复", "触控手感"] },
    { "id": "android-high-8g", "name": "高端安卓 8G", "tier": "high", "screen": "1440x2560", "bucket": "wide",
      "memoryMB": 8192, "input": ["touch", "gamepad"], "riskLevel": "low",
      "risks": ["高刷新率下帧预算偏松,易掩盖性能问题"],
      "mustTest": ["高分辨率渲染", "手柄映射"] },
    { "id": "ios-low-2g", "name": "入门 iOS 2G", "tier": "low", "screen": "750x1334", "bucket": "narrow",
      "memoryMB": 2048, "input": ["touch"], "riskLevel": "medium",
      "risks": ["旧系统 WebGL 特性缺失", "音频首次播放需用户手势"],
      "mustTest": ["特性降级开关", "音频解锁", "弱网启动"] },
    { "id": "ios-mid-4g", "name": "主流 iOS 4G", "tier": "mid", "screen": "1170x2532", "bucket": "standard",
      "memoryMB": 4096, "input": ["touch"], "riskLevel": "low",
      "risks": ["灵动岛区域避让"],
      "mustTest": ["安全区适配", "触控手感"] },
    { "id": "android-tablet", "name": "安卓平板", "tier": "mid", "screen": "1600x2560", "bucket": "tablet",
      "memoryMB": 4096, "input": ["touch"], "riskLevel": "medium",
      "risks": ["大屏元素拉伸", "横竖屏切换重排"],
      "mustTest": ["大屏布局", "横竖屏切换", "触控热区"] },
    { "id": "web-desktop", "name": "桌面浏览器", "tier": "high", "screen": "1920x1080", "bucket": "wide",
      "memoryMB": 8192, "input": ["mouse"], "riskLevel": "low",
      "risks": ["鼠标点击与触摸事件语义不同"],
      "mustTest": ["鼠标输入映射"] },
    { "id": "web-mobile-browser", "name": "移动浏览器", "tier": "mid", "screen": "390x844", "bucket": "standard",
      "memoryMB": 4096, "input": ["touch"], "riskLevel": "medium",
      "risks": ["浏览器地址栏动态高度", "页面回退丢状态"],
      "mustTest": ["动态视口高度", "刷新后恢复", "触控手感"] }
  ]
}

S 层:覆盖度与风险加权

光有一张矩阵不够,还得回答两个问题:覆盖够不够(屏幕 / 输入 / 内存三个维度有没有空白)、负担重不重(按风险加权,要测多少项)。下面这段是 S 层(仿真层)脚本 sim/e15_devices.py:读矩阵,统计三维覆盖度(整数百分比),并按「风险权重 × 必测项数」算出每档位的加权负担,产出 JSON + CSV + SVG。全整数运算,图表用纯 Python SVG。

#!/usr/bin/env python3
# file: sim/e15_devices.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/e15_devices.py --config config/ --out out/
"""S 层仿真:真机兼容测试矩阵的覆盖度统计与风险加权。

读 config/e15_devices.json:统计各维度(屏幕档 / 输入方式 / 内存档)的覆盖度,
按风险等级权重对每台设备的「必测项」加权,得到风险加权必测总数与每档位的加权负担。
全整数运算,图表用纯 Python SVG。
产出:out/e15_devices.json + out/e15_devices.csv + out/e15_devices.svg
"""

from __future__ import annotations

import argparse
import csv
import json
import os
import xml.etree.ElementTree as ET


def load(cfg_dir: str) -> dict:
    with open(os.path.join(cfg_dir, "e15_devices.json"), encoding="utf-8") as f:
        return json.load(f)


def pct(covered: int, total: int) -> int:
    """整数百分比(地板除),全整数,禁浮点。"""
    return 0 if total == 0 else covered * 100 // total


def write_svg(path: str, tier_order: list[str], tier_scores: dict) -> int:
    W, H, PAD = 460, 260, 40
    svg = ET.Element("svg", {"xmlns": "http://www.w3.org/2000/svg", "width": str(W), "height": str(H)})
    ET.SubElement(svg, "rect", {"width": str(W), "height": str(H), "fill": "#ffffff"})
    ET.SubElement(svg, "text", {"x": str(PAD), "y": "24", "font-size": "15", "fill": "#0f172a"},
                  ).text = "各档位风险加权必测负担"
    top = max(1, max(tier_scores.values()) if tier_scores else 1)
    plot_h = H - 2 * PAD
    bw = (W - 2 * PAD) // max(1, len(tier_order))
    for i, tier in enumerate(tier_order):
        v = tier_scores.get(tier, 0)
        bh = v * plot_h // top
        x = PAD + i * bw + bw // 6
        ET.SubElement(svg, "rect", {"x": str(x), "y": str(H - PAD - bh), "width": str(bw * 2 // 3),
                                    "height": str(bh), "fill": "#38bdf8"})
        ET.SubElement(svg, "text", {"x": str(x + bw // 3), "y": str(H - PAD - bh - 6),
                                    "font-size": "12", "text-anchor": "middle", "fill": "#0f172a"}, ).text = str(v)
        ET.SubElement(svg, "text", {"x": str(x + bw // 3), "y": str(H - PAD + 16),
                                    "font-size": "12", "text-anchor": "middle", "fill": "#334155"}, ).text = tier
    ET.ElementTree(svg).write(path, encoding="utf-8", xml_declaration=True)
    return os.path.getsize(path)


def main() -> int:
    ap = argparse.ArgumentParser()
    ap.add_argument("--config", default="config/")
    ap.add_argument("--out", default="out/")
    args = ap.parse_args()

    cfg = load(args.config)
    os.makedirs(args.out, exist_ok=True)
    weight = cfg["riskWeight"]
    all_buckets = cfg["screenBuckets"]
    all_inputs = cfg["inputMethods"]
    all_mem = cfg["memoryBucketsMB"]
    devices = cfg["devices"]

    covered_buckets = {d["bucket"] for d in devices}
    covered_inputs = {m for d in devices for m in d["input"]}
    covered_mem = {d["memoryMB"] for d in devices}

    tier_order = ["low", "mid", "high"]
    tier_counts = {t: 0 for t in tier_order}
    tier_scores = {t: 0 for t in tier_order}
    per_device = []
    risk_weighted_must = 0
    for d in devices:
        rw = weight[d["riskLevel"]]
        score = rw * len(d["mustTest"])
        risk_weighted_must += score
        tier_counts[d["tier"]] += 1
        tier_scores[d["tier"]] += score
        per_device.append({
            "id": d["id"], "tier": d["tier"], "bucket": d["bucket"], "memoryMB": d["memoryMB"],
            "input": "+".join(d["input"]), "riskLevel": d["riskLevel"], "riskWeight": rw,
            "mustTest": len(d["mustTest"]), "risks": len(d["risks"]), "riskScore": score,
        })

    report = {
        "version": 1, "source": "sim/e15_devices.py", "tier": "S",
        "seed": cfg["seed"], "deviceCount": len(devices),
        "tierCounts": tier_counts, "tierRiskScores": tier_scores,
        "screenCoverage": {"covered": len(covered_buckets), "total": len(all_buckets),
                           "pct": pct(len(covered_buckets), len(all_buckets))},
        "inputCoverage": {"covered": len(covered_inputs), "total": len(all_inputs),
                          "pct": pct(len(covered_inputs), len(all_inputs))},
        "memoryCoverage": {"covered": len(covered_mem), "total": len(all_mem),
                           "pct": pct(len(covered_mem), len(all_mem))},
        "riskWeightedMustTest": risk_weighted_must,
        "perDevice": per_device,
    }
    with open(os.path.join(args.out, "e15_devices.json"), "w", encoding="utf-8") as f:
        json.dump(report, f, ensure_ascii=False, indent=2)

    with open(os.path.join(args.out, "e15_devices.csv"), "w", encoding="utf-8", newline="") as f:
        w = csv.writer(f)
        w.writerow(["id", "tier", "bucket", "memoryMB", "input", "riskLevel", "riskWeight",
                    "mustTest", "risks", "riskScore"])
        for r in per_device:
            w.writerow([r["id"], r["tier"], r["bucket"], r["memoryMB"], r["input"], r["riskLevel"],
                        r["riskWeight"], r["mustTest"], r["risks"], r["riskScore"]])

    svg_bytes = write_svg(os.path.join(args.out, "e15_devices.svg"), tier_order, tier_scores)
    report["svgBytes"] = svg_bytes

    print(json.dumps({
        "deviceCount": len(devices), "tierCounts": tier_counts, "tierRiskScores": tier_scores,
        "screenCoveragePct": report["screenCoverage"]["pct"],
        "inputCoveragePct": report["inputCoverage"]["pct"],
        "memoryCoveragePct": report["memoryCoverage"]["pct"],
        "riskWeightedMustTest": risk_weighted_must, "svgBytes": svg_bytes,
    }, ensure_ascii=False, indent=2))
    print(f"== 兼容矩阵:设备 {len(devices)} / 风险加权必测 {risk_weighted_must}"
          f" / 覆盖 屏幕{report['screenCoverage']['pct']}% 输入{report['inputCoverage']['pct']}% 内存{report['memoryCoverage']['pct']}%"
          f"(同机相对量级,绝对数值不可移植)")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

本机实跑输出如下(同机相对量级,绝对数值不可移植):

$ /opt/homebrew/bin/python3 sim/e15_devices.py --config config/ --out out/
{ "deviceCount": 8, "tierCounts": { "low": 2, "mid": 4, "high": 2 },
  "tierRiskScores": { "low": 37, "mid": 29, "high": 3 },
  "screenCoveragePct": 100, "inputCoveragePct": 100, "memoryCoveragePct": 100,
  "riskWeightedMustTest": 69, "svgBytes": 926 }
== 兼容矩阵:设备 8 / 风险加权必测 69 / 覆盖 屏幕100% 输入100% 内存100%

读输出:覆盖度与负担是两件事

先看三个 100%:屏幕档(narrow / standard / wide / tablet)、输入方式(touch / mouse / gamepad)、内存档(2G / 4G / 8G)都覆盖满了。覆盖度是「有没有空白」的检查——如果某个维度只有 67%,说明有一档硬件完全没被任何设备覆盖,那是盲区,得补一台。100% 不代表「测够了」,只代表「每一档都至少有一台代表机」。

再看 tierRiskScores:low=37 / mid=29 / high=3。这是风险加权负担,不是设备数。低档位只有 2 台设备,却占了最大的加权负担——因为它们风险等级高(权重 7)、每台必测项又多(4 项和 3 项)。这正是真机测试该有的取向:低端机风险高、必测项多,应该花最多时间;高端机风险低、必测项少,扫一眼即可。如果把测试时间平均分配,就等于把最可能出事的地方测得最少。

把这两件事分开看很重要:覆盖度回答「有没有漏掉一档硬件」,加权负担回答「时间该往哪儿投」。前者是穷举检查,后者是优先级排序,缺一不可。

小结与下一步

这一节把「真机测试」从「凭感觉挑几台」变成了可统计的矩阵:设备档位、屏幕、内存、输入、已知风险、必测项写进一份配置,仿真算出三维覆盖度与风险加权负担。至此本章的三层测试就齐了——逻辑层用单元测试锁边界与不变量,链路层用回放断言两侧摘要一致,设备层用矩阵覆盖硬件档位。下一章转向工程化:代码怎么组织、类型怎么保证安全、配置怎么驱动、发布怎么进 CI。

阅读导航:上一节:端到端自动化与回放 · 下一节:代码组织与类型安全 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「minigame」更多文章

  1. 附录 F:全书公式、参数与数据集索引
  2. 9.3 同意管理与数据出境
  3. 9.2 COPPA 与儿童向游戏的限制