5.2 穿山甲、优量汇与 AdMob 的接入差异

三家主流广告网络的接入差异,决定了聚合层要写多少个适配器、每个适配器要填多少额外字段。本节把穿山甲、优量汇与 AdMob 的初始化步骤、额外字段、SDK 体积与能力位摊成一张可对照的矩阵,用可跑的 Python 评分脚本与 TypeScript 适配器内核把差异折成一个可比较的接入成本分。

站内有一篇广告 SDK 接入与瀑布流聚合 ,那是单一网络、单一引擎口径的接入笔记:怎么挂 SDK、回调怎么写。本节把它推广到多网络的横向对照:穿山甲、优量汇、AdMob 三家的接入面到底差在哪,这些差异如何影响你在聚合层里要写的适配器数量与字段数。另有 SaaS 小游戏平台 那 25 篇,是做平台的口径(编辑器、融资、平台生态);本节的视角是在平台上做一款游戏、去接第三方广告网络,两者的坐标完全不同,不重复。

5.1 讲了聚合层的三层架构,其中适配器层是「换网络只加一个适配器」的关键。但这句话有个隐含前提:每家网络的适配器长得不一样。有的初始化要六步,有的四步就够;有的要在标准接口之外多填一堆额外字段;有的能力位齐、有的缺一两个。这些差异不是细节,它们直接决定你的接入工时、包体增量、以及能力覆盖的缺口。这一节就把这些差异摊开、量化。

为什么接入差异值得单独讲

一个常见的误判是「接网络不就是照着文档抄一遍吗」。真到了工程里你会发现三件事:

第一,初始化步骤数直接等于联调工时。有的网络要求先初始化、再登录、再拉配置、再预加载,每一步都有回调顺序要求;顺序错了就是黑屏或填充率 0。

第二,额外字段数是维护成本。标准聚合接口只认「appId / 广告位 id / 回调」这几个字段,但总有些网络要求你在配置里补上「渠道号、包名、密钥、自定义参数」之类的非标准字段。字段越多,配置越容易填错。

第三,能力位缺口是功能缺口。你的广告位计划里可能既有激励视频又有插屏、又有横幅;如果某家网络缺了某一类,你的瀑布在这一层就只能跳过。

三家网络的能力矩阵

下面这张表是建模用的登记矩阵,口径是「公开可观察机制」的假设值,观察时点 2026-10,不是任何官方后台的实测。它登记了六个能力位与三类接入面指标,用来做横向对照。

网络区域初始化步骤额外字段SDK 体积(KB)激励视频插屏横幅原生开屏竞价
穿山甲国内642400✔✔✔✔✔✔
优量汇国内532100✔✔✔✔—✔
AdMob海外423300✔✔✔✔✔—

矩阵本身只说明「谁有什么」,不说明「接哪家划算」。要横向比较,得把三类指标折成一个可比的整数分——这就是下一节的接入成本。

接入成本的整数公式

把三类指标加权成一个 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)能力覆盖‰缺口位接入成本
穿山甲国内6424001000050
优量汇国内532100833147
AdMob海外423300833154

结论有点反直觉:穿山甲能力位最全(覆盖 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 瀑布流与竞价配置 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「minigame」更多文章

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