First Look

让 CloudX 优先填充广告位,然后回退到您现有的聚合设置

First Look 让 CloudX 在每一次广告机会中优先获得填充机会,同时保留您现有的聚合设置作为回退路径。任一时刻只有一个 SDK 拥有该广告位,下一个周期 CloudX 再次优先。建议先从一个广告位开始,验证加载和展示行为后,再扩展到更多广告位。

本页示例通过 react-native-google-mobile-ads 使用 Google Ad Manager(GAM)作为回退。相同模式也适用于其他回退聚合平台:先加载 CloudX;只有当 CloudX 未填充时才加载回退广告;当两个来源都未准备好时,让应用流程继续。

这条规则有两种形态,本页各提供一个 Hook:

  • Banner 和 MREC — 内嵌广告会持续留在屏幕上,永远不会被消耗,因此 Hook 需要一个显式的刷新周期,才能让 CloudX 再次获得优先填充机会。MREC 完全沿用此 Hook。
  • 插屏广告 — 全屏广告在展示后即被消耗,因此两个 SDK 各自的“广告是否就绪”会自动变为 false,下一次 load() 自然又从 CloudX 开始。激励视频广告完全沿用此 Hook。

横幅广告并不是把插屏广告换个方法名。全屏广告在展示后即被消耗,因此它的就绪判断会自动变为 false,下一次 load() 又从 CloudX 开始——下文的插屏广告正是依赖这一点。内嵌广告则永远不会被消耗:若不加处理,第一次填充就会一直占据该广告位,直到屏幕卸载;也就是说,CloudX 只要有一次未填充,该广告位在整个会话期间都会归回退广告所有。

该 Hook 用刷新周期解决这个问题。一个周期就是一次广告机会:先请求 CloudX,只有 CloudX 未填充时才请求 GAM,然后切入胜出方。切入胜出方即结束本周期,并开始下一个周期的计时,而下一个周期又从 CloudX 开始。

本页示例使用的是组件形式的横幅 CloudXBannerView,而不是编程式的 CloudXBannerAd。组件会渲染进您的视图树,因此它位于您的布局之中,而不是浮在其上的原生浮层——本周期也需要这种形式,因为挂载即触发加载、卸载即销毁广告。

两侧刷新都关闭后,每个周期的流程如下:

  1. 在屏幕外加载一个 CloudX 横幅,同时上一个广告(如有)保持展示。
  2. 填充成功后进行切换:展示新横幅并卸载上一个广告,卸载会销毁其原生视图。
  3. CloudX 未填充时,本周期改为加载 GAM 横幅。
  4. 如果两者都未填充,按指数延迟(1、2、4、8……秒,上限 64 秒)从 CloudX 重新开始。
  5. 新广告切换展示后,等待 30 秒,然后从第 1 步开始下一个周期——CloudX 再次优先。只有在应用处于前台时才会发起新的加载尝试;已经在进行中的加载会正常完成。

这样您可以在当前横幅展示的同时在后台准备下一次填充,在刷新时机到来时即时切换,并受益于 CloudX 的乐观加载(optimistic loading)特性。

四个文件都需要复制。它们合起来就是完整的流程。

它们位于 CloudX React Native 演示应用中——该版本已在真机上验证,出现问题时修复的也是它。这也是本页只给出链接、不粘贴代码的原因:粘贴在这里的副本会逐渐与之不一致,而过时的那一份一定是副本。

为什么槽位要单独一个文件

Hook 本身不包含视图。它只跟踪两次尝试——当前展示的广告,以及正在屏幕外加载的广告——而槽位负责把它们变成已挂载的组件。对 CloudXBannerView 和 GAM 的 GAMBannerAd 来说,挂载即触发加载,因此槽位的结构不是外观问题,而是周期得以运转的方式。其中有三条规则,正是集成时最容易出错的地方:

  • 把正在加载的尝试渲染为隐藏,而不是延后渲染。 不挂载就不会加载,因此如果等到刷新时机才挂载下一个广告,就会在整个加载期间显示一个空白横幅。隐藏槽位始终处于挂载状态,在填充之前不产生任何开销。
  • position: absoluteopacity: 0 隐藏,并加上 pointerEvents="none" 和无障碍属性。 卸载它会取消加载;而让它保持可交互,用户就可能点到并未显示在屏幕上的广告,那是无效点击。
  • 每个回调都要与所属尝试的 key 绑定。 展示中的广告和正在加载的广告共用同一组处理函数,而展示中的广告自己也会触发事件。没有尝试 key,来自屏幕上那个广告的过期事件就会把尚未填充的隐藏尝试切入前台,导致槽位空白。

