2.1 SDK 与广告 API 接入差异

九个小游戏平台的宿主广告 API 差异,决定了适配层要写多少分支、每个平台的「看完判定」能不能信。本节把微信、抖音、快手、QQ、百度、支付宝与 Poki、CrazyGames、GameDistribution 的接入面摊成一张登记矩阵,用可跑的 Python 评分脚本与 TypeScript 适配层内核,把差异折成可比较的整数接入成本分,并给出统一的看完判定。

站内有一篇跨平台发布笔记 ,那是单一引擎、单一路径的发布清单:怎么导出、怎么打包、怎么上传。本节不重复那份清单,而是把它推进到接入面的横向对照:九个平台的宿主广告 API 到底差在哪,这些差异如何决定你的适配层要写多少分支。另有一篇广告 SDK 接入与瀑布流聚合 ,讲的是接一个网络的步骤;本节讲的是接九种宿主的差异,两者的坐标不同,不重复。本章是全卷的接入章:上一章选好了平台组合,这一章回答「选定之后,代码到底要怎么改」。

宿主 API 不是第三方广告网络

先厘清一个常见混淆。上一卷讲聚合时,对照的是第三方广告网络(穿山甲、优量汇、AdMob)——那是你在同一个宿主里接入的多家填充来源。本节讲的是宿主自己的广告 API:微信的 wx.createRewardedVideoAd、抖音的 tt.createRewardedVideoAd、Poki 的 PokiSDK.rewardedBreak()。这是两个层级:宿主 API 决定你能不能展示广告,第三方网络决定展示哪家的广告。本节只处理第一层——因为出海的第一步,是让同一份逻辑能在九个宿主上把广告放出来。

接入差异的三个维度

九个宿主的广告 API,差异集中在三处。第一,调用风格:国内宿主几乎清一色是回调式(createRewardedVideoAd 返回广告对象,挂 onClose / onError),海外 H5 平台多是 Promise 式(showAd('rewarded') 返回 Promise,成功 resolve、失败 reject)。第二,是否必须先预加载:国内宿主普遍要求先 load() 再 show(),海外平台常常直接 show,由平台侧兜底。第三,「看完判定」有没有信号——这是最要命的一条:有的宿主在关闭回调里给一个布尔(微信的 res.isEnded),你能精确区分「看完」和「划走」;有的宿主只给一个「关闭」事件、没有完成信号,你只能按关闭推断,发奖逻辑因此留有被薅的空间。

接入成本的整数公式

把三类指标加权成一个 effort,单位分,越低越好:

satisfiedFormats = Σ formats[k]                               # 三类广告形态登记值之和
missingFormats   = len(formatKeys) - satisfiedFormats
implicit         = 1 if completionSignal == "none" else 0     # 无看完信号的风险位
effort           = initSteps   × w.initStep
                 + extraFields × w.extraField
                 + (sdkSizeKb / 100) × w.sdkPer100Kb          # 整数地板除
                 + missingFormats × w.missingFormat
                 + implicit     × w.implicitRisk              # 无信号单独罚一项
coveragePermille = satisfiedFormats × 1000 / len(formatKeys)  # 形态覆盖率

权重放在 config 里(w.initStep / w.extraField / w.sdkPer100Kb / w.missingFormat / w.implicitRisk),这样「我更在意包体还是更在意发奖安全」可以只改权重、不改代码。注意 sdkSizeKb / 100 是整数地板除:每满 100KB 计一分,避免浮点。implicitRisk 是本节特有的项——没有看完信号的平台,天然带一笔「可能被薅」的风险成本。

S 层:接入成本评分

下面这段是 S 层仿真脚本 sim/g02_sdk_adapter_score.py。它读 config/g02_sdk_matrix.json 的九个平台(六个国内 + 三个海外),按上面的整数公式算出各自的接入成本、形态覆盖率与无信号风险位,并产出 JSON + CSV + SVG。

