10.3 多平台差异与兼容矩阵

把微信 / 抖音 / QQ / 百度与浏览器摆成一张五维能力兼容矩阵:登录、支付、广告形态、分享、云存档逐平台对比,每个平台再配一份「已知差异」清单,并用可跑的 S 层脚本统计差异条目数、按复用系数估算边际适配工时与总成本,并给出差异维度分布与三个可执行结论。

站内已有一篇小游戏开发综述 ,那篇是平台能力的单篇综述,把「各平台都有登录支付广告」一句话带过;本章给的是可跑、可断言的抽象接口 + 两套宿主实现,并把平台差异摆成一张可统计、可估工时的矩阵。另有一组 25 篇的小游戏 SaaS 平台 ,那 25 篇是做一个游戏服务平台(SaaS 创业口径:BP / 融资 / 编辑器);本章是在平台上做一款游戏时,如何把平台 API 抽象成可测试的接口,口径完全不同。

前两节把平台能力收成了接口(10.1),又证明了同一份逻辑能在两种宿主上跑出同一行摘要(10.2)。但真实世界不止两个平台:微信、抖音、QQ、百度,加上浏览器,每个宿主的登录、支付、广告、分享、云存档都各差一点。这一节把差异摆成一张兼容矩阵,再算清楚适配它们要付多少代价。

五个能力维度与「已知差异」

把平台能力拆成五个维度,每个维度记一个公开可观察的机制:登录方式(wx.login 换 openId、tt.login 换 openId、OAuth 跳转)、支付(米大师、平台收银台、聚合支付、网页收银台)、广告形态(激励视频 / 插屏 / Banner 的组合)、分享(好友 / 群 / 朋友圈 / 录屏分享各平台支持度不同)、云存档(几乎都是 setUserCloudStorage 的 KV,但键名规则不通用)。

除这五维之外,还有一个**「已知差异」清单**:那些无法塞进一个单元格、但一定会咬人的细节,比如「微信分享回调不再返回是否成功,只能按回前台推断」「抖音云存档键名规则与微信不通用,须映射层」。这张矩阵不是「五个格子打勾」,而是五个维度 + 一份差异清单,后者往往才是真正吃掉工时的部分。

必须把话说死:矩阵里的平台单元格是公开可观察机制(观察时点 2026-10),真实调用签名与配额须在真机与官方文档核对,本节不做、也不能做「实测」。权重与复用系数则是本模型自设的输入,不是平台数据。

D 层:多平台兼容矩阵

下面这段是 D 层(静态层)产物,一份平台兼容矩阵配置。五个维度各带权重(登录 / 支付 / 云存档更重,广告 / 分享较轻),每个平台带一份 knownDiff 已知差异清单。它没有代码可跑,只做 schema 与 lint 校验。

{
  "version": 1,
  "game": "merge-island",
  "seed": 20261012,
  "note": "多平台能力兼容矩阵。平台单元格是「公开可观察机制」(观察时点 2026-10),真实调用签名与配额须在真机 / 官方文档核对(X)。权重与复用系数是本模型自设的输入,不是平台数据。",
  "baseline": "web",
  "costPerWeight": 5,
  "dimensions": [
    { "id": "login", "name": "登录方式", "weight": 3 },
    { "id": "pay", "name": "支付", "weight": 3 },
    { "id": "ad", "name": "广告形态", "weight": 2 },
    { "id": "share", "name": "分享", "weight": 2 },
    { "id": "cloudSave", "name": "云存档", "weight": 3 }
  ],
  "platforms": [
    {
      "id": "web", "name": "浏览器", "reusePct": 0,
      "attrs": {
        "login": "OAuth 跳转", "pay": "网页收银台", "ad": "展示位 / 视频贴片",
        "share": "navigator.share", "cloudSave": "localStorage / IndexedDB"
      },
      "knownDiff": []
    },
    {
      "id": "wechat", "name": "微信小游戏", "reusePct": 100,
      "attrs": {
        "login": "wx.login 换 openId", "pay": "米大师(需先绑定)", "ad": "激励视频 / 插屏 / Banner",
        "share": "shareAppMessage / 朋友圈", "cloudSave": "setUserCloudStorage KV"
      },
      "knownDiff": [
        "支付走米大师,须先完成商户与账号绑定",
        "云存档为 KV,单键有容量上限,须自行分片",
        "分享回调不再返回是否成功,只能按回前台推断"
      ]
    },
    {
      "id": "douyin", "name": "抖音小游戏", "reusePct": 45,
      "attrs": {
        "login": "tt.login 换 openId", "pay": "tt.pay 平台收银", "ad": "激励视频 / 插屏",
        "share": "shareAppMessage / 录屏分享", "cloudSave": "setUserCloudStorage KV"
      },
      "knownDiff": [
        "登录与支付 API 名与微信同构但不同命名空间",
        "录屏分享有独立权限申请流程",
        "云存档键名规则与微信不通用,须映射层"
      ]
    },
    {
      "id": "qq", "name": "QQ 小游戏", "reusePct": 45,
      "attrs": {
        "login": "qq.login 换 openId", "pay": "qq.requestMidasPayment", "ad": "激励视频 / 插屏",
        "share": "shareAppMessage / 群分享", "cloudSave": "setUserCloudStorage KV"
      },
      "knownDiff": [
        "支付同样走米大师,但参数集与微信不同",
        "群分享与好友分享是两条不同通道",
        "部分广告位需在后台单独开通"
      ]
    },
    {
      "id": "baidu", "name": "百度小游戏", "reusePct": 70,
      "attrs": {
        "login": "swan.login 换 openId", "pay": "requestPolymerPayment", "ad": "激励视频 / 插屏",
        "share": "shareAppMessage", "cloudSave": "setUserCloudStorage KV"
      },
      "knownDiff": [
        "支付为聚合支付,渠道差异更大",
        "分享形态较少,无朋友圈等价通道"
      ]
    }
  ]
}