直接采用现成的槽位,这三点就都满足了。哪个 SDK 赢得本周期、下一个周期何时开始,属于 Hook 那一侧,并在该文件中有注释说明。

接入方式

在布局中横幅应处的位置渲染 <FirstLookBannerSlot />,并可选地传入 observer 以观察周期的运行情况。屏幕卸载时,两个槽位会随之卸载,从而销毁原生广告并清除 Hook 的计时器。

HomeScreen.tsx
import React, { useCallback, useMemo } from 'react';
import { View } from 'react-native';
import { FirstLookBannerSlot } from './firstlook/FirstLookBannerSlot';
import type { FirstLookBannerObserver } from './firstlook/useFirstLookBanner';

export function HomeScreen() {
  const report = useCallback((line: string) => console.log(line), []);

  /*
   * observer 是可选的——无论是否传入,周期都会照常运行。
   * Hook 会把它保存在 ref 中,因此每次渲染都创建新对象也没有影响:
   * observer 的引用变化不会重启正在运行的周期。
   */
  const observer: FirstLookBannerObserver = useMemo(
    () => ({
      onAdLoaded: source => report(`Banner loaded (${source})`),
      onAdLoadFailed: (source, error) =>
        report(`Banner load failed (${source}): ${error}`),
      onAdClicked: source => report(`Banner clicked (${source})`),
    }),
    [report],
  );

  return (
    <View>
      <FirstLookBannerSlot observer={observer} />
    </View>
  );
}

每个广告位只渲染一个槽位。导航器会让屏幕保持挂载,因此同一个广告单元上的两个组件会运行两套互不相干的周期,并且都会收到该广告单元的事件——其中一个实例的失败会触发另一个实例的回退。

observer 上报的内容

三个回调,每个都携带实际填充广告的来源。

回调含义
onAdLoaded(source)某个来源已填充。这同时也是切换动作:广告在同一个处理函数中被切到屏幕上。source 就是“CloudX 是否真的在填充”的答案。
onAdLoadFailed(source, error)两个来源都未填充,本次广告机会结束。目前只会上报 'gam'
onAdClicked(source)用户点击了广告。仅用于上报。

最需要理解正确的是 onAdLoadFailed。仅 CloudX 未填充时不会触发它,因为那次未填充正是 GAM 尝试的起点——周期仍在运行;若把它当作最终失败处理,就会在 GAM 仍在加载时重复占用同一次广告机会。

有两处“静默”需要知道。GAM 横幅的点击永远不会被上报:react-native-google-mobile-ads 在 iOS 和 Android 上都没有提供横幅点击事件,因此 onAdClicked 只覆盖 CloudX 横幅(插屏广告不受影响,两个来源都会上报)。另外,只要应用离开前台,周期就会暂停,并且没有任何回调。在 iOS 上这包括 ATT 弹窗——它出现在启动时,正好是第一个周期开始的时刻。

插屏广告

两者中较简单的一个,因为展示即消耗广告:不需要渲染槽位,也不需要驱动周期。激励视频广告就是把本 Hook 中的调用替换为激励视频的对应调用,并加上奖励回调。

屏幕准备就绪时调用 load() 来预备该广告位。在广告位触发时机调用 show()。如果它返回 false,说明 CloudX 和 GAM 都没有就绪的广告,此时让应用流程继续,不展示广告。

Hook 不会替您重新加载。 onAdClosedonAdLoadFailedonAdShowFailed 都意味着本次广告机会已经结束,也都是调用 load() 的合适位置——如果不在关闭时重新加载,该广告位在第一次展示之后就再也不会有广告。

GameScreen.tsx
const { isReady, load, show } = useFirstLookInterstitial(
  CLOUDX_INTERSTITIAL_AD_UNIT_ID,
  GAM_INTERSTITIAL_AD_UNIT_ID,
  {
    onAdLoaded: source => console.log(`Interstitial ready (${source})`),
    // 展示会消耗广告,所以没有这一次重新加载,
    // 该广告位在第一次展示之后就失效了。
    onAdClosed: () => load(),
    // 未填充之后不会有关闭事件,因此没有别的时机能补上加载。
    // 请用指数延迟安排重试,而不是立即重新加载。
    onAdLoadFailed: (source, error) => scheduleRetry(),
    onAdShowFailed: (source, error) => scheduleRetry(),
  },
);

