站内已有一篇小游戏开发综述 ,那篇是平台能力的单篇综述,把「各平台都有登录支付广告」一句话带过;本章给的是可跑、可断言的抽象接口 + 两套宿主实现,并把平台差异摆成一张可统计、可估工时的矩阵。另有一组 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 系统:布局 / 层级 / 滚动 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。