权重不是平台属性,是工程判断:改一个支付通道(pay,权重 3)比改一个广告位形态(ad,权重 2)影响面大,所以前者更重。reusePct 是平台复用系数——它衡量「这个平台的适配有多少能落在已有抽象层上」,是策略输入,不是平台事实:微信作首发平台,抽象层为它而建,所以记 100;抖音 / QQ 命名空间不同但结构同构,记 45;百度支付是聚合支付、分享形态更少,记 70。

S 层:统计差异条目与适配成本

有了矩阵,下一步是把它变成数字。下面这段是 S 层(仿真层)脚本:以 web 为参照,逐平台统计在哪些维度上存在差异,按权重换算「基础适配工时」,再乘复用系数得到「边际适配工时」,并统计每平台的「已知差异」条目数。全整数运算,图表用纯 Python SVG。

#!/usr/bin/env python3
# file: sim/e10_compat.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/e10_compat.py --config config/ --out out/
"""多平台能力兼容矩阵统计与适配成本估算。

读 config/e10_platforms.json:以 baseline 平台为参照,逐平台统计在哪些能力维度
(登录 / 支付 / 广告 / 分享 / 云存档)上存在差异,按维度权重换算「基础适配工时」,
再乘平台复用系数得到「边际适配工时」,并统计每平台的「已知差异」条目数。
全整数运算,图表用纯 Python SVG。

产出:out/e10_compat.json + out/e10_compat_platforms.csv
     + out/e10_compat_dims.csv + out/e10_compat.svg
"""

from __future__ import annotations

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


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