useEffect(() => {
  load();
}, [load]);

const continueToNextScreen = () => {
  if (!show()) {
    navigation.navigate('NextScreen');
  }
};

observer 上报的内容

六个回调,每个都携带实际填充广告的来源。

回调含义
onAdLoaded(source)某个来源已填充。source 就是“CloudX 是否真的在填充”的答案。
onAdLoadFailed(source, error)两个来源都未填充,本次广告机会结束。目前只会上报 'gam'
onAdShown(source)SDK 确认广告已显示在屏幕上。不是根据 show() 推断出来的。
onAdShowFailed(source, error)已加载的广告无法展示,本次广告机会结束。目前只有 'gam'——CloudX 的展示失败会走加载路径,而那正是触发回退的原因。
onAdClosed(source)广告被关闭。请在这里调用 load()
onAdClicked(source)用户点击了广告。仅用于上报,不影响该广告位。

onAdLoadFailed 遵循与横幅相同的“仅最终失败”规则:CloudX 未填充正是触发回退的原因,因此不会上报。

错误路径

  • CloudX 加载失败(未填充或错误):Hook 会为本次广告机会加载 GAM 插屏。useCloudXInterstitial 通过设置自身的 error 字段来上报,而这也是本 Hook 触发回退的唯一条件。
  • CloudX 展示失败:广告已被消耗,同一个 error 字段会被设置,因此 Hook 也会为本次广告机会加载 GAM——回退不仅覆盖加载失败,也覆盖展示失败。这包括填充已过期的情况(ADAPTER_AD_EXPIRED):CloudX 插屏在加载后长时间未展示可能过期并在展示时失败,此时由回退接手。
  • 在关闭事件中立即重新加载:广告位刚关闭的那一刻,SDK 还不会接受新的加载。隐藏事件会先触发,因此在 onAdClosed 中直接调用 load() 会被以 Cannot load while another ad is currently being displayed 拒绝——它与未填充无法区分,会把一次 CloudX 从未真正参与的广告机会交给 GAM。Hook 会消化这一点:它会等过 CLOSE_SETTLE_MS 后重试 CloudX 的加载,而不是把这次拒绝当作未填充。请继续在 onAdClosed 中调用 load(),时序由 Hook 负责。
  • GAM 也加载失败show() 返回 false,应用流程继续且不展示广告。此时会触发 onAdLoadFailed('gam', ...);请按指数延迟(1、2、4、8……秒)安排重试,而不是立即重试。
  • GAM 完全没有响应:超过 ATTEMPT_TIMEOUT_MS 后,Hook 会触发 onAdLoadFailed('gam', ...),以免您一直等待。请留意该消息——它同时说明这个 GAM 广告对象在本次会话中无法再次加载,因为只要它自己的请求仍未结束,插件就会忽略新的加载,而只有关闭或错误才会清除该状态。后续的广告机会仍会从 CloudX 开始并正常出广告;要恢复 GAM 这一路,需要重新创建广告对象。
  • GAM 无法展示(在 Android 上表现为没有处于 resumed 状态的 Activity):Promise 会在 show() 已经返回 true 之后才被拒绝,因此这种情况上报为 onAdShowFailed。填充并没有丢失——下一次 show() 会展示仍然在手的那个广告。
  • 成功展示并关闭之后:当应用准备预备下一次广告机会时再次调用 load()——CloudX 再次优先。

卸载时,CloudX 的 Hook 会释放其事件订阅并销毁插屏实例,GAM 的事件监听器则由 effect 的清理函数取消订阅。

状态参考

GAM 只能通过 CloudX 的错误路径触达,因此任一时刻只有一个来源在加载,而下一次广告机会总是从 CloudX 重新开始。

状态isReadyshow()load()
空闲falsefalse — 让应用流程继续启动 CloudX
CloudX 加载中falsefalse — 让应用流程继续无操作
CloudX 就绪true展示 CloudX无操作
GAM 加载中falsefalse — 让应用流程继续无操作
GAM 就绪true展示 GAM无操作
展示中truefalse — 已有一次展示在进行中无操作
两者都失败falsefalse — 让应用流程继续从 CloudX 重新开始

检查您的集成

