10.1 IAA 广告形态与场景布局

把广告从「塞进去」变成「设计出来」:先按打断成本区分激励视频、插屏、横幅、原生四种形态,再用一份配置把《合成小岛》每个广告场景写成位置加触发条件加日均触发次数,最后用可跑的整数脚本算出各场景的展示机会与收益结构,并给出场景布局的三条可落地规则。

动手前先划边界。站内那篇小游戏广告变现设计 是一篇清单式短文——它把「该放哪些广告位」列成一张表;本节给的是可跑的广告场景布局模型:同样四个形态,我们会把「放在哪个场景、触发条件是什么、日均触发几次、预期收益多少」全部写成配置,再用脚本把展示机会与收益结构算出来。另一篇独立开发者以为靠广告就能赚钱的失败复盘 讲的是翻车过程;本节把它抽象成一个可量化的权衡——广告位到底该往哪放、放几次,先有数再动手。具体 eCPM、填充率、LTV、回本周期的建模是卷三第 1–3 章的主题,本节只做变现设计前置:在写第一行代码之前,先把广告放进场景。

为什么这件事必须前置?因为广告位一旦落到 UI 上,改起来就是改体验:结算页多一个插屏,玩家下一次就少回来一次;离线收益少一个「看广告翻倍」,日收益直接掉一截。广告形态与场景布局是产品结构决策,不是上线前补的开关。这一节先把形态说清,再把场景写成可校验的配置,最后用整数脚本估一遍收益结构。

四种广告形态,四种打断成本

IAA(In-App Advertising,广告变现)不是一种东西,而是四种打断成本差异极大的形态。混着谈「要不要放广告」是没意义的,得逐形态谈:

  • 激励视频(rewarded video):玩家主动点击、看完换奖励。打断成本最低——因为控制权在玩家手里,他把广告当成「用 30 秒换资源」的一次交易。它是轻度小游戏 IAA 的主力。
  • 插屏(interstitial):在关卡切换、结算等自然停顿处强制弹出。打断成本高,放错位置(比如操作中途)会直接激怒玩家。次数必须硬限。
  • 横幅(banner):常驻屏幕边缘,不打断操作,但单价低、还会侵占本就紧张的手机屏。适合做「兜底填充」,不适合做主力。
  • 原生(native):融入界面样式的信息流广告,单价居中,位置灵活,但需要和自家 UI 风格协调,实现成本略高。

一句话记住区别:激励视频是玩家买账,插屏是玩家买单,横幅是玩家忍耐,原生是玩家没注意。四者的「打断成本」依次是 低 / 高 / 低但持续 / 中,而单位收益是 高 / 中 / 低 / 中。打断成本与收益并不总是同向,这正是场景布局要权衡的地方。

有一个容易忽略的字段是 maxPerDay——每种形态每日最多弹几次。它不是收益参数,而是体验护栏:激励视频因为自愿,上限可以放松(这里填 12);插屏因为强制,必须卡得很死(这里填 4);横幅和原生常驻,不设单日上限(填 0 表示不限)。把上限写进配置而不是散落在代码里,好处是它变成了一个可以调、可以对比、可以复盘的参数——上线后发现插屏太密,改一个数字就能做 A/B,而不是去翻哪一行 UI 代码里藏了个硬编码的 3。

一个先决问题:为什么轻度小游戏大多做 IAA,而不是纯 IAP

在写场景表之前,得先回答「为什么《合成小岛》以广告为主、内购为辅」。这背后是轻度小游戏的结构性事实:它的用户盘子大,但单用户付费意愿浅。合成、放置、关卡这类玩法上手门槛低,能吃到大量「不付费但愿意花时间」的用户;如果只做内购(IAP),这批用户一分钱不贡献,收入全靠少数鲸鱼玩家,风险极高。反过来,广告能把「愿意花时间」直接换成「愿意看 30 秒」,让免费用户也产生价值。

所以本套的选择是混合变现:IAA 打底、IAP 提上限。这一节先把 IAA 这一半布局清楚;下一节再把 IAP 那一半和两者的比例结构补上。至于「两个都做,会不会互相打架」——比如内购的「去广告月卡」是不是在拆自己的广告收入——那是 10.2 要算的账。