def draw_svg(path, rows, cost_per_weight):
    """纯 Python SVG:每个平台一根横条,长度正比于边际适配工时。"""
    W, H, PAD, ROW = 760, 70 + 46 * len(rows), 170, 46
    peak = max((r["adaptedHours"] for r in rows), default=1) or 1
    svg = ET.Element(
        "svg",
        {"xmlns": "http://www.w3.org/2000/svg", "width": str(W), "height": str(H),
         "viewBox": f"0 0 {W} {H}"},
    )
    ET.SubElement(svg, "rect", {"width": str(W), "height": str(H), "fill": "#ffffff"})
    ET.SubElement(svg, "text", {"x": "16", "y": "28", "font-size": "15", "fill": "#111827"}).text = (
        "各平台能力适配边际工时(小时,每权重 %d 小时)" % cost_per_weight
    )
    bar_max = W - PAD - 30
    for i, r in enumerate(rows):
        y = 54 + i * ROW
        ET.SubElement(svg, "text", {"x": "16", "y": str(y + 20), "font-size": "12",
                                    "fill": "#374151"}).text = r["name"]
        w = (bar_max * r["adaptedHours"]) // peak
        ET.SubElement(svg, "rect", {"x": str(PAD), "y": str(y + 6), "width": str(w), "height": "22",
                                    "rx": "4", "fill": "#0d9488"})
        ET.SubElement(svg, "text", {"x": str(PAD + w + 8), "y": str(y + 22), "font-size": "12",
                                    "fill": "#6b7280"}).text = "%d 项差异 · %d 条已知差异 · %d h" % (
            r["diffCount"], r["knownDiffCount"], r["adaptedHours"])
    os.makedirs(os.path.dirname(path), exist_ok=True)
    ET.ElementTree(svg).write(path, encoding="utf-8", xml_declaration=True)
    return len(ET.tostring(svg))


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)
    dims = cfg["dimensions"]
    base_id = cfg["baseline"]
    cost = cfg["costPerWeight"]
    by_id = {p["id"]: p for p in cfg["platforms"]}
    base = by_id[base_id]["attrs"]

    rows = []
    for p in cfg["platforms"]:
        if p["id"] == base_id:
            continue
        diff = [d for d in dims if p["attrs"][d["id"]] != base[d["id"]]]
        weight = sum(d["weight"] for d in diff)
        base_hours = weight * cost
        adapted_hours = (base_hours * p["reusePct"]) // 100
        rows.append({
            "id": p["id"], "name": p["name"], "reusePct": p["reusePct"],
            "diffIds": [d["id"] for d in diff], "diffCount": len(diff),
            "diffWeight": weight, "baseHours": base_hours, "adaptedHours": adapted_hours,
            "knownDiffCount": len(p["knownDiff"]), "knownDiff": p["knownDiff"],
        })

    distinct = sorted({d for r in rows for d in r["diffIds"]})
    report = {
        "version": cfg["version"], "game": cfg["game"], "seed": cfg["seed"],
        "baseline": base_id, "costPerWeight": cost,
        "dimensionCount": len(dims), "platformCount": len(cfg["platforms"]),
        "distinctDiffDimensions": distinct,
        "distinctDiffCount": len(distinct),
        "totalBaseHours": sum(r["baseHours"] for r in rows),
        "totalAdaptedHours": sum(r["adaptedHours"] for r in rows),
        "totalKnownDiff": sum(r["knownDiffCount"] for r in rows),
        "platforms": rows,
    }

    os.makedirs(args.out, exist_ok=True)
    svg_bytes = draw_svg(os.path.join(args.out, "e10_compat.svg"), rows, cost)
    report["svg"] = os.path.join(args.out, "e10_compat.svg")
    report["svgBytes"] = svg_bytes
    with open(os.path.join(args.out, "e10_compat.json"), "w", encoding="utf-8") as f:
        json.dump(report, f, ensure_ascii=False, indent=2)
    with open(os.path.join(args.out, "e10_compat_platforms.csv"), "w", encoding="utf-8", newline="") as f:
        w = csv.writer(f)
        w.writerow(["id", "name", "reusePct", "diffCount", "diffWeight", "baseHours",
                    "adaptedHours", "knownDiffCount", "diffIds"])
        for r in rows:
            w.writerow([r["id"], r["name"], r["reusePct"], r["diffCount"], r["diffWeight"],
                        r["baseHours"], r["adaptedHours"], r["knownDiffCount"], "|".join(r["diffIds"])])
    with open(os.path.join(args.out, "e10_compat_dims.csv"), "w", encoding="utf-8", newline="") as f:
        w = csv.writer(f)
        w.writerow(["id", "name", "weight", "webValue"])
        for d in dims:
            w.writerow([d["id"], d["name"], d["weight"], base[d["id"]]])

    print(json.dumps({k: report[k] for k in
                      ("distinctDiffCount", "totalBaseHours", "totalAdaptedHours", "totalKnownDiff")},
                     ensure_ascii=False))
    return 0


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

实跑输出

在本机跑它,输出是这样的(同机相对量级,绝对数值不可移植):

{"distinctDiffCount": 5, "totalBaseHours": 260, "totalAdaptedHours": 168, "totalKnownDiff": 11}

同时落盘 out/e10_compat_platforms.csv(每平台一行)。它说明「每个平台差在哪、要花多少工时、有几条已知差异」:

id,name,reusePct,diffCount,diffWeight,baseHours,adaptedHours,knownDiffCount,diffIds
wechat,微信小游戏,100,5,13,65,65,3,login|pay|ad|share|cloudSave
douyin,抖音小游戏,45,5,13,65,29,3,login|pay|ad|share|cloudSave
qq,QQ 小游戏,45,5,13,65,29,3,login|pay|ad|share|cloudSave
baidu,百度小游戏,70,5,13,65,45,2,login|pay|ad|share|cloudSave