#!/usr/bin/env python3
# file: sim/g02_sdk_adapter_score.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/g02_sdk_adapter_score.py --config config/ --out out/
"""宿主广告 API 接入差异评分:把「九个平台各自的接入成本」折成一个可比较的整数分。

读 config/g02_sdk_matrix.json,对每个平台:

    satisfiedFormats = Σ formats[k] for k in formatKeys         # 三类广告形态登记值之和
    missingFormats   = len(formatKeys) - satisfiedFormats
    implicit         = 1 if completionSignal == "none" else 0    # 无法判定「看完」的风险位
    effort           = initSteps   * w.initStep
                     + extraFields * w.extraField
                     + (sdkSizeKb // 100) * w.sdkPer100Kb        # 整数地板除
                     + missingFormats * w.missingFormat
                     + implicit     * w.implicitRisk             # 加权接入成本(分越低越好)
    coveragePermille = satisfiedFormats * 1000 // len(formatKeys)

effort 只用于横向排序,不代表任何官方口径。全程整数、地板除,无浮点。
产出 out/g02_sdk_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(p: dict, fmt_keys: list[str], w: dict) -> dict:
    satisfied = sum(p["formats"].get(k, 0) for k in fmt_keys)
    missing = len(fmt_keys) - satisfied
    implicit = 1 if p["completionSignal"] == "none" else 0
    effort = (p["initSteps"] * w["initStep"]
              + p["extraFields"] * w["extraField"]
              + (p["sdkSizeKb"] // 100) * w["sdkPer100Kb"]
              + missing * w["missingFormat"]
              + implicit * w["implicitRisk"])
    coverage = satisfied * 1000 // len(fmt_keys)
    return {
        "id": p["id"], "name": p["name"], "region": p["region"],
        "apiStyle": p["apiStyle"], "preloadRequired": p["preloadRequired"],
        "completionSignal": p["completionSignal"], "initSteps": p["initSteps"],
        "extraFields": p["extraFields"], "sdkSizeKb": p["sdkSizeKb"],
        "satisfiedFormats": satisfied, "missingFormats": missing, "implicitRisk": implicit,
        "effort": effort, "coveragePermille": coverage,
    }


def draw_svg(path: str, rows: list[dict]) -> int:
    """纯 Python SVG:每个平台的接入成本横条 + 形态覆盖率与「看完」信号标注。"""
    W, H, PAD, ROW = 780, 92 + 40 * len(rows), 170, 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 = (
        "《合成小岛》九平台宿主广告 API 接入成本(分越低越好)与形态覆盖率")
    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"]}‰ · '
            f'{"可判定看完" if r["implicitRisk"] == 0 else "无看完信号"}')
    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, "g02_sdk_matrix.json"), encoding="utf-8") as f:
        cfg = json.load(f)
    rows = [score(p, cfg["formatKeys"], cfg["weights"]) for p in cfg["platforms"]]
    cheapest = min(rows, key=lambda r: r["effort"])
    riskiest = max(rows, key=lambda r: r["implicitRisk"])

    digest = ",".join(f'{r["id"]}:{r["apiStyle"]}:{r["initSteps"]}:{r["extraFields"]}:'
                      f'{r["satisfiedFormats"]}:{r["effort"]}' for r in rows)

    os.makedirs(args.out, exist_ok=True)
    svg_bytes = draw_svg(os.path.join(args.out, "g02_sdk_adapter_score.svg"), rows)
    report = {"version": cfg["version"], "game": cfg["game"], "observedAt": cfg["observedAt"],
              "formatKeys": cfg["formatKeys"], "weights": cfg["weights"], "platforms": rows,
              "cheapestEffort": cheapest["id"], "digest": digest,
              "svg": os.path.join(args.out, "g02_sdk_adapter_score.svg"), "svgBytes": svg_bytes}
    with open(os.path.join(args.out, "g02_sdk_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, "g02_sdk_adapter_score.csv"), "w", encoding="utf-8", newline="") as f:
        w = csv.writer(f)
        cols = ("id", "name", "region", "apiStyle", "preloadRequired", "completionSignal",
                "initSteps", "extraFields", "sdkSizeKb", "satisfiedFormats", "missingFormats",
                "implicitRisk", "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"],
                      "implicitPlatform": riskiest["id"], "platforms": rows},
                     ensure_ascii=False, indent=2))
    return 0


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

接入参数放在 config/g02_sdk_matrix.json。再次强调:这些接入面数字是「公开可观察机制」口径的建模假设,不是官方文档的实测口径;真实后台配置属于 X 层。

{
  "version": 1,
  "game": "merge-island",
  "observedAt": "2026-10",
  "note": "九个小游戏平台的宿主广告 API 接入面登记表。口径是「公开可观察机制」的建模假设(观察时点 2026-10,非实测):apiStyle=回调式或 Promise 式;preloadRequired=是否必须先预加载再展示;completionSignal=能否判定「看完」的字段(isEnded=关闭回调带布尔、resolveBool=Promise 带布尔、none=只能凭关闭事件推断);initSteps=接入所需独立初始化步骤数;extraFields=宿主配置里除广告位 id 之外必填的字段数;sdkSizeKb=宿主广告 SDK 体积(KB);formats 是三类广告形态的 0/1 登记值。effort 是加权接入成本,仅用于横向排序,不代表任何官方口径。真实宿主配置、真实广告后台需真实业务环境,命令行不可复现(X)。全整数,无浮点。",
  "formatKeys": ["rewarded", "interstitial", "banner"],
  "weights": { "initStep": 3, "extraField": 2, "sdkPer100Kb": 1, "missingFormat": 5, "implicitRisk": 6 },
  "platforms": [
    { "id": "wechat", "name": "微信小游戏", "region": "cn", "apiStyle": "callback", "preloadRequired": 1, "completionSignal": "isEnded", "initSteps": 5, "extraFields": 3, "sdkSizeKb": 900,
      "formats": { "rewarded": 1, "interstitial": 1, "banner": 1 } },
    { "id": "douyin", "name": "抖音小游戏", "region": "cn", "apiStyle": "callback", "preloadRequired": 1, "completionSignal": "isEnded", "initSteps": 6, "extraFields": 4, "sdkSizeKb": 1100,
      "formats": { "rewarded": 1, "interstitial": 1, "banner": 1 } },
    { "id": "kuaishou", "name": "快手小游戏", "region": "cn", "apiStyle": "callback", "preloadRequired": 1, "completionSignal": "isEnded", "initSteps": 6, "extraFields": 3, "sdkSizeKb": 1000,
      "formats": { "rewarded": 1, "interstitial": 1, "banner": 1 } },
    { "id": "qq", "name": "QQ 小游戏", "region": "cn", "apiStyle": "callback", "preloadRequired": 1, "completionSignal": "isEnded", "initSteps": 5, "extraFields": 3, "sdkSizeKb": 900,
      "formats": { "rewarded": 1, "interstitial": 1, "banner": 0 } },
    { "id": "baidu", "name": "百度小游戏", "region": "cn", "apiStyle": "callback", "preloadRequired": 1, "completionSignal": "isEnded", "initSteps": 5, "extraFields": 3, "sdkSizeKb": 850,
      "formats": { "rewarded": 1, "interstitial": 1, "banner": 1 } },
    { "id": "alipay", "name": "支付宝小游戏", "region": "cn", "apiStyle": "callback", "preloadRequired": 1, "completionSignal": "none", "initSteps": 4, "extraFields": 2, "sdkSizeKb": 800,
      "formats": { "rewarded": 1, "interstitial": 1, "banner": 0 } },
    { "id": "poki", "name": "Poki", "region": "global", "apiStyle": "promise", "preloadRequired": 0, "completionSignal": "resolveBool", "initSteps": 3, "extraFields": 1, "sdkSizeKb": 600,
      "formats": { "rewarded": 1, "interstitial": 1, "banner": 0 } },
    { "id": "crazygames", "name": "CrazyGames", "region": "global", "apiStyle": "promise", "preloadRequired": 0, "completionSignal": "resolveBool", "initSteps": 3, "extraFields": 1, "sdkSizeKb": 700,
      "formats": { "rewarded": 1, "interstitial": 1, "banner": 1 } },
    { "id": "gamedistribution", "name": "GameDistribution", "region": "global", "apiStyle": "promise", "preloadRequired": 0, "completionSignal": "none", "initSteps": 2, "extraFields": 1, "sdkSizeKb": 500,
      "formats": { "rewarded": 1, "interstitial": 1, "banner": 0 } }
  ]
}

在本机跑 /opt/homebrew/bin/python3 sim/g02_sdk_adapter_score.py --config config/ --out out/,结果如下(同机相对量级,绝对数值不可移植):

平台调用风格初始化步骤额外字段形态覆盖‰看完信号接入成本
微信小游戏回调531000有30
抖音小游戏回调641000有37
快手小游戏回调631000有34
QQ 小游戏回调53666有35
百度小游戏回调531000有29
支付宝小游戏回调42666无35
PokiPromise31666有22
CrazyGamesPromise311000有18
GameDistributionPromise21666无24

结论有两层。第一层是风格分野:三个海外平台的接入成本(18 / 22 / 24)整体低于六个国内平台(29 ~ 37),因为它们是 Promise 式、不强制预加载、额外字段也少——「接起来更轻」是海外 H5 平台的共性。第二层更关键:支付宝小游戏和 GameDistribution 都落在「无看完信号」那一档(implicitRisk = 1),各自被罚了 6 分。这不是说它们不好接,而是说接它们时你必须自己兜底发奖安全——下一段 P 层会把这笔账算清楚。

P 层:适配层归一化与看完判定

S 层算出了成本,但真正要落地的是适配层:把九种异构的宿主 API 归一成一套统一接口,并统一「看完判定」。下面这段是 P 层 TypeScript 内核 core/src/g02/sdk_adapter.ts,它读同一份 config/g02_sdk_matrix.json,复算同一套成本分——和 Python 逐字符一致,并给出 resolveReward 这个统一的发奖入口。

// file: core/src/g02/sdk_adapter.ts
// tier: P
// verify: node --test 'core/test/g02_sdk_adapter.test.ts'
//
// 宿主广告 API 适配层内核:把九个平台异构的宿主广告接口归一成 AdapterSpec,
// 复算与 sim/g02_sdk_adapter_score.py 逐位一致的接入成本,并给出统一的「看完判定」。
// 读同一份 config/g02_sdk_matrix.json;全整数、地板除、无浮点;只用可擦除语法(禁 enum / namespace)。

export type ApiStyle = "callback" | "promise";
export type CompletionSignal = "isEnded" | "resolveBool" | "none";

export interface RawPlatform {
  readonly id: string; readonly name: string; readonly region: string;
  readonly apiStyle: ApiStyle; readonly preloadRequired: number;
  readonly completionSignal: CompletionSignal;
  readonly initSteps: number; readonly extraFields: number; readonly sdkSizeKb: number;
  readonly formats: Readonly<Record<string, number>>;
}
export interface AdapterWeights {
  readonly initStep: number; readonly extraField: number; readonly sdkPer100Kb: number;
  readonly missingFormat: number; readonly implicitRisk: number;
}
export interface SdkMatrixConfig {
  readonly version: number; readonly game: string; readonly observedAt: string;
  readonly formatKeys: readonly string[];
  readonly weights: AdapterWeights;
  readonly platforms: readonly RawPlatform[];
}

/** 归一后的适配器规格:业务层只认这套字段,不再看各平台的原生回调形态。 */
export interface AdapterSpec {
  readonly id: string; readonly name: string; readonly region: string;
  readonly apiStyle: ApiStyle; readonly requiresPreload: boolean;
  readonly completion: "explicit" | "implicit";
  readonly initSteps: number; readonly extraFields: number; readonly sdkSizeKb: number;
  readonly formats: readonly string[]; readonly satisfiedFormats: number;
  readonly missingFormats: number; readonly implicitRisk: number;
  readonly coveragePermille: number; readonly effort: number;
}

export function normalize(p: RawPlatform, fmtKeys: readonly string[],
                         weights: AdapterWeights): AdapterSpec {
  const formats = fmtKeys.filter((k) => (p.formats[k] ?? 0) === 1);
  const satisfiedFormats = formats.length;
  const missingFormats = fmtKeys.length - satisfiedFormats;
  const implicitRisk = p.completionSignal === "none" ? 1 : 0;
  const effort = p.initSteps * weights.initStep
    + p.extraFields * weights.extraField
    + Math.floor(p.sdkSizeKb / 100) * weights.sdkPer100Kb
    + missingFormats * weights.missingFormat
    + implicitRisk * weights.implicitRisk;
  const coveragePermille = Math.floor((satisfiedFormats * 1000) / fmtKeys.length);
  return { id: p.id, name: p.name, region: p.region, apiStyle: p.apiStyle,
    requiresPreload: p.preloadRequired === 1,
    completion: implicitRisk === 1 ? "implicit" : "explicit",
    initSteps: p.initSteps, extraFields: p.extraFields, sdkSizeKb: p.sdkSizeKb,
    formats, satisfiedFormats, missingFormats, implicitRisk, coveragePermille, effort };
}

export function normalizeAll(cfg: SdkMatrixConfig): AdapterSpec[] {
  return cfg.platforms.map((p) => normalize(p, cfg.formatKeys, cfg.weights));
}

/** 跨语言对拍摘要:与 sim/g02_sdk_adapter_score.py 的 digest 逐字符一致。 */
export function digest(specs: readonly AdapterSpec[]): string {
  return specs.map((s) => `${s.id}:${s.apiStyle}:${s.initSteps}:${s.extraFields}:${s.satisfiedFormats}:${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;
}

/** 宿主上报的展示事件:closed=广告已关闭;completed=宿主明确告知「看完」(无信号时为 undefined)。 */
export interface ShowEvent {
  readonly closed: boolean;
  readonly completed?: boolean;
}

/**
 * 统一「看完判定」:显式信号(isEnded / resolveBool)按宿主布尔值发奖;
 * 无信号平台只能凭关闭事件推断,故用户中途跳过也会被判为看完(`inferred`)——这正是风险位。
 */
export function resolveReward(adapter: AdapterSpec, event: ShowEvent): { granted: boolean; evidence: "explicit" | "inferred" } {
  if (adapter.completion === "explicit") {
    return { granted: event.completed === true, evidence: "explicit" };
  }
  return { granted: event.closed === true, evidence: "inferred" };
}

配套测试 core/test/g02_sdk_adapter.test.ts 把 Python 落盘的摘要写成金标准,断言 TS 归一化结果逐字符相等,并验证「看完判定」的两种行为。在本机跑 node --test 'core/test/g02_sdk_adapter.test.ts',6 个用例全过;两套独立实现给出同一个摘要:

digest = wechat:callback:5:3:3:30,douyin:callback:6:4:3:37,kuaishou:callback:6:3:3:34,qq:callback:5:3:2:35,baidu:callback:5:3:3:29,alipay:callback:4:2:2:35,poki:promise:3:1:2:22,crazygames:promise:3:1:3:18,gamedistribution:promise:2:1:2:24

那份摘要也是适配层回归测试的基准:以后某平台改了初始化步骤或加了必填字段,重跑一次、对比摘要,就能立刻看出接入成本变了多少。而 resolveReward 更值得单独说:测试里有一条断言——对无信号平台,用户中途跳过(closed=true)也会被判为看完,发奖证据是 inferred。这不是 bug,是这类平台的物理约束;正确的应对是把发奖与展示解耦:展示层照常调宿主 API,发奖层对 inferred 证据加一道业务校验(比如要求本次会话的有效操作数达标),把「能不能被薅」关进可控范围。这正是卷二「平台能力适配层」 那个主张在发行侧的延续——把易变的平台差异关进适配层,用可复现的摘要守住回归。

自检表

检查项合格标准
分层清楚宿主广告 API 与第三方广告网络分开,不混为一谈
矩阵口径清晰接入面标「公开可观察机制」+ 观察时点,不写「实测」
权重可调五类指标的权重全在 config 里,改权重不改代码
成本可复算effort 由公式唯一确定,Python / TS 结果一致
看完判定统一显式与无信号两类平台都走同一个 resolveReward 入口
边界清晰真实后台 / 审核标 X,不写成「实测」

接入差异讲清楚了,但「能接」不等于「能上」——下一节处理上架资料、审核流程与驳回应对,尤其是那条把国内平台和海外平台彻底分开的门槛。

阅读导航:上一节:1.3 平台选择与组合 · 下一节:2.2 上架资料、审核与驳回 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「minigame」更多文章

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