D 层:把形态和场景写成一张配置表

既然要权衡,就不能靠感觉。下面这份是 D 层配置,它把「形态表」和「场景表」分开写:形态表描述每种广告的属性(是否打断、是否自愿、每日上限),场景表描述在哪儿放、怎么触发、量级多大。这两张表合起来,就是《合成小岛》的广告场景布局。

{
  "version": 1,
  "game": "merge-island",
  "note": "广告形态与场景布局表(D 层)。triggersPerDau / fillPct / ecpmFen 均为示例假设参数,真实 eCPM 与填充率需真实业务环境,命令行不可复现(X)。",
  "formats": [
    { "id": "rewarded", "name": "激励视频", "interruptive": false, "optIn": true, "maxPerDay": 12 },
    { "id": "interstitial", "name": "插屏", "interruptive": true, "optIn": false, "maxPerDay": 4 },
    { "id": "banner", "name": "横幅", "interruptive": false, "optIn": false, "maxPerDay": 0 },
    { "id": "native", "name": "原生", "interruptive": false, "optIn": false, "maxPerDay": 0 }
  ],
  "scenes": [
    { "id": "offline-earn", "name": "离线收益翻倍", "format": "rewarded", "triggersPerDau": 3, "fillPct": 92, "ecpmFen": 2600 },
    { "id": "revive", "name": "失败复活", "format": "rewarded", "triggersPerDau": 2, "fillPct": 92, "ecpmFen": 2800 },
    { "id": "double-reward", "name": "合成双倍奖励", "format": "rewarded", "triggersPerDau": 4, "fillPct": 90, "ecpmFen": 2500 },
    { "id": "daily-task", "name": "日常任务领取", "format": "rewarded", "triggersPerDau": 2, "fillPct": 88, "ecpmFen": 2200 },
    { "id": "level-end", "name": "关卡结算", "format": "interstitial", "triggersPerDau": 6, "fillPct": 85, "ecpmFen": 1200 },
    { "id": "home-banner", "name": "主界面横幅", "format": "banner", "triggersPerDau": 8, "fillPct": 95, "ecpmFen": 300 },
    { "id": "shop-native", "name": "商城原生位", "format": "native", "triggersPerDau": 1, "fillPct": 70, "ecpmFen": 1500 }
  ]
}

读这张表要抓住三个字段。triggersPerDau 是「每个日活玩家平均每天在这个场景触发几次广告机会」——它不是拍脑袋,而是可以从玩法节奏反推的:一局大约 2 分钟、每天开 4 次、每次打 3 关,那「关卡结算」的机会就约等于 12,我们保守填 6,因为不是每关都会看完插屏。fillPct 是填充率,指广告请求有多少比例真的返回了一条广告,这里全填成 70–95 的示例值。ecpmFen 是每千次展示的预期收入(单位:分),是四种形态单价差异的直接体现。

必须诚实标注:这三个字段全是示例假设参数。真实的 eCPM 和填充率取决于平台、季节、地区和广告主竞价,属于「需真实业务环境,命令行不可复现」的范畴(X),我们不编造任何后台数据。本节真正能算出来、可复现的,是结构——各场景的相对量级、各形态的收益占比,以及「哪些场景值得放、哪些不值得」。

S 层:算每个场景的展示机会与收益结构

有了配置,就可以把「展示机会」和「收益结构」算出来。下面这段是 S 层仿真脚本,全程整数地板除,收益用「0.1 分」的 deciFen 记账,读 config/ 跑出 JSON + CSV + SVG 三份产物。

#!/usr/bin/env python3
# file: sim/ch10_ads.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/ch10_ads.py --config config/ --out out/
"""IAA 广告形态与场景布局:读 config/ch10_ads.json 的形态表与场景表,
按「日均触发次数 × 填充率 × eCPM」估算每个场景的展示机会与收益结构,
产出 JSON + CSV + SVG。全程整数,收益用「0.1 分」的 deciFen 记账。

公式(整数地板除):
  impressionsPerDauMilli = triggersPerDau * fillPct * 10      # 千次展示 ×1000
  revenueDeciFen         = triggersPerDau * fillPct * ecpmFen // 10000
  sharePermille          = revenueDeciFen * 1000 // total

注意:triggersPerDau / fillPct / ecpmFen 均为示例假设参数,真实 eCPM 与填充率
需真实业务环境,命令行不可复现(X)。
"""