Hook 决定顺序。它无法决定的是您的 App Key、广告单元 ID 和控制台配置是否正确,而这些都在您的项目里。其中任意一项出错时,症状都是静默的:回退照常出广告,一切看起来都很健康。

一个检查就够了。把每个回调本来就携带的来源打印出来:

onAdLoaded: source => console.log(`First Look loaded: ${source}`);

用您真实的广告单元 ID 运行,并留意填充时是否出现 cloudx。如果始终只看到 gam,说明 CloudX 完全没有填充——请先检查 App Key、广告单元 ID 和控制台配置,再去看 Hook。

对于横幅广告,请观察超过一个刷新延迟的时长:每一次 GAM 填充之前都应该先有一次新的 CloudX 尝试,而不只是第一次。如果 CloudX 只被请求过一次就再也没有,说明有东西在终止周期——可能是仍然开启的 SDK 刷新计时器,或者同一个广告单元上挂载了第二个组件。

如果要主动验证回退——在上线前确认您的 GAM 广告单元配置无误——可以把 CloudX 广告单元 ID 指向一个控制台中不存在的字符串,这样每次 CloudX 加载都会失败,只能由回退来填充。此时序列应该是:

cloudx no-fill -> gam attempt -> gam fill -> (refresh delay) -> cloudx attempt

换回可用的 CloudX ID 后,同样的观察还能确认一个同样重要的反面情形:CloudX 未填充与 Banner loaded (gam) 之间不应出现任何加载失败的日志,因为 CloudX 的未填充并非最终失败。

常见错误

以下模式适用于任何回退聚合平台,不限于上文示例中使用的那一个。它们大多最终都会导致同一种故障:同一次广告机会有两个来源在加载。

  • 为了“始终有一个就绪”而提前加载回退广告。 这样每次广告机会都会产生两次填充,被丢弃的那一次就浪费了;而且大多数聚合 SDK 都会让长时间未展示的插屏过期——GAM 插屏约一小时后过期,且不会产生任何 impression。触发回退加载的唯一条件是 CloudX 失败——加载失败或展示失败。

  • 旧的预加载逻辑仍在运行。 如果在接入 CloudX 之前,您的应用会在启动时预加载回退聚合平台,那段代码现在会与 Hook 并行运行,即使 Hook 本身完全正确。请让每个广告单元只有一个归属方;如果在第一次广告位触发时机之前、应用启动阶段就出现回退请求,说明旧路径仍然是活跃的。

  • 用计时器触发回退。 如果因为 CloudX “慢”就用超时去加载回退广告,那么在正常竞价延迟下它也会触发,而当 CloudX 随后填充时就重复占用了同一次广告机会。只在 CloudX 失败时触发回退;如果确实需要一个截止时间,请像横幅 Hook 的 15 秒超时那样取消并替换本次尝试,绝不要让两次加载同时在进行。

  • 同一个广告位挂载两次。 导航器会让屏幕保持挂载,因此同一个广告单元上的两个槽位会运行两套周期,请求量翻倍。横幅视图之间不会串事件——每个都通过自己的 props 上报——它们只是在争夺同一个广告位。同一个广告单元上挂两个插屏 Hook 更糟:两者都会订阅该广告单元的事件,因此其中一个实例的未填充会触发另一个实例的回退。每个广告位只保留一个。

  • 把请求门控改成 useState 这些门控——回退请求标记和各个计时器——之所以用 ref,是因为判断必须在同一个 tick 内完成读取和写入;而状态更新是批处理的,两个事件可能都通过同一个判断。请保持它们是 ref,并保留插屏 Hook 对 CloudX 就绪标记的 ref 镜像——它的事件处理函数需要在下一次渲染提交之前就读到这些值。

  • 绕过 Hook 直接展示。 本 Hook 的 show() 任何时候都可以安全调用——没有广告就绪时它只会返回 false。但 useCloudXInterstitial 自己的 show() 不是:在没有广告加载的情况下调用它会设置 error,而 CloudX 的错误正是触发回退的条件,因此在 CloudX 加载期间的一次投机性展示会启动并行的 GAM 加载。请统一通过本 Hook 调用。

  • load() 套上自己的重试逻辑。 在渲染函数体中调用 load()、在依赖不稳定的 effect 中调用它,或在它外面再套一个通用重试助手,都会把一个失败的广告位变成请求风暴。请从挂载 effect 或用户流程节点发起加载。横幅 Hook 内部已经实现了退避;插屏的退避需要您自己写,就像上文的 scheduleRetry