动手前先划边界。站内那篇小游戏广告变现设计 是一篇清单式短文——它把「该放哪些广告位」列成一张表;本节给的是可跑的广告场景布局模型:同样四个形态,我们会把「放在哪个场景、触发条件是什么、日均触发几次、预期收益多少」全部写成配置,再用脚本把展示机会与收益结构算出来。另一篇独立开发者以为靠广告就能赚钱的失败复盘 讲的是翻车过程;本节把它抽象成一个可量化的权衡——广告位到底该往哪放、放几次,先有数再动手。具体 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分) | 占比‰ |
|---|---|---|---|---|---|---|---|
| 合成双倍奖励 | 激励视频 | 4 | 90% | 2500 | 3600 | 90 | 262 |
| 离线收益翻倍 | 激励视频 | 3 | 92% | 2600 | 2760 | 71 | 206 |
| 关卡结算 | 插屏 | 6 | 85% | 1200 | 5100 | 61 | 177 |
| 失败复活 | 激励视频 | 2 | 92% | 2800 | 1840 | 51 | 148 |
| 日常任务领取 | 激励视频 | 2 | 88% | 2200 | 1760 | 38 | 110 |
| 主界面横幅 | 横幅 | 8 | 95% | 300 | 7600 | 22 | 64 |
| 商城原生位 | 原生 | 1 | 70% | 1500 | 700 | 10 | 29 |
按形态汇总,结论更清楚:激励视频 250 deciFen(728‰)、插屏 61(177‰)、横幅 22(64‰)、原生 10(29‰)。四个激励视频场景只贡献了 12 次日均触发,却拿走了近四分之三的收益;而横幅日均触发 8 次(全表最多)、展示机会最多,收益却只有 22 deciFen——因为它的 eCPM 只有 300 分,是激励视频的十分之一出头。
场景布局的三条规则
把这节收成三条可以直接用的规则:
- 把激励视频放满「玩家本来就想做」的地方。 离线收益翻倍、失败复活、合成双倍、日常领取,这四个场景的共同点是:玩家此刻有明确动机想要那个奖励,看广告是他主动的选择。这类场景要尽量多铺,因为打断成本最低、单位收益最高。
- 插屏只放在自然停顿处,并硬限次数。 「关卡结算」是天然的停顿点,放插屏不会打断操作;但
maxPerDay必须卡死(形态表里填 4),否则玩家连过五关被连弹五次,D1 留存立刻见血。插屏的价值在于「填充激励视频覆盖不到的用户」,不是主力。 - 横幅和原生只做兜底,别指望它赚钱。 从表里看,横幅触发了 8 次才换来 22 deciFen,投入产出比最差。它们的作用是填满「没有激励视频机会」的屏幕时段,属于保底,不该占据设计精力。
最后一句提醒:本节所有收益数字都建立在示例参数上,它告诉你的是结构(激励视频主导、插屏次之、横幅原生兜底)与方法(配置驱动、可复算、可调参),而不是「你的游戏能赚多少」。真实数字要靠卷三的 eCPM 与 LTV 模型,以及上线后的实测校准。而比收益更棘手的问题是:频次加一档,收益涨多少、留存掉多少?下一节先把内购结构讲清,再在 10.3 用一整节的模型来解这个权衡。
阅读导航:上一节:社交压力与体验保护 · 下一节:IAP 内购与混合变现结构 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。