《移动游戏产品的架构·商业化与发行工程》6.2 广告 SDK 生命周期与填充失败兜底

填充率 92% 意味着每 100 次激励请求就有 8 次拿不到广告。本节把广告 SDK 的生命周期拆成加载、就绪、展示、完成、关闭与失败六个状态,用纯函数状态机处理超时、重复回调和重试,明确奖励只认完成事件,并给出无填充时的三种兜底路径与代价。

一次没有广告的黑屏

玩家在《潮汐消消》失败后点了「看视频,多走三步」。按钮开始转圈,八秒后屏幕变黑,再过几秒回到游戏,奖励没有到账。玩家看到的是「广告没看成」,工程看到的是至少四种可能:加载请求没有填充、SDK 回调超时、广告展示了但完成回调丢失、完成回调到了但发奖接口失败。

这四种可能对应生命周期里的不同位置。如果代码只有一个 showAd() 和一个 onSuccess(),排查时只能靠猜;如果每次展示都有一条带状态、事件和错误码的会话记录,问题就能被定位到具体一跳。

本节讨论广告从加载到发奖的状态流转,以及没有填充时怎么退。广告位该不该放、频次怎么定是 6.1 的内容;一次展示值多少钱是 6.3 的内容。这里只关心「这次广告到底走到了哪一步」。

生命周期不是一条直线

广告 SDK 的典型生命周期可以压成七个状态。它们不是线性队列,失败和超时会把会话推到终点,重试则会开一条新的加载路径:

状态含义进入条件允许的关键动作
idle尚未请求会话创建发起加载
loading正在请求填充收到加载请求等待加载结果或超时
ready已填充、可展示加载成功展示广告
showing正在展示调用展示成功等待完成或关闭
completed完成回调已到收到完成事件发放奖励
closed用户提前关闭收到关闭事件不发奖,可重新加载
failed加载或展示失败失败、超时、错误重试、补偿或放弃

这张表最关键的一列是「进入条件」。状态不能由 UI 直接设置,只能由事件驱动。很多线上问题的根源,是页面在调用 show() 之后就把按钮置灰并假定广告已经开始,而 SDK 可能还在加载,或者根本没有填充。

另一个容易忽略的点是 ready 也会过期。广告平台返回的填充通常有有效期,库存放置太久会失效。因此 ready 不是安全状态,超时事件同样应该把它推到 failed,而不是继续等待一个永远不会来的展示。

奖励只认完成事件

激励视频的奖励发放只有一条合法路径:会话进入 completed,且本次会话尚未发过奖。点击、展示、关闭都不能作为发奖依据。

这条规则要处理四种现实情况:

  1. 点击不等于观看。 玩家点了按钮只说明有意愿,广告可能还没加载出来。
  2. 展示不等于完成。 广告开始播放后,玩家可能切后台、杀进程或提前关闭。
  3. 完成与关闭的到达顺序不固定。 有的 SDK 先回调完成再回调关闭,有的先关闭再补完成。先关闭就丢弃完成回调,会漏发奖励;先完成再关闭则应该保持 completed,不能被关闭事件覆盖。
  4. 完成回调可能重复。 网络重试、SDK 内部重放、页面重建都可能让同一个完成事件到达两次。发奖必须幂等,靠会话 id 或服务端订单号去重,而不是靠客户端的一个布尔值。

案例事实表里,消消的广告作弊点击占比是 1.5%,方舟是 0.6%。这个数字提醒的是:客户端上报的「完成」不能无条件相信。能接服务端验证的广告平台,应尽量用服务端回调确认展示与完成;只能客户端回调的平台,至少要把会话 id、广告位、时间戳和奖励流水一起落库,方便事后对账。

填充失败不是异常,是常态

消消的填充率是 92%,方舟是 88%。这不是「偶尔失败」,而是每 100 次请求里分别有 8 次和 12 次拿不到广告。把无填充当成异常抛出,会让日志被大量预期内的失败淹没。

按发生位置,失败至少可以分成四类:

失败类型发生状态典型原因玩家侧表现建议动作
无填充loading广告平台没有合适库存转圈后无反应重试或隐藏入口
加载超时loading网络慢、SDK 无响应长时间转圈超时后失败,可重试
加载错误loadingSDK 初始化失败、参数错误立即失败记录错误码,按配置重试
库存过期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 口径 。

常见错误与边界

  1. 调用 show() 后立刻把按钮置灰并假定广告已开始。SDK 可能还在加载或没有填充,玩家会卡在一个无法操作的界面。
  2. 把关闭回调当成完成回调。玩家提前关掉广告也会触发关闭,按关闭发奖等于白送奖励。
  3. 先关闭就丢弃后续完成回调。部分 SDK 的回调顺序是关闭在前、完成在后,直接丢弃会漏发奖励并引发客诉。
  4. 完成回调重复到达时重复发奖。网络重试和页面重建都会造成重复回调,必须用会话 id 或订单号幂等。
  5. 把无填充当成异常抛出。消消 8%、方舟 12% 的请求会无填充,异常日志会淹没真正的集成错误。
  6. 失败也扣减玩家的每日额度。玩家没有看完广告却失去一次机会,会认为系统在欺骗他。
  7. 补偿与广告奖励共用一套限额。玩家会发现触发失败比完成观看更划算,进而故意制造失败。
  8. 预加载策略不分产品。方舟首包 350MB,对内存更敏感;照搬消消的预加载深度会增加 OOM 风险。
  9. 只记录最终成功或失败,不记录中间状态。没有 loading、ready、showing 的时间戳,就无法判断问题出在供给、网络还是回调。
  10. 认为客户端回调已经足够可信。消消的作弊点击占比 1.5%,方舟 0.6%;高价值奖励应尽量接服务端验证或事后审计。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

  1. 《移动游戏产品的架构·商业化与发行工程》附录 C 商业化与发行评审清单
  2. 《移动游戏产品的架构·商业化与发行工程》附录 B 双案例商业化事实表
  3. 《移动游戏产品的架构·商业化与发行工程》附录 A 商业化与发行术语速查