站内有一篇广告 SDK 接入与瀑布流聚合 ,那是单一网络、单一引擎口径的接入笔记:怎么挂 SDK、回调怎么写。本节把它推广到多网络的横向对照:穿山甲、优量汇、AdMob 三家的接入面到底差在哪,这些差异如何影响你在聚合层里要写的适配器数量与字段数。另有 SaaS 小游戏平台 那 25 篇,是做平台的口径(编辑器、融资、平台生态);本节的视角是在平台上做一款游戏、去接第三方广告网络,两者的坐标完全不同,不重复。
5.1 讲了聚合层的三层架构,其中适配器层是「换网络只加一个适配器」的关键。但这句话有个隐含前提:每家网络的适配器长得不一样。有的初始化要六步,有的四步就够;有的要在标准接口之外多填一堆额外字段;有的能力位齐、有的缺一两个。这些差异不是细节,它们直接决定你的接入工时、包体增量、以及能力覆盖的缺口。这一节就把这些差异摊开、量化。
为什么接入差异值得单独讲
一个常见的误判是「接网络不就是照着文档抄一遍吗」。真到了工程里你会发现三件事:
第一,初始化步骤数直接等于联调工时。有的网络要求先初始化、再登录、再拉配置、再预加载,每一步都有回调顺序要求;顺序错了就是黑屏或填充率 0。
第二,额外字段数是维护成本。标准聚合接口只认「appId / 广告位 id / 回调」这几个字段,但总有些网络要求你在配置里补上「渠道号、包名、密钥、自定义参数」之类的非标准字段。字段越多,配置越容易填错。
第三,能力位缺口是功能缺口。你的广告位计划里可能既有激励视频又有插屏、又有横幅;如果某家网络缺了某一类,你的瀑布在这一层就只能跳过。
三家网络的能力矩阵
下面这张表是建模用的登记矩阵,口径是「公开可观察机制」的假设值,观察时点 2026-10,不是任何官方后台的实测。它登记了六个能力位与三类接入面指标,用来做横向对照。
| 网络 | 区域 | 初始化步骤 | 额外字段 | SDK 体积(KB) | 激励视频 | 插屏 | 横幅 | 原生 | 开屏 | 竞价 |
|---|---|---|---|---|---|---|---|---|---|---|
| 穿山甲 | 国内 | 6 | 4 | 2400 | ✔ | ✔ | ✔ | ✔ | ✔ | ✔ |
| 优量汇 | 国内 | 5 | 3 | 2100 | ✔ | ✔ | ✔ | ✔ | — | ✔ |
| AdMob | 海外 | 4 | 2 | 3300 | ✔ | ✔ | ✔ | ✔ | ✔ | — |
矩阵本身只说明「谁有什么」,不说明「接哪家划算」。要横向比较,得把三类指标折成一个可比的整数分——这就是下一节的接入成本。
接入成本的整数公式
把三类指标加权成一个 effort,单位分,越低越好:
satisfiedCaps = Σ caps[k] # 六个能力位登记值之和
missingCaps = len(capKeys) - satisfiedCaps
effort = initSteps × w.initStep
+ extraFields × w.extraField
+ (sdkSizeKb / 100) × w.sdkPer100Kb # 整数地板除
+ missingCaps × w.missingCap
coveragePermille = satisfiedCaps × 1000 / len(capKeys) # 能力覆盖率
权重放在 config 里(w.initStep / w.extraField / w.missingCap / w.sdkPer100Kb),这样「我更在意包体还是更在意工时」可以只改权重、不改代码。注意 sdkSizeKb / 100 是整数地板除:每满 100KB 计一分,避免浮点,也让大体积 SDK 的惩罚随体积线性增长。
S 层:接入成本评分
下面这段是 S 层仿真脚本 sim/o05_adapter_score.py。它读 config/o05_adapters.json 的三家网络,按上面的整数公式算出各自的接入成本、能力覆盖率与缺口数,并产出 JSON + CSV + SVG。
#!/usr/bin/env python3
# file: sim/o05_adapter_score.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/o05_adapter_score.py --config config/ --out out/
"""广告网络接入差异评分:把「三个网络的接入成本」折成一个可比较的整数分。
对 config/o05_adapters.json 的每个网络:
satisfiedCaps = Σ caps[k] # 六个能力位登记值之和
missingCaps = len(capKeys) - satisfiedCaps
effort = initSteps * w.initStep
+ extraFields * w.extraField
+ (sdkSizeKb // 100) * w.sdkPer100Kb
+ missingCaps * w.missingCap # 加权接入成本(分越低越好)
coveragePermille = satisfiedCaps * 1000 // len(capKeys) # 能力覆盖率
effort 只用于横向排序,不代表任何官方口径。全程整数、地板除,无浮点。
产出 out/o05_adapter_score.json + .csv + .svg。
接入参数是「公开可观察机制」口径的建模假设;真实后台配置、真实审核结果需真实
业务环境,命令行不可复现(X)。
"""
from __future__ import annotations
import argparse
import csv
import json
import os
import xml.etree.ElementTree as ET
def score(net: dict, cap_keys: list[str], w: dict) -> dict:
satisfied = sum(net["caps"].get(k, 0) for k in cap_keys)
missing = len(cap_keys) - satisfied
effort = (net["initSteps"] * w["initStep"]
+ net["extraFields"] * w["extraField"]
+ (net["sdkSizeKb"] // 100) * w["sdkPer100Kb"]
+ missing * w["missingCap"])
coverage = satisfied * 1000 // len(cap_keys)
return {
"id": net["id"], "name": net["name"], "region": net["region"],
"initSteps": net["initSteps"], "extraFields": net["extraFields"],
"sdkSizeKb": net["sdkSizeKb"], "satisfiedCaps": satisfied, "missingCaps": missing,
"effort": effort, "coveragePermille": coverage,
}
def draw_svg(path: str, rows: list[dict]) -> int:
"""纯 Python SVG:每个网络的接入成本横条 + 能力覆盖率标注。"""
W, H, PAD, ROW = 760, 88 + 40 * len(rows), 150, 40
root = ET.Element("svg", {"xmlns": "http://www.w3.org/2000/svg", "width": str(W),
"height": str(H), "viewBox": f"0 0 {W} {H}"})
ET.SubElement(root, "rect", {"width": str(W), "height": str(H), "fill": "#ffffff"})
ET.SubElement(root, "text", {"x": "16", "y": "28", "font-size": "15", "fill": "#111827"}).text = (
"《合成小岛》三大广告网络接入成本(分越低越好)与能力覆盖率")
span = W - PAD - 24
hi = max([r["effort"] for r in rows] + [1])
for i, r in enumerate(rows):
y = 56 + i * ROW
ET.SubElement(root, "text", {"x": "16", "y": str(y + 18), "font-size": "12",
"fill": "#374151"}).text = f'{r["name"]}({r["region"]})'
w = span * r["effort"] // hi
ET.SubElement(root, "rect", {"x": str(PAD), "y": str(y + 4), "width": str(w),
"height": "20", "rx": "3", "fill": "#0ea5e9"})
ET.SubElement(root, "text", {"x": str(PAD + w + 6), "y": str(y + 18), "font-size": "11",
"fill": "#6b7280"}).text = (
f'effort {r["effort"]} · 覆盖 {r["coveragePermille"]}‰ · 缺 {r["missingCaps"]} 位')
os.makedirs(os.path.dirname(path), exist_ok=True)
ET.ElementTree(root).write(path, encoding="utf-8", xml_declaration=True)
return len(ET.tostring(root))
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--config", default="config/")
ap.add_argument("--out", default="out/")
args = ap.parse_args()
with open(os.path.join(args.config, "o05_adapters.json"), encoding="utf-8") as f:
cfg = json.load(f)
rows = [score(n, cfg["capKeys"], cfg["weights"]) for n in cfg["networks"]]
cheapest = min(rows, key=lambda r: r["effort"])
digest = ",".join(f'{r["id"]}:{r["initSteps"]}:{r["extraFields"]}:'
f'{r["satisfiedCaps"]}:{r["effort"]}' for r in rows)
os.makedirs(args.out, exist_ok=True)
svg_bytes = draw_svg(os.path.join(args.out, "o05_adapter_score.svg"), rows)
report = {"version": cfg["version"], "game": cfg["game"], "observedAt": cfg["observedAt"],
"capKeys": cfg["capKeys"], "weights": cfg["weights"], "networks": rows,
"cheapestEffort": cheapest["id"], "digest": digest,
"svg": os.path.join(args.out, "o05_adapter_score.svg"), "svgBytes": svg_bytes}
with open(os.path.join(args.out, "o05_adapter_score.json"), "w", encoding="utf-8") as f:
json.dump(report, f, ensure_ascii=False, indent=2)
with open(os.path.join(args.out, "o05_adapter_score.csv"), "w", encoding="utf-8", newline="") as f:
w = csv.writer(f)
cols = ("id", "name", "region", "initSteps", "extraFields", "sdkSizeKb",
"satisfiedCaps", "missingCaps", "effort", "coveragePermille")
w.writerow(cols)
for r in rows:
w.writerow([r[k] for k in cols])
print(json.dumps({"digest": digest, "cheapestEffort": cheapest["id"],
"networks": rows}, ensure_ascii=False, indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())
接入参数放在 config/o05_adapters.json。再次强调:这里的能力位与接入面数字是「公开可观察机制」口径的建模假设,不是官方文档的实测口径;真实后台配置、真实审核结果属于 X 层。
{
"version": 1,
"game": "merge-island",
"observedAt": "2026-10",
"note": "三大广告网络的接入差异登记表。口径是「公开可观察机制」的建模假设:initSteps=接入所需的独立初始化/回调步骤数;extraFields=标准聚合接口之外需额外填写的字段数;sdkSizeKb=SDK 体积(KB);caps 是六个能力位的 0/1 登记值(观察时点 2026-10,非实测)。effort 是加权接入成本,仅用于横向排序,不代表任何官方口径。真实后台配置、真实审核结果需真实业务环境,命令行不可复现(X)。全整数,无浮点。",
"capKeys": ["rewarded", "interstitial", "banner", "native", "splash", "bidding"],
"weights": { "initStep": 3, "extraField": 2, "missingCap": 5, "sdkPer100Kb": 1 },
"networks": [
{ "id": "pangle", "name": "穿山甲", "region": "cn", "initSteps": 6, "extraFields": 4, "sdkSizeKb": 2400,
"caps": { "rewarded": 1, "interstitial": 1, "banner": 1, "native": 1, "splash": 1, "bidding": 1 } },
{ "id": "ylh", "name": "优量汇", "region": "cn", "initSteps": 5, "extraFields": 3, "sdkSizeKb": 2100,
"caps": { "rewarded": 1, "interstitial": 1, "banner": 1, "native": 1, "splash": 0, "bidding": 1 } },
{ "id": "admob", "name": "AdMob", "region": "global", "initSteps": 4, "extraFields": 2, "sdkSizeKb": 3300,
"caps": { "rewarded": 1, "interstitial": 1, "banner": 1, "native": 1, "splash": 1, "bidding": 0 } }
]
}
在本机跑 /opt/homebrew/bin/python3 sim/o05_adapter_score.py --config config/ --out out/,跑出来的结果(同机相对量级,绝对数值不可移植):
| 网络 | 区域 | 初始化步骤 | 额外字段 | SDK(KB) | 能力覆盖‰ | 缺口位 | 接入成本 |
|---|---|---|---|---|---|---|---|
| 穿山甲 | 国内 | 6 | 4 | 2400 | 1000 | 0 | 50 |
| 优量汇 | 国内 | 5 | 3 | 2100 | 833 | 1 | 47 |
| AdMob | 海外 | 4 | 2 | 3300 | 833 | 1 | 54 |
结论有点反直觉:穿山甲能力位最全(覆盖 1000‰、零缺口),接入成本却不是最低——它初始化步骤最多、额外字段最多,把成本推到了 50。优量汇成本最低(47):步骤和字段都更少,虽然缺一个开屏能力位,但按当前权重它仍是横向最省的一家。AdMob 成本最高(54):步骤最少、字段最少,但 SDK 体积最大(3300KB → 33 分),再加上缺一个竞价能力位,反而垫底。这张表的意义不是「选优量汇」,而是说明:接入成本是三类指标的加权,谁最省取决于你的权重——如果你把包体看得比工时重,结论立刻翻转。
P 层:适配器归一化,跨语言对拍
S 层算出了成本,但真正要落地的是适配器层:把三家异构的配置归一成一套统一接口。下面这段是 P 层 TypeScript 内核 core/src/o05/adapter.ts,它读同一份 config/o05_adapters.json,把每个网络的原始配置归一成 AdapterSpec,并复算同一套成本分——和 Python 逐字符一致。
// file: core/src/o05/adapter.ts
// tier: P
// verify: node --test 'core/test/o05_adapter.test.ts'
//
// 广告网络适配层内核:把异构的接入登记表归一成 AdapterSpec,并复算接入成本分。
// 与 sim/o05_adapter_score.py 逐位等价;读同一份 config/o05_adapters.json。
// 全整数、地板除、无浮点;只用可擦除语法(禁 enum / namespace)。
export interface RawNetwork {
readonly id: string; readonly name: string; readonly region: string;
readonly initSteps: number; readonly extraFields: number; readonly sdkSizeKb: number;
readonly caps: Readonly<Record<string, number>>;
}
export interface AdapterWeights {
readonly initStep: number; readonly extraField: number;
readonly missingCap: number; readonly sdkPer100Kb: number;
}
export interface AdapterConfig {
readonly version: number; readonly game: string;
readonly capKeys: readonly string[];
readonly weights: AdapterWeights;
readonly networks: readonly RawNetwork[];
}
/** 归一后的适配器规格:聚合层只认这套字段,不再看各网络的原生配置。 */
export interface AdapterSpec {
readonly id: string; readonly name: string; readonly region: string;
readonly initSteps: number; readonly extraFields: number; readonly sdkSizeKb: number;
readonly caps: readonly string[]; readonly satisfiedCaps: number; readonly missingCaps: number;
readonly coveragePermille: number; readonly effort: number;
}
export function normalize(net: RawNetwork, capKeys: readonly string[],
weights: AdapterWeights): AdapterSpec {
const caps = capKeys.filter((k) => (net.caps[k] ?? 0) === 1);
const satisfiedCaps = caps.length;
const missingCaps = capKeys.length - satisfiedCaps;
const effort = net.initSteps * weights.initStep
+ net.extraFields * weights.extraField
+ Math.floor(net.sdkSizeKb / 100) * weights.sdkPer100Kb
+ missingCaps * weights.missingCap;
const coveragePermille = Math.floor((satisfiedCaps * 1000) / capKeys.length);
return { id: net.id, name: net.name, region: net.region,
initSteps: net.initSteps, extraFields: net.extraFields, sdkSizeKb: net.sdkSizeKb,
caps, satisfiedCaps, missingCaps, coveragePermille, effort };
}
export function normalizeAll(cfg: AdapterConfig): AdapterSpec[] {
return cfg.networks.map((n) => normalize(n, cfg.capKeys, cfg.weights));
}
/** 跨语言对拍摘要:与 sim/o05_adapter_score.py 的 digest 逐字符一致。 */
export function digest(specs: readonly AdapterSpec[]): string {
return specs.map((s) => `${s.id}:${s.initSteps}:${s.extraFields}:${s.satisfiedCaps}:${s.effort}`)
.join(",");
}
/** 接入成本最低的网络(并列时取先出现的,与 Python 的 min 语义一致)。 */
export function cheapestEffort(specs: readonly AdapterSpec[]): string {
let best = specs[0];
for (const s of specs) if (s.effort < best.effort) best = s;
return best.id;
}
配套测试 core/test/o05_adapter.test.ts 把 Python 落盘的摘要写成金标准,断言 TS 归一化结果逐字符相等,并验能力位过滤与成本复算。在本机跑 node --test 'core/test/o05_adapter.test.ts',5 个用例全过;两套独立实现给出同一个摘要:
digest = pangle:6:4:6:50,ylh:5:3:5:47,admob:4:2:5:54
这份摘要也是适配器回归测试的基准:以后升级某个网络的 SDK、改了初始化步骤数,只要重跑一次、对比摘要,就能立刻看出接入成本变了多少。这正是卷二「配置驱动数据表」 那个主张在变现侧的延续——把易变的平台差异关进配置,用可复现的摘要守住回归。
X 层:真实后台与审核不可复现
- 真实的初始化步骤数:官方文档与实际联调能对上的只是主干,很多网络的「必填步骤」藏在客服口头指引或控制台提示里,命令行不可复现(X);
- 真实的额外字段:随平台版本、账号类型、投放区域变化,且部分字段只在大陆账号 / 海外账号上出现;
- 真实的能力位与审核结果:某类广告形态在某个平台是否可用、某个账号能否开通竞价,取决于资质与审核,需真实业务环境。
工程上正确的做法是:把上面这些差异登记进配置(就是 config/o05_adapters.json 那张表),用模型层做「换网络要改多少」的预估,用真实联调结果去反标定登记值。这张登记表本身就是一份可维护的资产——它把散落在文档、客服、控制台里的差异,收敛成一份可版本管理的整数表。
自检表
| 检查项 | 合格标准 |
|---|---|
| 矩阵口径清晰 | 能力位标「公开可观察机制」+ 观察时点,不写「实测」 |
| 权重可调 | 三类指标的权重全在 config 里,改权重不改代码 |
| 成本可复算 | effort 由公式唯一确定,Python / TS 结果一致 |
| 归一化到位 | 原始配置 → AdapterSpec,聚合层只认统一接口 |
| 回归有基准 | 摘要可作适配器升级的回归基线 |
| 边界清晰 | 真实后台 / 审核标 X,不写成「实测」 |
接入差异讲清楚了,但还有一个更实操的问题没回答:瀑布的顺序怎么排、底价怎么定、什么时候该从瀑布切到竞价。这正是下一节的主题——把瀑布与竞价放在同一批请求上对照,看收入差从哪里来、又受什么约束。
阅读导航:上一节:5.1 聚合架构:SDK、适配器与瀑布流 · 下一节:5.3 瀑布流与竞价配置 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。