from __future__ import annotations

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

FORMAT_COLOR = {
    "rewarded": "#2563eb",
    "interstitial": "#f59e0b",
    "banner": "#10b981",
    "native": "#7c3aed",
}


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


def impressions_milli(triggers, fill_pct):
    """日均展示机会,单位「千次展示」的千分之一(即 1 表示 0.001 次展示)。"""
    return triggers * fill_pct * 10


def revenue_deci_fen(triggers, fill_pct, ecpm_fen):
    """日均收益,单位 0.1 分。triggers*(fill/100)*(ecpm/1000) 分 ×10。"""
    return triggers * fill_pct * ecpm_fen // 10000


def draw_svg(path, scenes, fmt_name, total):
    """纯 Python SVG:每个场景一根横条,长度正比于日均收益,颜色按形态区分。"""
    W, H, PAD, ROW = 720, 96 + 40 * len(scenes), 176, 40
    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": "26", "font-size": "15", "fill": "#111827"}).text = (
        "《合成小岛》各广告场景日均收益(0.1 分/DAU,示例参数)"
    )
    # 图例
    lx = 16
    for fid, color in FORMAT_COLOR.items():
        ET.SubElement(svg, "rect", {"x": str(lx), "y": "40", "width": "12", "height": "12", "rx": "2", "fill": color})
        ET.SubElement(svg, "text", {"x": str(lx + 17), "y": "51", "font-size": "11", "fill": "#374151"}).text = fmt_name[fid]
        lx += 96
    bar_max = W - PAD - 96
    hi = max((s["revenueDeciFen"] for s in scenes), default=1) or 1
    for i, s in enumerate(scenes):
        y = 76 + i * ROW
        ET.SubElement(svg, "text", {"x": "16", "y": str(y + 20), "font-size": "12", "fill": "#374151"}).text = s["name"]
        w = bar_max * s["revenueDeciFen"] // hi
        ET.SubElement(svg, "rect", {"x": str(PAD), "y": str(y + 6), "width": str(w), "height": "20",
                                    "rx": "4", "fill": FORMAT_COLOR.get(s["format"], "#6b7280")})
        ET.SubElement(svg, "text", {"x": str(PAD + w + 8), "y": str(y + 21), "font-size": "12",
                                    "fill": "#6b7280"}).text = (
            f'{s["revenueDeciFen"] / 10:.1f}分 · {s["sharePermille"]}‰'
        )
    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)
    fmt_name = {f["id"]: f["name"] for f in cfg["formats"]}
    total = sum(revenue_deci_fen(s["triggersPerDau"], s["fillPct"], s["ecpmFen"]) for s in cfg["scenes"])

    scenes = []
    for s in cfg["scenes"]:
        rev = revenue_deci_fen(s["triggersPerDau"], s["fillPct"], s["ecpmFen"])
        scenes.append({
            "id": s["id"], "name": s["name"], "format": s["format"],
            "triggersPerDau": s["triggersPerDau"], "fillPct": s["fillPct"], "ecpmFen": s["ecpmFen"],
            "impressionsPerDauMilli": impressions_milli(s["triggersPerDau"], s["fillPct"]),
            "revenueDeciFen": rev,
            "sharePermille": rev * 1000 // total if total else 0,
        })

    by_format = {}
    for s in scenes:
        b = by_format.setdefault(s["format"], {"format": s["format"], "name": fmt_name.get(s["format"], s["format"]),
                                               "scenes": 0, "revenueDeciFen": 0})
        b["scenes"] += 1
        b["revenueDeciFen"] += s["revenueDeciFen"]
    formats = sorted(by_format.values(), key=lambda b: b["revenueDeciFen"], reverse=True)
    for b in formats:
        b["sharePermille"] = b["revenueDeciFen"] * 1000 // total if total else 0

    os.makedirs(args.out, exist_ok=True)
    svg_bytes = draw_svg(os.path.join(args.out, "ch10_ads.svg"), scenes, fmt_name, total)
    report = {
        "version": cfg["version"],
        "game": cfg["game"],
        "sceneCount": len(scenes),
        "arpdauDeciFen": total,
        "shareSumPermille": sum(s["sharePermille"] for s in scenes),
        "scenes": scenes,
        "formats": formats,
        "svg": os.path.join(args.out, "ch10_ads.svg"),
        "svgBytes": svg_bytes,
    }
    with open(os.path.join(args.out, "ch10_ads.json"), "w", encoding="utf-8") as f:
        json.dump(report, f, ensure_ascii=False, indent=2)
    with open(os.path.join(args.out, "ch10_ads.csv"), "w", encoding="utf-8", newline="") as f:
        w = csv.writer(f)
        w.writerow(["id", "name", "format", "triggersPerDau", "fillPct", "ecpmFen",
                    "impressionsPerDauMilli", "revenueDeciFen", "sharePermille"])
        for s in scenes:
            w.writerow([s["id"], s["name"], s["format"], s["triggersPerDau"], s["fillPct"], s["ecpmFen"],
                        s["impressionsPerDauMilli"], s["revenueDeciFen"], s["sharePermille"]])

    print(json.dumps({"sceneCount": len(scenes), "arpdauDeciFen": total,
                      "shareSumPermille": report["shareSumPermille"]}, ensure_ascii=False))
    return 0


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

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