维度表 out/e10_compat_dims.csv 记的是每个维度的权重与浏览器侧的参照值,它是矩阵的「另一半」——平台表说明「差在哪」,维度表说明「这些差各值多少」:

id,name,weight,webValue
login,登录方式,3,OAuth 跳转
pay,支付,3,网页收银台
ad,广告形态,2,展示位 / 视频贴片
share,分享,2,navigator.share
cloudSave,云存档,3,localStorage / IndexedDB

数字读出来的三个结论

第一,四家小游戏平台在五个维度上全部与浏览器不同,差异维度完全相同。 distinctDiffCount = 5,每个平台 diffCount 都是 5、diffWeight 都是 13。这不像 6.3 的输入矩阵那样有「原点」这个对照维度——能力层几乎没有哪一格是平台间通用的,登录、支付、广告、分享、云存档,各家都用自己的 API。差异维度完全相同,意味着一套抽象层可以同时覆盖四家,不需要为每家写一套骨架。

第二,基础适配工时是 260 小时,但边际工时只有 168 小时。 totalBaseHours = 260 是「每家各付一次 13 权重 × 5 小时 = 65 小时」的朴素加总;totalAdaptedHours = 168 是引入复用系数后的结果。差值 92 小时就是抽象层的价值:微信(reusePct = 100)承担完整的抽象层建设成本 65 小时,抖音 / QQ(45%)各付 29 小时,百度(70%)付 45 小时。注意复用系数不是平台属性,而是工程策略——它回答的是「新平台的适配有多少能落在已有抽象层上」。

第三,11 条「已知差异」才是真正咬人的部分。 totalKnownDiff = 11(微信 3 条、抖音 3 条、QQ 3 条、百度 2 条)。它们不进任何权重换算,因为它们不是「改个 API 名」那么简单:米大师要先做账号绑定、云存档 KV 有容量上限要自己分片、分享回调拿不到是否成功只能按回前台推断。这些条目应该被逐条转成 15.3 兼容性设备矩阵里的验收项,而不是躺在文档里。

X 与真机核对:矩阵不是事实

必须再强调一次:矩阵里的平台单元格是公开可观察机制,不是本机实测。本机跑不出微信宿主,也跑不出抖音宿主,所以「微信的登录是 wx.login 换 openId」这类结论需要在真机上核对,本节把它整体标为 X(不可复现)。矩阵的价值不在于「它一定是准的」,而在于它给出了一个结构化的核对清单:拿到真机后,逐个平台逐个维度打勾,任何一格与预期不符,就说明抽象层要补一条分支。

这也是为什么配置里 note 字段特意写明「真实调用签名与配额须在真机 / 官方文档核对(X)」。工程上最危险的做法,是把一份「看起来很像真的」矩阵当成事实直接排期——权重可以拍、复用系数可以估,但平台行为必须真机验证。

把矩阵接回抽象层

矩阵不只是排期工具,它直接对应代码结构。10.1 的 PlatformAPI 五个方法——login / pay / showAd / share / cloudSave——正好对应矩阵的五个维度。也就是说,每个平台适配器要实现的就是这五个方法,矩阵里某一格与浏览器不同,适配器里那一个方法就要重写。knownDiff 里的每一条,则是适配器内部要额外处理的边界(分片、映射层、回前台推断)。

这就是平台适配层的完整闭环:10.1 定义接口,10.2 证明接口可换宿主,10.3 用矩阵说明真实世界有几个宿主、各差在哪、要花多少代价。三者合起来,才是「一份逻辑、两种宿主」从口号到工程方案的完整形态。下一章转向 UI 系统——那是玩家唯一直接看到的东西,也是抽象层之上第一块「看得见的」业务代码。

小结与下一步

这一节把多平台差异从「注意兼容」变成了三个可操作的产物:一张五维矩阵、一份可跑的统计脚本、一组带权重的适配工时估算与已知差异清单。四家小游戏平台的差异维度完全一致(5 维全差),抽象层可以一次覆盖;260 小时的朴素成本被复用系数压到 168 小时,差值就是抽象层的价值;而 11 条已知差异提示了真正吃工时的不是 API 改名,而是那些结构性的坑。

下一章进入 UI 系统:布局、层级、滚动。那是抽象层之上第一块玩家看得见的业务代码,也是「平台无关」这条线第一次接受真实渲染的检验。

阅读导航:上一节:一份逻辑两种宿主:mock 宿主与真机宿主 · 下一节:小游戏 UI 系统:布局 / 层级 / 滚动 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「minigame」更多文章

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