一次没有广告的黑屏
玩家在《潮汐消消》失败后点了「看视频,多走三步」。按钮开始转圈,八秒后屏幕变黑,再过几秒回到游戏,奖励没有到账。玩家看到的是「广告没看成」,工程看到的是至少四种可能:加载请求没有填充、SDK 回调超时、广告展示了但完成回调丢失、完成回调到了但发奖接口失败。
这四种可能对应生命周期里的不同位置。如果代码只有一个 showAd() 和一个 onSuccess(),排查时只能靠猜;如果每次展示都有一条带状态、事件和错误码的会话记录,问题就能被定位到具体一跳。
本节讨论广告从加载到发奖的状态流转,以及没有填充时怎么退。广告位该不该放、频次怎么定是 6.1 的内容;一次展示值多少钱是 6.3 的内容。这里只关心「这次广告到底走到了哪一步」。
生命周期不是一条直线
广告 SDK 的典型生命周期可以压成七个状态。它们不是线性队列,失败和超时会把会话推到终点,重试则会开一条新的加载路径:
| 状态 | 含义 | 进入条件 | 允许的关键动作 |
|---|---|---|---|
idle | 尚未请求 | 会话创建 | 发起加载 |
loading | 正在请求填充 | 收到加载请求 | 等待加载结果或超时 |
ready | 已填充、可展示 | 加载成功 | 展示广告 |
showing | 正在展示 | 调用展示成功 | 等待完成或关闭 |
completed | 完成回调已到 | 收到完成事件 | 发放奖励 |
closed | 用户提前关闭 | 收到关闭事件 | 不发奖,可重新加载 |
failed | 加载或展示失败 | 失败、超时、错误 | 重试、补偿或放弃 |
这张表最关键的一列是「进入条件」。状态不能由 UI 直接设置,只能由事件驱动。很多线上问题的根源,是页面在调用 show() 之后就把按钮置灰并假定广告已经开始,而 SDK 可能还在加载,或者根本没有填充。
另一个容易忽略的点是 ready 也会过期。广告平台返回的填充通常有有效期,库存放置太久会失效。因此 ready 不是安全状态,超时事件同样应该把它推到 failed,而不是继续等待一个永远不会来的展示。
奖励只认完成事件
激励视频的奖励发放只有一条合法路径:会话进入 completed,且本次会话尚未发过奖。点击、展示、关闭都不能作为发奖依据。
这条规则要处理四种现实情况:
- 点击不等于观看。 玩家点了按钮只说明有意愿,广告可能还没加载出来。
- 展示不等于完成。 广告开始播放后,玩家可能切后台、杀进程或提前关闭。
- 完成与关闭的到达顺序不固定。 有的 SDK 先回调完成再回调关闭,有的先关闭再补完成。先关闭就丢弃完成回调,会漏发奖励;先完成再关闭则应该保持
completed,不能被关闭事件覆盖。 - 完成回调可能重复。 网络重试、SDK 内部重放、页面重建都可能让同一个完成事件到达两次。发奖必须幂等,靠会话 id 或服务端订单号去重,而不是靠客户端的一个布尔值。
案例事实表里,消消的广告作弊点击占比是 1.5%,方舟是 0.6%。这个数字提醒的是:客户端上报的「完成」不能无条件相信。能接服务端验证的广告平台,应尽量用服务端回调确认展示与完成;只能客户端回调的平台,至少要把会话 id、广告位、时间戳和奖励流水一起落库,方便事后对账。
填充失败不是异常,是常态
消消的填充率是 92%,方舟是 88%。这不是「偶尔失败」,而是每 100 次请求里分别有 8 次和 12 次拿不到广告。把无填充当成异常抛出,会让日志被大量预期内的失败淹没。
按发生位置,失败至少可以分成四类:
| 失败类型 | 发生状态 | 典型原因 | 玩家侧表现 | 建议动作 |
|---|---|---|---|---|
| 无填充 | loading | 广告平台没有合适库存 | 转圈后无反应 | 重试或隐藏入口 |
| 加载超时 | loading | 网络慢、SDK 无响应 | 长时间转圈 | 超时后失败,可重试 |
| 加载错误 | loading | SDK 初始化失败、参数错误 | 立即失败 | 记录错误码,按配置重试 |
| 库存过期 | ready | 填充放置过久 | 点击后无法展示 | 视为失败,重新加载 |
这四类的共同点是:它们都不是玩家的错,也不应该消耗玩家的当日激励额度。额度只在真正完成观看后扣减;失败只影响这一次尝试。
失败分类还会影响运营口径。无填充高,说明广告平台供给不足,应该去谈更多的广告源或调整底价;加载超时高,说明网络或 SDK 集成有问题;库存过期高,说明预加载策略过于激进,加载了太多用不完的广告。把失败混成一个 error,这三种问题就无法区分。
兜底顺序:重试、补偿、隐藏
无填充发生时,产品通常有三种选择。它们的成本和对玩家的语义完全不同:
| 兜底动作 | 对玩家 | 成本 | 适用条件 |
|---|---|---|---|
| 重试 | 再等一次 | 额外延迟与一次请求 | 还有重试次数,且失败是暂时性的 |
| 补偿 | 直接给一点资源 | 消耗游戏经济预算 | 重试耗尽,且失败已经影响玩家体验 |
| 隐藏入口 | 本次不展示广告 | 损失一次曝光 | 供给长期不足,或场景不适合等待 |
顺序应该是重试、补偿、隐藏。先重试,是因为多数无填充是瞬时的;重试耗尽后再考虑补偿,是因为玩家已经为「看广告」付出了时间;如果连补偿都不合适,就把入口隐藏,而不是让玩家对着一个永远失败的按钮。
这里有一个必须说清楚的边界:补偿不是广告奖励。广告奖励是玩家完成观看后的对价,补偿是服务失败后的安抚。两者要分开记账、分开限额,否则玩家会发现「触发失败比看完更划算」,从而故意制造失败。补偿预算一旦和广告收入混在一起,就会被当成免费的留存工具。
可运行产物:广告会话状态机
下面的实现把状态、事件和兜底决策收在一个纯函数 reducer 里。它不调用任何 SDK,只根据当前会话和事件返回新会话;调用方负责把 SDK 回调翻译成事件,并把返回的会话写进日志或内存。
// file: src/biz/ad-lifecycle.ts
// tier: L1
// verify: node --test test/biz/ad-lifecycle.test.ts
export type AdStatus =
| "idle"
| "loading"
| "ready"
| "showing"
| "completed"
| "closed"
| "failed";
export type AdEventType =
| "load-request"
| "loaded"
| "load-failed"
| "timeout"
| "show"
| "completed"
| "closed";
export interface AdEvent {
readonly type: AdEventType;
readonly detail?: string;
}
export interface AdSession {
readonly id: string;
readonly status: AdStatus;
readonly attempts: number;
readonly rewardGranted: boolean;
readonly lastError: string | null;
}
export type FallbackAction = "retry" | "compensate" | "drop";
export interface FallbackOptions {
readonly maxAttempts: number;
readonly canRetry: boolean;
readonly canCompensate: boolean;
}
export function createSession(id: string): AdSession {
if (typeof id !== "string" || id.trim() === "") {
throw new Error("session id must be a non-empty string");
}
return { id, status: "idle", attempts: 0, rewardGranted: false, lastError: null };
}
export function isTerminal(status: AdStatus): boolean {
return status === "completed" || status === "closed" || status === "failed";
}
export function reduce(session: AdSession, event: AdEvent): AdSession {
const type = event.type;
if (type === "load-request") {
if (session.status === "loading" || session.status === "ready" || session.status === "showing") {
return session;
}
return {
...session,
status: "loading",
attempts: session.attempts + 1,
rewardGranted: false,
lastError: null,
};
}
if (type === "loaded") {
return session.status === "loading" ? { ...session, status: "ready" } : session;
}
if (type === "load-failed") {
return session.status === "loading"
? { ...session, status: "failed", lastError: event.detail ?? "load-failed" }
: session;
}
if (type === "timeout") {
return session.status === "loading" || session.status === "ready"
? { ...session, status: "failed", lastError: "timeout" }
: session;
}
if (type === "show") {
return session.status === "ready" ? { ...session, status: "showing" } : session;
}
if (type === "completed") {
if (session.status !== "showing" && session.status !== "closed") {
return session;
}
return { ...session, status: "completed", rewardGranted: true };
}
if (type === "closed") {
return session.status === "showing" ? { ...session, status: "closed" } : session;
}
throw new Error(`unknown ad event: ${String(type)}`);
}
export function shouldGrant(session: AdSession): boolean {
return session.status === "completed" && session.rewardGranted;
}
export function fallbackFor(session: AdSession, options: FallbackOptions): FallbackAction {
if (!Number.isInteger(options.maxAttempts) || options.maxAttempts < 0) {
throw new Error(`maxAttempts must be a non-negative integer, got ${options.maxAttempts}`);
}
if (session.status !== "failed") {
return "drop";
}
if (options.canRetry && session.attempts < options.maxAttempts) {
return "retry";
}
if (options.canCompensate) {
return "compensate";
}
return "drop";
}
export function formatSession(session: AdSession): string {
const reward = session.rewardGranted ? "yes" : "no";
const error = session.lastError ?? "none";
return `${session.id} status=${session.status} reward=${reward} attempts=${session.attempts} error=${error}`;
}
reduce 对非法事件采取「忽略并返回原会话」的策略,而不是抛错。广告 SDK 的回调顺序并不完全可靠,重复事件、迟到事件都会发生;在运行时抛错会让一个本可恢复的展示变成崩溃。真正非法的输入——比如空的会话 id、未知的事件类型——仍然会抛错,因为它们属于集成错误,不应该被静默吞掉。
fallbackFor 把兜底决策也做成纯函数:只有 failed 会话才需要兜底,重试次数未耗尽时优先重试,否则根据是否允许补偿决定补偿或放弃。这样运营可以只改配置,不用改状态机。
双案例:92%/88% 与 88%/93%
消消的填充率是 92%,激励完成率是 88%,人均每日 3.5 次。每 100 次请求约有 8 次无填充,高频入口必须配好重试与降级,否则玩家会把失败归因于游戏。
方舟的填充率是 88%,激励完成率是 93%,人均每日 1.5 次。请求量更少、单次完成质量更高,兜底重点不是高频重试,而是不要在少数入口反复打扰玩家。
取舍:预加载、补偿与校验的成本
预加载能降低展示等待,但会占用内存和带宽,也会加载一些最终用不完的广告。消消的首包是 120MB,方舟是 350MB,后者对内存更敏感;同一套预加载策略不能无差别地套在两个产品上。
补偿能安抚玩家,但会消耗游戏经济预算,还会制造「失败有奖」的逆向激励。补偿必须限额、限频,并且和广告奖励分开记账。
服务端校验能提高奖励的可信度,但会增加一次网络往返和外部依赖。广告平台的服务端回调如果延迟,玩家会感到「看完了却迟迟不到账」;如果回调丢失,还需要客户端兜底与人工补单。校验越严,链路越长,运营工单也越多。这里的取舍不是「要不要校验」,而是「哪些广告位必须校验、哪些可以先用客户端回调加事后审计」。
小结
- 广告生命周期是事件驱动的状态机,UI 不能直接设置状态,
ready也会因库存过期而失败。 - 奖励只认完成事件;关闭、展示、点击都不算,完成与关闭的到达顺序不固定,迟到完成仍应发奖。
- 完成回调可能重复,发奖必须按会话 id 或服务端订单号幂等。
- 填充失败是常态:消消 92%、方舟 88%,失败不消耗玩家额度,但要按类型分别监控。
- 兜底顺序是重试、补偿、隐藏;补偿是服务失败的安抚,不是广告奖励,必须分开记账。
阅读导航:上一节:6.1 广告位设计与激励视频边界 · 下一节:6.3 广告收入核算与 eCPM 口径 。
常见错误与边界
- 调用
show()后立刻把按钮置灰并假定广告已开始。SDK 可能还在加载或没有填充,玩家会卡在一个无法操作的界面。 - 把关闭回调当成完成回调。玩家提前关掉广告也会触发关闭,按关闭发奖等于白送奖励。
- 先关闭就丢弃后续完成回调。部分 SDK 的回调顺序是关闭在前、完成在后,直接丢弃会漏发奖励并引发客诉。
- 完成回调重复到达时重复发奖。网络重试和页面重建都会造成重复回调,必须用会话 id 或订单号幂等。
- 把无填充当成异常抛出。消消 8%、方舟 12% 的请求会无填充,异常日志会淹没真正的集成错误。
- 失败也扣减玩家的每日额度。玩家没有看完广告却失去一次机会,会认为系统在欺骗他。
- 补偿与广告奖励共用一套限额。玩家会发现触发失败比完成观看更划算,进而故意制造失败。
- 预加载策略不分产品。方舟首包 350MB,对内存更敏感;照搬消消的预加载深度会增加 OOM 风险。
- 只记录最终成功或失败,不记录中间状态。没有
loading、ready、showing的时间戳,就无法判断问题出在供给、网络还是回调。 - 认为客户端回调已经足够可信。消消的作弊点击占比 1.5%,方舟 0.6%;高价值奖励应尽量接服务端验证或事后审计。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。