{"sceneCount": 7, "arpdauDeciFen": 343, "shareSumPermille": 996}

7 个场景加起来,日均广告收益是 343 deciFen,也就是 34.3 分/DAU(约三毛四)。逐场景看 out/ch10_ads.csv:

场景形态日均触发填充率eCPM(分)展示机会(×0.001)收益(0.1分)占比‰
合成双倍奖励激励视频490%2500360090262
离线收益翻倍激励视频392%2600276071206
关卡结算插屏685%1200510061177
失败复活激励视频292%2800184051148
日常任务领取激励视频288%2200176038110
主界面横幅横幅895%30076002264
商城原生位原生170%15007001029

按形态汇总,结论更清楚:激励视频 250 deciFen(728‰)、插屏 61(177‰)、横幅 22(64‰)、原生 10(29‰)。四个激励视频场景只贡献了 12 次日均触发,却拿走了近四分之三的收益;而横幅日均触发 8 次(全表最多)、展示机会最多,收益却只有 22 deciFen——因为它的 eCPM 只有 300 分,是激励视频的十分之一出头。

场景布局的三条规则

把这节收成三条可以直接用的规则:

  1. 把激励视频放满「玩家本来就想做」的地方。 离线收益翻倍、失败复活、合成双倍、日常领取,这四个场景的共同点是:玩家此刻有明确动机想要那个奖励,看广告是他主动的选择。这类场景要尽量多铺,因为打断成本最低、单位收益最高。
  2. 插屏只放在自然停顿处,并硬限次数。 「关卡结算」是天然的停顿点,放插屏不会打断操作;但 maxPerDay 必须卡死(形态表里填 4),否则玩家连过五关被连弹五次,D1 留存立刻见血。插屏的价值在于「填充激励视频覆盖不到的用户」,不是主力。
  3. 横幅和原生只做兜底,别指望它赚钱。 从表里看,横幅触发了 8 次才换来 22 deciFen,投入产出比最差。它们的作用是填满「没有激励视频机会」的屏幕时段,属于保底,不该占据设计精力。

最后一句提醒:本节所有收益数字都建立在示例参数上,它告诉你的是结构(激励视频主导、插屏次之、横幅原生兜底)与方法(配置驱动、可复算、可调参),而不是「你的游戏能赚多少」。真实数字要靠卷三的 eCPM 与 LTV 模型,以及上线后的实测校准。而比收益更棘手的问题是:频次加一档,收益涨多少、留存掉多少?下一节先把内购结构讲清,再在 10.3 用一整节的模型来解这个权衡。

阅读导航:上一节:社交压力与体验保护 · 下一节:IAP 内购与混合变现结构 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「minigame」更多文章

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