Trusted Arbiter

在 iOS 应用中比较 CloudX 出价与受支持的第三方出价

Trusted Arbiter 会比较已加载的 CloudX 出价和受支持的第三方出价,并返回被选中的平台。CloudX iOS SDK 3.4.0 支持 CloudX、Unity LevelPlay 和 PubMatic 出价输入。CloudX iOS SDK 3.5.0 及更高版本还支持发布商传入的自定义出价输入。

为什么使用 Trusted Arbiter?

当应用从多个平台加载广告时,需要决定展示哪个广告。Trusted Arbiter 为受支持的出价提供统一的比较 API。

减少需要维护的出价比较逻辑

将受支持的出价提交给 Trusted Arbiter,根据返回的平台选择要展示的广告。应用仍需负责合作伙伴 SDK 的集成、广告加载和展示。

使用可用的出价值

在可以获取实际出价值时,Trusted Arbiter 可以使用这些值进行比较。部分广告需求使用估算价格。请参阅下文中各类受支持输入的定价说明。

示例应用

CloudX iOS 示例应用以插屏广告运行本页描述的流程,提供 Swift 和 Objective-C 两个版本。CloudX 和 AdMob 并行加载,加载成功的广告作为出价参与,由 CloudXCore.shared.arbiter(with:completion:) 选出胜出方,并从保存的结果中展示,因此展示路径不会发起网络请求。

控制器通过示例的 DemoAppLogger 记录日志,复制时请换成您自己的日志方式。示例应用使用 Google 的 AdMob 测试广告单元,它们上报的收入为 0。CloudX 不会保留 0 价格,因此测试期间 AdMob 出价没有可比价格。改为指向真实投放的广告单元,即可看到价格之间的比较;也可以按示例 README 的说明,在单次启动时为 AdMob 出价设置手动价格。

何时运行仲裁器

请在候选广告加载完成时运行仲裁器,绝不要在展示路径上运行。仲裁器调用是一次网络往返,因此当用户点击某个会展示广告的按钮时,绝不能让其等待这次调用。提前在广告位之前完成仲裁,展示本身就变成一次即时的本地决策。

全屏格式(插屏、激励视频、开屏):提前准备

并行加载所有候选广告。当它们全部落定(加载成功或失败)后,把已加载的候选提交给仲裁器,并存储结果。到达广告位时,立即展示已存储的获胜方;此处不再发生任何仲裁器调用。在广告展示或关闭之后、展示失败之后,或某个候选过期时,重新开始该周期。

如果在存储获胜方之前就到达了广告位,可以选择不展示广告继续流程,或者展示那个唯一加载成功的候选。这是可以接受的降级路径,但绝不应作为主路径。

// 在候选广告加载完成后立即执行,早于广告位。
func prepareWinner(configuration: CLXArbiterConfiguration) {
    CloudXCore.shared.arbiter(with: configuration) { [weak self] result in
        self?.nextWinner = result
    }
}

// 在广告位处执行。此处没有网络调用。
func showInterstitial(from viewController: UIViewController) {
    switch nextWinner?.platform.name {
    case CLXArbiterPlatform.cloudX.name:
        cloudXInterstitial?.show(from: viewController)
    case CLXArbiterPlatform.adMob.name:
        adMobInterstitial?.present(fromRootViewController: viewController)
    default:
        break // 未准备好获胜方;不展示广告继续流程
    }
    nextWinner = nil
}

视图格式(横幅、MREC、原生):先仲裁,再渲染

视图格式没有任何由用户发起的操作在等待,因此在这里直接在完成回调中挂载或渲染获胜方是正确的做法——仲裁器完成本身就是展示的触发点。任何时候都只能挂载获胜方的视图或素材;挂载与刷新规则参见横幅广告和 MREC 仲裁。

支持的广告格式

Trusted Arbiter 与广告格式无关:它接收任何已加载的 CloudX 广告,并将其与传入的第三方出价进行比较,与格式无关。仲裁负载会附加到所有格式的 CloudX 广告上——横幅、MREC、原生、插屏、激励视频和开屏。

  • 插屏广告、激励视频广告和开屏广告(全屏格式)需要在广告位之前提前准备好获胜方,并在广告位处展示已存储的结果,详见何时运行仲裁器。本页后续的分步和控制器模式都遵循该规则。完整示例:插屏广告示例和开屏广告示例。激励视频广告完全沿用插屏广告的模式,只是广告类型不同。
  • 横幅广告、MREC 和原生广告(视图格式)先仲裁再渲染,并需要下方横幅广告和 MREC 仲裁一节中描述的额外处理,因为落败出价的视图绝不能被加入视图层级,并且刷新周期需要手动协调。完整示例:横幅广告示例和原生广告示例。

基础 API

从已加载的广告创建出价候选项,然后传给仲裁器。

// cloudXAd 是 CloudX 加载回调中的 CLXAd 对象。
// levelPlayAdInfo 是 Unity LevelPlay 的广告信息对象。
// pubMaticPrice 和 pubMaticPartnerName 来自 PubMatic/OpenWrap 出价对象。
CLXArbiterBid *cloudXBid = [CLXArbiterBid cloudXBidWithAd:cloudXAd];

CLXArbiterBid *levelPlayBid =
    [CLXArbiterBid levelPlayBidWithNetworkName:levelPlayAdInfo.adNetwork
                                       revenue:levelPlayAdInfo.revenue.doubleValue
                                     precision:levelPlayAdInfo.precision];

CLXArbiterBid *pubMaticBid =
    [CLXArbiterBid pubMaticBidWithPrice:pubMaticPrice
                            partnerName:pubMaticPartnerName
                                 extras:nil];

CLXArbiterConfiguration *configuration =
    [CLXArbiterConfiguration configurationWithBids:@[cloudXBid, levelPlayBid, pubMaticBid]];

[[CloudXCore shared] arbiterWithConfiguration:configuration completion:^(CLXArbiterResult *result) {
    NSLog(@"Selected platform: %@", result.platform.name);
}];

CLXArbiterBid.cloudX 接收 CloudX 加载回调中的 CLXAd 对象。CLXArbiterBid.levelPlay 接收 Unity LevelPlay 广告信息值。CLXArbiterBid.pubMatic 接收 PubMatic OpenWrap 出价价格和可选的合作伙伴名称。extras 映射在 LevelPlay 和 PubMatic 出价中均为可选参数,partnerName 在 PubMatic 出价中为可选参数。完成回调在主线程上执行,因此你可以直接在其中展示广告或更新 UI。

result.platform 在选中平台时为 CLXArbiterPlatform.cloudX、levelPlay 或 pubMatic;当无法选出获胜平台时则为 CLXArbiterPlatform.none——可能是未传入任何出价,也可能是传入了多个出价,但在回退时它们都没有可在本地比较的价格。

完成回调是存储结果的地方,而不是展示全屏广告的地方。参见何时运行仲裁器。

AdMob 与 Google Ad Manager

CloudX 会将已加载的 CloudX 广告与已加载的 AdMob 或 Google Ad Manager 广告进行比较。AdMob 和 Ad Manager 是两个独立的需求来源,因此二者可以同时参与同一次仲裁。此方式适用于发布商自行管理的聚合配置,包括商业上称为 AdMob Pro 的账户;SDK 不提供单独的 AdMob Pro API。

Google 需求方通常不会在展示前透露已加载广告的价格,因此仲裁器无法获得可与 CloudX 出价比较的 pre-bid 价格。CloudX 会根据你的 Google 历史表现自动为出价定价,因此你无需自行提供价格,也不需要任何 pre-bid 定价 API。参见 AdMob/GAM estimated 定价的工作方式。

如果你的 AdMob 账户能够在 pre-bid 阶段提供展示级收益数据,也可以自行提供该精确价格来代替使用估算值——参见下方使用 pre-bid ILRD 手动输入价格。

将 Google 付费事件回传给 CloudX(必需)

转发 Google 的付费事件是 Trusted Arbiter 接入 AdMob 与 Ad Manager 的必需环节,而不是可选的分析功能。你回传的收入会为 CloudX 为出价定价所依据的 Google 历史表现提供数据。

当通过仲裁获胜的 AdMob 或 Ad Manager 广告展示完成后,请把 Google 的展示级收入数据——即传给广告 paidEventHandler 的 GADAdValue——通过 reportRevenueData(_:) 转发进 CloudX SDK。AdMob 广告使用 CLXRevenuePlatformAdMob,Ad Manager 广告使用 CLXRevenuePlatformGAM。如果缺少这一回传,CloudX 将无法学习到你广告单元的真实成交价格,后续的仲裁估算质量会随之下降。

在 iOS 上,GADAdValue.value 是以货币单位表示的 NSDecimalNumber,可直接传入,无需换算。不要除以 1,000,000——只有 Android 和 Unity 版 Google Mobile Ads SDK 才以微单位(micros)上报广告价值。

func revenuePrecision(from precision: GADAdValuePrecision) -> CLXRevenuePrecision {
    switch precision {
    case .precise: return .exact
    case .estimated: return .estimated
    case .publisherProvided: return .publisherDefined
    default: return .undefined
    }
}

// Attach once to each Google ad you loaded as an arbiter candidate.
adMobInterstitial.paidEventHandler = { [weak adMobInterstitial] adValue in
    let servedBy = adMobInterstitial?.responseInfo.loadedAdNetworkResponseInfo
    let data = CLXRevenueData.revenueData(
        platform: .adMob,   // .gam for an Ad Manager ad
        revenue: adValue.value.doubleValue,
        adFormat: "interstitial"
    ) { builder in
        builder.currencyCode = adValue.currencyCode
        builder.precision = revenuePrecision(from: adValue.precision)
        builder.networkName = servedBy?.adSourceName
        builder.adUnitId = adMobAdUnitId
        builder.thirdPartyAdPlacementId = servedBy?.adSourceInstanceName
    }

    CloudXCore.shared.reportRevenueData(data)
}

每个广告对象只需挂载一次处理器。对于横幅广告和 MREC,AdMob 会在同一个视图自动刷新时重新触发付费事件,因此只挂载一次即可持续上报每一次刷新后的展示。完整的上报设置与全部字段列表参见发布商上报收入数据。

用你已加载广告的广告单元 ID 创建出价,在候选广告加载完成后运行仲裁器,并为广告位存储获胜方:

// AdMob 广告单元。networkName 为可选参数;如果你知道获胜的广告来源,请传入它,
// 例如 responseInfo.loadedAdapterResponseInfo?.adSourceName。
let adMobBid = CLXArbiterBid.adMob(
    adUnitId: adMobAdUnitId,
    networkName: adMobNetworkName ?? "admob",
    manualRevenuePerImpressionUSD: nil,
    extras: [:]
)

// Ad Manager 广告单元 ID 的格式为 /NNNNNNN/placement/name。
let adManagerBid = CLXArbiterBid.gam(adUnitId: "/21775744923/example/interstitial")

let configuration = CLXArbiterConfiguration.configuration(
    bids: [CLXArbiterBid.cloudX(ad: cloudXAd), adMobBid, adManagerBid],
    builderBlock: nil
)

// 在候选广告加载完成后执行,早于广告位。
CloudXCore.shared.arbiter(with: configuration) { [weak self] result in
    self?.nextWinner = result
}

到达广告位时,展示已存储的获胜方。此处不会发生网络调用。

func showInterstitial(from viewController: UIViewController) {
    switch nextWinner?.platform.name {
    case CLXArbiterPlatform.cloudX.name:
        cloudXInterstitial.show(from: viewController)
    case CLXArbiterPlatform.adMob.name:
        adMobInterstitial.present(fromRootViewController: viewController)
    case CLXArbiterPlatform.gam.name:
        adManagerInterstitial.present(fromRootViewController: viewController)
    default:
        break // 未准备好获胜方;不展示广告继续流程
    }
    nextWinner = nil
}

获胜的 Google 出价会报告自己的平台——CLXArbiterPlatform.adMob 或 CLXArbiterPlatform.gam。你不再需要像处理自定义出价那样,通过 result.platformName 来区分这两个来源。

传入空白的广告单元 ID,出价对象仍会正常构建,不会导致应用崩溃,但它不具备可用的身份信息:既不会被定价,也会被服务器拒绝。

使用 pre-bid ILRD 手动输入价格

部分 AdMob 账户可以在 pre-bid 阶段提供展示级收益数据(ILRD):已加载广告的 GADAdValue 在加载时即可获取,早于广告展示。这是一项受账户控制的历史能力,请与你的 Google 客户团队确认你的账户是否已启用。在展示之前就已知的精确单次展示价格,是自行提供价格优于 CloudX 估算值的唯一场景。

将 pre-bid 广告价值作为 manualRevenuePerImpressionUSD 传入,它会覆盖估算值:

// GADAdValue.value 是已按货币单位表示的 NSDecimalNumber,直接原样传入即可,不要做任何除法。
let adMobBid = CLXArbiterBid.adMob(
    adUnitId: adMobAdUnitId,
    networkName: adMobNetworkName ?? "admob",
    manualRevenuePerImpressionUSD: preBidAdValue.value,
    extras: [:]
)

该数值的处理方式:

  • 0.0 是一个真实的价格。它表示该出价价值为零——而不是价格缺失。
  • 负值和非有限值不是有效价格,因此会被视为缺失并记录日志。
  • 空白的广告单元 ID 会导致手动价格被完全丢弃,因为没有身份信息的出价无法通过校验。

manualRevenuePerImpressionUSD 是单次展示的美元收益,而不是 CPM。请换算你的价格来源报告的数值:

  • AdMob 广告价值 在 iOS 上无需缩放。GADAdValue.value 是已按货币单位表示的 NSDecimalNumber,因此数值 0.005 就对应单次展示 0.005,直接原样传入即可——既不要除以 1,000,也不要除以 1,000,000。只有 Android 和 Unity 版 Google Mobile Ads SDK 才以微单位(micros)报告广告价值。
  • 非美元金额 必须先换算为美元。

分步示例:在 CloudX 与 LevelPlay 之间仲裁

本演练展示了应读取哪个 Unity LevelPlay 回调,以及应将哪些值传入仲裁器。示例使用插屏广告,但相同的字段映射适用于任何广告格式——横幅广告和 MREC 有何不同请参见支持的广告格式。

加载两个候选项

创建 CloudX 和 LevelPlay 插屏广告,设置各自的 delegate,并为每个平台启动加载。

self.cloudXInterstitial = [[CloudXCore shared] createInterstitialWithAdUnitId:@"YOUR_CLOUDX_AD_UNIT_ID"];
self.cloudXInterstitial.delegate = self;
[self.cloudXInterstitial load];

self.levelPlayInterstitial = [[LPMInterstitialAd alloc] initWithAdUnitId:@"YOUR_LEVELPLAY_AD_UNIT_ID"];
self.levelPlayInterstitial.delegate = self;
[self.levelPlayInterstitial loadAd];

保存每个平台已加载的广告

LevelPlay 在其加载回调中提供 LPMAdInfo;CloudX 提供 CLXAd。请保存两者,下一步会从中读取仲裁输入。

// 属性:@property (nonatomic, strong) CLXAd *cloudXAd;
//       @property (nonatomic, strong) LPMAdInfo *levelPlayInfo;

// CLXInterstitialDelegate
- (void)didLoadAd:(CLXAd *)ad {
    self.cloudXAd = ad;
}

// LPMInterstitialAdDelegate
- (void)didLoadAdWithAdInfo:(LPMAdInfo *)adInfo {
    self.levelPlayInfo = adInfo;
}

将值映射为出价

从 LPMAdInfo 读取 LevelPlay 字段,并传给 CLXArbiterBid.levelPlay。CloudX 出价直接接收 CLXAd。只提交实际加载成功的平台。

LPMAdInfo 字段类型CLXArbiterBid.levelPlay 参数
adNetworkNSString *networkName
revenueNSNumber *revenue(用 .doubleValue 解包)
precisionNSString *precision
NSMutableArray<CLXArbiterBid *> *bids = [NSMutableArray array];

if (self.cloudXAd) {
    [bids addObject:[CLXArbiterBid cloudXBidWithAd:self.cloudXAd]];
}

if (self.levelPlayInfo) {
    CLXArbiterBid *levelPlayBid =
        [CLXArbiterBid levelPlayBidWithNetworkName:self.levelPlayInfo.adNetwork
                                           revenue:self.levelPlayInfo.revenue.doubleValue
                                         precision:self.levelPlayInfo.precision];
    [bids addObject:levelPlayBid];
}

CLXArbiterConfiguration *configuration =
    [CLXArbiterConfiguration configurationWithBids:bids];

运行仲裁器并存储获胜方

将 configuration 连同完成回调传给仲裁器。请在两个插屏广告都加载完成(或加载失败)后再运行:跟踪各自的加载回调和加载失败,并且只提交成功加载的候选项。这一步发生在广告位之前,因此完成回调只存储结果,不展示广告。完成回调在主线程上执行。

__weak typeof(self) weakSelf = self;
[[CloudXCore shared] arbiterWithConfiguration:configuration completion:^(CLXArbiterResult *result) {
    weakSelf.nextWinner = result;
}];

在广告位处展示已存储的获胜方

到达广告位时,将已存储结果的 platform.name 与平台常量比较,展示该平台的广告。此处不会发生任何仲裁器调用。CLXArbiterPlatform.none,或者根本没有存储获胜方,都表示没有可展示的广告,此时不展示广告,继续应用流程。展示之后清空已存储的获胜方,并开始下一个加载周期。

- (void)showStoredWinner {
    NSString *platform = self.nextWinner.platform.name;
    if ([platform isEqualToString:CLXArbiterPlatform.cloudX.name]) {
        [self.cloudXInterstitial showFromViewController:self];
    } else if ([platform isEqualToString:CLXArbiterPlatform.levelPlay.name]) {
        [self.levelPlayInterstitial showAdWithViewController:self placementName:nil];
    }
    // 未存储获胜方,或为 CLXArbiterPlatform.none:不展示广告,继续应用流程
    self.nextWinner = nil;
}

下方的 ArbiterInterstitialController 是提前准备规则的参考实现:它将上述步骤封装为一个可复用的组件。

插屏广告示例

这个插屏广告示例是提前准备规则的参考实现,会在两个平台之间仲裁:CloudX 和 Unity LevelPlay。在到达广告位之前先准备好获胜平台:

  1. 并行加载 CloudX 和 LevelPlay。
  2. 等待两个平台都加载完成或加载失败。
  3. 只将已加载的候选项提交给 Trusted Arbiter。
  4. 缓存选中的平台。
  5. 到达广告位时,立即展示缓存的获胜广告。

如果两个平台都加载失败,开始新的加载周期。如果到达广告位时还没有准备好获胜平台,则继续应用流程,不展示广告。

ArbiterInterstitialController.swift
/// 提前准备好 Trusted Arbiter 的获胜平台,以便在到达广告位时立即展示插屏广告。
///
/// 并行加载 CloudX 和 LevelPlay 插屏广告,等待两者都加载完成或加载失败,
/// 将已加载的候选项提交给 CloudXCore.shared.arbiter,并将选中的
/// CLXArbiterPlatform 缓存到 nextWinner 中。
final class ArbiterInterstitialController: NSObject {
    protocol Listener: AnyObject {
        /// 当仲裁器为下一次展示选出平台后调用。
        func arbiterInterstitialController(
            _ controller: ArbiterInterstitialController,
            didPrepareWinner platform: CLXArbiterPlatform
        )
    }

    weak var listener: Listener?

    private let cloudXInterstitial: CLXInterstitial
    private let levelPlayInterstitial: LPMInterstitialAd
    private var cloudXAd: CLXAd?
    private var cloudXLoadDone = false
    private var levelPlayAdInfo: LPMAdInfo?
    private var levelPlayLoadDone = false
    private var nextWinner: CLXArbiterPlatform?

    init(cloudXInterstitial: CLXInterstitial, levelPlayInterstitial: LPMInterstitialAd) {
        self.cloudXInterstitial = cloudXInterstitial
        self.levelPlayInterstitial = levelPlayInterstitial
        super.init()
        self.cloudXInterstitial.delegate = self
        self.levelPlayInterstitial.setDelegate(self)
    }

    /// 为每个当前没有缓存广告的平台启动加载。
    func loadMissingAds() {
        if cloudXAd == nil { cloudXInterstitial.load() }
        if levelPlayAdInfo == nil { levelPlayInterstitial.loadAd() }
    }

    /// 展示已准备好的获胜广告,仅当确实发起了展示调用时返回 true。
    ///
    /// 当没有准备好获胜平台或缓存的广告已不可用时返回 false,
    /// 此时会启动一次新的加载周期。
    func showAtPlacement(from viewController: UIViewController, placementName: String? = nil) -> Bool {
        guard let platformName = nextWinner?.name else { return false }

        if platformName == CLXArbiterPlatform.cloudX.name {
            return showCloudX(from: viewController, placementName: placementName)
        }

        if platformName == CLXArbiterPlatform.levelPlay.name {
            return showLevelPlay(from: viewController, placementName: placementName)
        }

        return false
    }

    /// 在两个平台都完成后运行仲裁器,然后缓存获胜平台。
    ///
    /// 在两个加载都完成之前提前返回。如果两个平台都没有加载成功,则重启加载周期;
    /// 否则将已加载的候选项提交给 CloudXCore.shared.arbiter。
    private func maybePrepareWinner() {
        guard cloudXLoadDone, levelPlayLoadDone else { return }

        if cloudXAd == nil && levelPlayAdInfo == nil {
            cloudXLoadDone = false
            levelPlayLoadDone = false
            loadMissingAds()
            return
        }

        var bids: [CLXArbiterBid] = []
        if let cloudXAd {
            bids.append(CLXArbiterBid.cloudX(ad: cloudXAd))
        }

        if let levelPlayAdInfo {
            bids.append(CLXArbiterBid.levelPlay(
                networkName: levelPlayAdInfo.adNetwork,
                revenue: levelPlayAdInfo.revenue?.doubleValue ?? 0,
                precision: levelPlayAdInfo.precision
            ))
        }

        let configuration = CLXArbiterConfiguration.configuration(bids: bids, builderBlock: nil)
        CloudXCore.shared.arbiter(with: configuration) { [weak self] result in
            guard let self else { return }
            nextWinner = result.platform
            listener?.arbiterInterstitialController(self, didPrepareWinner: result.platform)
        }
    }

    private func showCloudX(from viewController: UIViewController, placementName: String?) -> Bool {
        if cloudXInterstitial.isReady {
            if let placementName {
                cloudXInterstitial.show(from: viewController, placement: placementName, customData: nil)
            } else {
                cloudXInterstitial.show(from: viewController)
            }
            return true
        }

        clearCloudXAndLoadMissingAds()
        return false
    }

    private func showLevelPlay(from viewController: UIViewController, placementName: String?) -> Bool {
        if levelPlayInterstitial.isAdReady() {
            levelPlayInterstitial.showAd(viewController: viewController, placementName: placementName)
            return true
        }

        clearLevelPlayAndLoadMissingAds()
        return false
    }

    private func clearCloudXAndLoadMissingAds() {
        cloudXAd = nil
        cloudXLoadDone = false
        nextWinner = nil
        loadMissingAds()
    }

    private func clearLevelPlayAndLoadMissingAds() {
        levelPlayAdInfo = nil
        levelPlayLoadDone = false
        nextWinner = nil
        loadMissingAds()
    }
}

extension ArbiterInterstitialController: CLXInterstitialDelegate {
    func didLoad(_ ad: CLXAd) {
        cloudXAd = ad
        cloudXLoadDone = true
        maybePrepareWinner()
    }

    func didFailToLoadAd(_ adUnitId: String, error: CLXError) {
        cloudXAd = nil
        cloudXLoadDone = true
        maybePrepareWinner()
    }

    func didDisplay(_ ad: CLXAd) {}

    func didFailToDisplay(_ ad: CLXAd, error: CLXError) {
        clearCloudXAndLoadMissingAds()
    }

    func didHide(_ ad: CLXAd) {
        clearCloudXAndLoadMissingAds()
    }

    func didClick(_ ad: CLXAd) {}
}

extension ArbiterInterstitialController: LPMInterstitialAdDelegate {
    func didLoadAd(with adInfo: LPMAdInfo) {
        levelPlayAdInfo = adInfo
        levelPlayLoadDone = true
        maybePrepareWinner()
    }

    func didFailToLoadAd(withAdUnitId adUnitId: String, error: Error) {
        levelPlayAdInfo = nil
        levelPlayLoadDone = true
        maybePrepareWinner()
    }

    func didChangeAdInfo(_ adInfo: LPMAdInfo) {
        levelPlayAdInfo = adInfo
    }

    func didDisplayAd(with adInfo: LPMAdInfo) {}

    func didFailToDisplayAd(with adInfo: LPMAdInfo, error: Error) {
        clearLevelPlayAndLoadMissingAds()
    }

    func didCloseAd(with adInfo: LPMAdInfo) {
        clearLevelPlayAndLoadMissingAds()
    }

    func didClickAd(with adInfo: LPMAdInfo) {}
}

showAtPlacement(from:placementName:) 仅当确实发起了展示调用时返回 true。didChangeAdInfo(_:) 会在 LevelPlay 广告保持加载状态期间更新缓存的 LevelPlay 候选项值。

对于 PubMatic OpenWrap,使用 CLXArbiterBid.pubMatic(price:partnerName:extras:) 创建第三方出价。如果仲裁服务不可用、超时或失败,SDK 会在传入的受支持出价输入中回退选择可比较美元出价最高的平台。

开屏广告示例

开屏广告是一种全屏格式,因此它会像插屏广告和激励视频广告一样,提前在广告位之前准备好获胜方。不同的是触发方式:你的界面上没有任何操作会主动请求展示开屏广告,因此这个决策必须在应用处于后台时就做好——用户返回应用时,绝不能让他等待一次加载或一次仲裁器的往返调用。

ArbiterAppOpenController 会在 CloudX 开屏广告与 AdMob 开屏广告之间进行仲裁:

  1. 在应用启动时加载两个平台,并在每次展示后重新加载。
  2. 等两个平台都已确定结果后运行仲裁器,并存储获胜方。
  3. 在下一次前台切换时,展示已存储的获胜方,并开始下一个周期。
  4. 如果没有存储的获胜方,则让用户直接返回应用,不展示广告,并开始新的加载。
ArbiterAppOpenController.swift
/// 在 CloudX 开屏广告与 AdMob 开屏广告之间进行仲裁,并在下一次前台切换时展示获胜方。
///
/// 开屏广告没有用户触发的展示动作,因此当用户返回应用时,获胜方必须已经选定。
/// loadMissingAds() 会启动两个平台的加载,仲裁器会在两者都已确定结果后立即运行,
/// 而 showOnForeground(from:) 只会展示此前已经存储好的结果。
final class ArbiterAppOpenController: NSObject {
    private let cloudXAppOpen: CLXAppOpen?
    private let adMobAdUnitId: String
    private var cloudXAd: CLXAd?
    private var cloudXLoadDone = false
    private var adMobAppOpen: GADAppOpenAd?
    /// 正在展示或最近展示过的广告。保留它,好让迟到的付费事件仍能找到对应的广告。
    private var presentedAdMobAd: GADAppOpenAd?
    private var adMobLoadDone = false
    private var cloudXLoadInFlight = false
    private var adMobLoadInFlight = false
    private var nextWinner: CLXArbiterPlatform?
    private var isShowingAd = false

    init(cloudXAdUnitId: String, adMobAdUnitId: String) {
        cloudXAppOpen = CloudXCore.shared.createAppOpen(adUnitId: cloudXAdUnitId)
        self.adMobAdUnitId = adMobAdUnitId
        super.init()
        cloudXAppOpen?.delegate = self
    }

    /// 为既没有缓存广告、也没有进行中请求的平台启动加载。
    ///
    /// 每次启动加载时都会清除该平台的 `LoadDone` 标志,因此只有当本轮发起的所有请求都返回后
    /// 才会进行仲裁。如果不重置,上一轮加载失败的平台在重试时会遇到仍为 true 的标志,
    /// 仲裁就会基于只有一个候选的快照运行。
    func loadMissingAds() {
        if cloudXAd == nil, !cloudXLoadInFlight {
            cloudXLoadInFlight = true
            cloudXLoadDone = false
            cloudXAppOpen?.load()
        }

        if adMobAppOpen == nil, !adMobLoadInFlight {
            adMobLoadInFlight = true
            adMobLoadDone = false
            loadAdMob()
        }
    }

    /// 展示已准备好的获胜广告,仅在实际调用了展示方法时返回 true。
    ///
    /// 请从前台观察者中调用该方法。它绝不会拖慢应用返回前台的流程:如果已有广告在屏幕上展示,
    /// 或者还没有准备好获胜方,它会返回 false,并启动下一个加载周期。
    func showOnForeground(from viewController: UIViewController) -> Bool {
        guard !isShowingAd else { return false }

        switch nextWinner?.name {
        case CLXArbiterPlatform.cloudX.name:
            return showCloudX(from: viewController)
        case CLXArbiterPlatform.adMob.name:
            return showAdMob(from: viewController)
        case CLXArbiterPlatform.none.name:
            // 仲裁器判定没有获胜方,因此两个缓存的广告都不可用,需要丢弃:
            // loadMissingAds() 只会为空槽位发起请求,如果不丢弃,这两个候选会一直留在原处,
            // 后续任何一轮都无法再运行。
            discardCandidatesAndReload()
            return false
        default:
            // nextWinner 为 nil —— 仲裁周期仍在进行中,不要干预。
            return false
        }
    }

    /// 释放两个平台的广告。请在所属对象销毁时调用该方法。
    func destroy() {
        cloudXAppOpen?.destroy()
        adMobAppOpen = nil
        presentedAdMobAd = nil
    }

    /// 在两个平台都完成加载后运行仲裁器,然后缓存获胜平台。
    ///
    /// 在两个加载都完成之前会提前返回。如果两个平台都未加载成功,则重新启动加载周期;
    /// 否则将已加载的候选项提交给 CloudXCore.shared.arbiter。
    private func maybePrepareWinner() {
        guard cloudXLoadDone, adMobLoadDone else { return }

        if cloudXAd == nil && adMobAppOpen == nil {
            loadMissingAds()
            return
        }

        var bids: [CLXArbiterBid] = []
        if let cloudXAd {
            bids.append(CLXArbiterBid.cloudX(ad: cloudXAd))
        }
        if adMobAppOpen != nil {
            bids.append(CLXArbiterBid.adMob(adUnitId: adMobAdUnitId))
        }

        let configuration = CLXArbiterConfiguration.configuration(bids: bids, builderBlock: nil)
        CloudXCore.shared.arbiter(with: configuration) { [weak self] result in
            self?.nextWinner = result.platform
        }
    }

    private func showCloudX(from viewController: UIViewController) -> Bool {
        guard let cloudXAppOpen, cloudXAppOpen.isReady else {
            clearCloudXAndLoadMissingAds()
            return false
        }

        isShowingAd = true
        cloudXAppOpen.show(from: viewController, placement: "app_foreground", customData: nil)
        return true
    }

    private func showAdMob(from viewController: UIViewController) -> Bool {
        guard let ad = adMobAppOpen else {
            clearAdMobAndLoadMissingAds()
            return false
        }

        ad.fullScreenContentDelegate = self
        // GADAppOpenAd 是一次性的:present 会消耗它。现在就把它移到一边,这样在展示过程中
        // 返回的加载结果就不会让一个已消耗的广告参与仲裁。它会继续存活在 presentedAdMobAd 中,
        // 因为 AdMob 可能在广告已被关闭之后才投递付费事件,而这笔收入正是下一个 AdMob
        // 出价的定价依据。
        presentedAdMobAd = ad
        adMobAppOpen = nil
        isShowingAd = true
        ad.present(fromRootViewController: viewController)
        return true
    }

    private func loadAdMob() {
        GADAppOpenAd.load(withAdUnitID: adMobAdUnitId, request: GADRequest()) { [weak self] ad, error in
            guard let self else { return }

            if let error {
                print("AdMob App Open failed to load: \(error.localizedDescription)")
                adMobAppOpen = nil
            } else {
                ad?.paidEventHandler = { [weak self, weak ad] adValue in
                    self?.reportAdMobPaidEvent(ad, adValue: adValue)
                }
                adMobAppOpen = ad
            }

            adMobLoadInFlight = false
            adMobLoadDone = true
            maybePrepareWinner()
        }
    }

    /// 将 AdMob 的实际收益转发给 CloudX。这是集成中必需的环节,而不是可选的分析附加项:
    /// CloudX 会根据你回传的数据为未来的 AdMob 出价定价。
    ///
    /// revenuePrecision(from:) 就是上文"将 Google 付费事件回传给 CloudX(必需)"
    /// 一节中展示的映射方式。
    private func reportAdMobPaidEvent(_ ad: GADAppOpenAd?, adValue: GADAdValue) {
        let servedBy = ad?.responseInfo.loadedAdNetworkResponseInfo
        let data = CLXRevenueData.revenueData(
            platform: .adMob,
            revenue: adValue.value.doubleValue,
            adFormat: "app_open"
        ) { builder in
            builder.currencyCode = adValue.currencyCode
            builder.precision = revenuePrecision(from: adValue.precision)
            builder.networkName = servedBy?.adSourceName
            builder.adUnitId = self.adMobAdUnitId
            builder.thirdPartyAdPlacementId = servedBy?.adSourceInstanceName
        }

        // 当没有任何一方消费该事件时,reportRevenueData 返回 false。CloudX 会根据你在这里
        // 上报的数据为后续 AdMob 出价定价,因此静默的 false 值得记录下来。
        if !CloudXCore.shared.reportRevenueData(data) {
            print("AdMob App Open paid event was not accepted by the CloudX SDK")
        }
    }

    /// 丢弃两个候选并开始新一轮周期。用于仲裁器判定没有获胜方的情况。
    private func discardCandidatesAndReload() {
        cloudXAd = nil
        adMobAppOpen = nil
        cloudXLoadDone = false
        adMobLoadDone = false
        nextWinner = nil
        loadMissingAds()
    }

    private func clearCloudXAndLoadMissingAds() {
        cloudXAd = nil
        cloudXLoadDone = false
        nextWinner = nil
        isShowingAd = false
        loadMissingAds()
    }

    private func clearAdMobAndLoadMissingAds() {
        adMobAppOpen = nil
        adMobLoadDone = false
        nextWinner = nil
        isShowingAd = false
        loadMissingAds()
    }
}

extension ArbiterAppOpenController: CLXAppOpenDelegate {
    func didLoad(_ ad: CLXAd) {
        cloudXAd = ad
        cloudXLoadInFlight = false
        cloudXLoadDone = true
        maybePrepareWinner()
    }

    func didFailToLoadAd(_ adUnitId: String, error: CLXError) {
        print("CloudX App Open failed to load: \(error.localizedDescription)")
        cloudXAd = nil
        cloudXLoadInFlight = false
        cloudXLoadDone = true
        maybePrepareWinner()
    }

    func didDisplay(_ ad: CLXAd) {}

    func didFailToDisplay(_ ad: CLXAd, error: CLXError) {
        print("CloudX App Open failed to display: \(error.localizedDescription)")
        clearCloudXAndLoadMissingAds()
    }

    func didHide(_ ad: CLXAd) {
        clearCloudXAndLoadMissingAds()
    }

    func didClick(_ ad: CLXAd) {}
}

extension ArbiterAppOpenController: GADFullScreenContentDelegate {
    func adDidDismissFullScreenContent(_ ad: GADFullScreenPresentingAd) {
        clearAdMobAndLoadMissingAds()
    }

    func ad(_ ad: GADFullScreenPresentingAd, didFailToPresentFullScreenContentWithError error: Error) {
        print("AdMob App Open failed to show: \(error.localizedDescription)")
        clearAdMobAndLoadMissingAds()
    }
}

showOnForeground(from:) 只有在实际调用了展示方法时才会返回 true。isShowingAd 用于避免把广告自身的全屏展示误判为第二次前台切换——如果没有它,场景或应用生命周期观察者可能会在广告已经展示在屏幕上时再次进入 showOnForeground(from:)。

请使用 willEnterForegroundNotification 来驱动它,该通知只在应用从后台返回时触发。不要使用 didBecomeActiveNotification:它在应用只是恢复活跃状态时也会触发,这样在 App Tracking Transparency 弹窗、控制中心或来电之后都会展示一次开屏广告。

// 在持有该控制器的类型中,例如你的 scene delegate:
let controller = ArbiterAppOpenController(cloudXAdUnitId: "…", adMobAdUnitId: "…")

NotificationCenter.default.addObserver(
    forName: UIApplication.willEnterForegroundNotification,
    object: nil,
    queue: .main
) { [weak self] _ in
    guard let self, let viewController = self.topViewController() else { return }
    _ = self.controller.showOnForeground(from: viewController)
}

获胜的开屏广告出价报告自己平台的方式与其他格式相同:CLXArbiterPlatform.cloudX 或 CLXArbiterPlatform.adMob。请继续遵循 App Open 广告 中的开屏广告位规则——仲裁只会改变由哪个网络来填充广告位,而不会改变何时适合展示广告。

横幅广告和 MREC 仲裁

横幅广告和 MREC 是基于视图的广告格式:每个候选广告网络一旦加载成功,就会立即将广告渲染到视图中,无论该视图最终是否会显示在屏幕上。Trusted Arbiter 选出获胜方的方式不变,但你需要承担全屏格式不需要的两项额外职责。

关闭自动刷新

Trusted Arbiter 需要完全掌控何时请求新的填充、何时切换正在展示的广告,因此必须关闭每个网络自身的刷新定时器:

  • 在 CloudX 控制台中关闭该广告单元的自动刷新。
  • 创建 CLXBannerAdView 后立即对其调用 stopAutoRefresh(参见横幅广告 (320x50))。
  • 对你所仲裁的其他每个网络的对应 API 也关闭自动刷新。

视图挂载

只有获胜出价对应的视图可以被加入视图层级。落败网络的横幅视图一旦被加入父视图(superview),仍会渲染并触发自己的展示事件,因此在某个非获胜视图赢得后续轮次之前(或除非它赢得后续轮次),必须让它保持在屏幕之外(不要对其调用 addSubview: / addSubview(_:))。这与标准横幅广告集成不同——标准集成会在创建视图时立即通过 addSubview 挂载;而在 Trusted Arbiter 中,视图不能在创建时挂载,只能在仲裁选出获胜方之后才挂载。

刷新周期

关闭自动刷新后,需要自行驱动整个周期:

  1. 并行发起加载,将已加载的候选项提交给仲裁器,并挂载获胜方的视图。
  2. 获胜广告的展示事件一旦触发,立即为该获胜网络发起新的加载。
  3. 保留未获胜网络已经加载好的广告,用于下一轮仲裁;只对上一轮未能填充的网络重新发起加载请求。
  4. 待未完成的加载响应全部返回后,再次运行仲裁器。
  5. 每 20-30 秒刷新一次正在展示的广告,每次替换为新的获胜视图。刷新间隔短于 20 秒会降低 CPM 表现。

横幅广告示例

下方的 ArbiterBannerController 是视图格式「先仲裁,再渲染」规则的参考实现:它在 CloudX 和 LevelPlay 的横幅广告之间进行仲裁,任意时刻只挂载一个视图,并驱动上述刷新周期。

/// 以 20-30 秒为周期,在 CloudX 和 LevelPlay 的横幅广告之间进行仲裁。
///
/// 只挂载获胜出价对应的视图。未获胜的视图保持已加载但不挂载的状态,
/// 因此永远不会渲染或触发展示事件。当前展示的获胜广告触发展示事件后,
/// 会为该网络发起新的加载,并保留另一个网络已经加载好的广告,用于下一轮仲裁。
@interface ArbiterBannerController () <CLXBannerDelegate, CLXAdRevenueDelegate, LPMBannerAdViewDelegate>
@property (nonatomic, weak) UIView *containerView;
@property (nonatomic, weak) UIViewController *presentingViewController;
@property (nonatomic, strong) CLXBannerAdView *cloudXBanner;
@property (nonatomic, strong) LPMBannerAdView *levelPlayBanner;
@property (nonatomic, strong) CLXAd *cloudXAd;
@property (nonatomic, assign) BOOL cloudXLoadDone;
@property (nonatomic, strong) LPMAdInfo *levelPlayAdInfo;
@property (nonatomic, assign) BOOL levelPlayLoadDone;
@property (nonatomic, copy) NSString *attachedPlatformName;
@property (nonatomic, strong) NSTimer *refreshTimer;
@end

@implementation ArbiterBannerController

- (instancetype)initWithContainerView:(UIView *)containerView
                presentingViewController:(UIViewController *)presentingViewController
                       cloudXAdUnitId:(NSString *)cloudXAdUnitId
                    levelPlayAdUnitId:(NSString *)levelPlayAdUnitId {
    self = [super init];
    if (self) {
        _containerView = containerView;
        _presentingViewController = presentingViewController;

        _cloudXBanner = [[CloudXCore shared] createBannerWithAdUnitId:cloudXAdUnitId];
        _cloudXBanner.delegate = self;
        _cloudXBanner.revenueDelegate = self;
        [_cloudXBanner stopAutoRefresh];

        LPMBannerAdViewConfigBuilder *levelPlayConfigBuilder = [[LPMBannerAdViewConfigBuilder alloc] init];
        LPMBannerAdViewConfig *levelPlayConfig = [levelPlayConfigBuilder build];
        _levelPlayBanner = [[LPMBannerAdView alloc] initWithAdUnitId:levelPlayAdUnitId
                                                                config:levelPlayConfig];
        _levelPlayBanner.delegate = self;
        // LevelPlay 的自动刷新需要通过 LevelPlay 自身的配置(控制台/API)关闭。
    }
    return self;
}

/// 为每个当前没有已填充广告的网络启动加载。
- (void)loadMissingAds {
    if (!self.cloudXAd) { [self.cloudXBanner load]; }
    if (!self.levelPlayAdInfo) {
        [self.levelPlayBanner loadAdWithViewController:self.presentingViewController];
    }
}

/// 启动周期性的 20-30 秒刷新定时器。仅在首次加载周期开始后调用一次。
- (void)startRefreshTimer {
    [self.refreshTimer invalidate];
    self.refreshTimer = [NSTimer scheduledTimerWithTimeInterval:25.0
                                                          target:self
                                                        selector:@selector(runArbiterIfReady)
                                                        userInfo:nil
                                                         repeats:YES];
}

- (void)runArbiterIfReady {
    if (!self.cloudXLoadDone || !self.levelPlayLoadDone) { return; }

    NSMutableArray<CLXArbiterBid *> *bids = [NSMutableArray array];
    if (self.cloudXAd) {
        [bids addObject:[CLXArbiterBid cloudXBidWithAd:self.cloudXAd]];
    }
    if (self.levelPlayAdInfo) {
        [bids addObject:[CLXArbiterBid levelPlayBidWithNetworkName:self.levelPlayAdInfo.adNetwork
                                                            revenue:self.levelPlayAdInfo.revenue.doubleValue
                                                          precision:self.levelPlayAdInfo.precision]];
    }
    if (bids.count == 0) { return; }

    CLXArbiterConfiguration *configuration = [CLXArbiterConfiguration configurationWithBids:bids];
    [[CloudXCore shared] arbiterWithConfiguration:configuration completion:^(CLXArbiterResult *result) {
        [self attachWinner:result.platform.name];
    }];
}

/// 卸载上一个获胜方的视图,挂载新获胜方的视图,并在获胜网络的展示事件触发后为其发起新的加载。
- (void)attachWinner:(NSString *)platformName {
    [self.cloudXBanner removeFromSuperview];
    [self.levelPlayBanner removeFromSuperview];

    if ([platformName isEqualToString:CLXArbiterPlatform.cloudX.name]) {
        [self.containerView addSubview:self.cloudXBanner];
        self.attachedPlatformName = platformName;
    } else if ([platformName isEqualToString:CLXArbiterPlatform.levelPlay.name]) {
        [self.containerView addSubview:self.levelPlayBanner];
        self.attachedPlatformName = platformName;
    } else {
        self.attachedPlatformName = nil;
    }
}

#pragma mark - CLXBannerDelegate

- (void)didLoadAd:(CLXAd *)ad {
    self.cloudXAd = ad;
    self.cloudXLoadDone = YES;
    [self runArbiterIfReady];
}

- (void)didFailToLoadAd:(NSString *)adUnitId error:(CLXError *)error {
    self.cloudXAd = nil;
    self.cloudXLoadDone = YES;
    [self runArbiterIfReady];
}

#pragma mark - CLXAdRevenueDelegate

- (void)didPayRevenueForAd:(CLXAd *)ad {
    if ([self.attachedPlatformName isEqualToString:CLXArbiterPlatform.cloudX.name]) {
        self.cloudXAd = nil;
        self.cloudXLoadDone = NO;
        [self.cloudXBanner load];
    }
}

#pragma mark - LPMBannerAdViewDelegate

- (void)didLoadAdWithAdInfo:(LPMAdInfo *)adInfo {
    self.levelPlayAdInfo = adInfo;
    self.levelPlayLoadDone = YES;
    [self runArbiterIfReady];
}

- (void)didFailToLoadAdWithAdUnitId:(NSString *)adUnitId error:(NSError *)error {
    self.levelPlayAdInfo = nil;
    self.levelPlayLoadDone = YES;
    [self runArbiterIfReady];
}

- (void)didDisplayAdWithAdInfo:(LPMAdInfo *)adInfo {
    if ([self.attachedPlatformName isEqualToString:CLXArbiterPlatform.levelPlay.name]) {
        self.levelPlayAdInfo = nil;
        self.levelPlayLoadDone = NO;
        [self.levelPlayBanner loadAdWithViewController:self.presentingViewController];
    }
}

@end

didPayRevenue(for:)(CloudX 的展示信号)和 didDisplayAd(with:)(LevelPlay 的展示信号)会触发当前已挂载网络的下一次加载;未获胜网络已经加载好的广告会保持不变,直到它赢得某一轮或被消耗为止。runArbiterIfReady() 既会被加载回调触发,也会被刷新定时器触发,因此只有当两个网络都已就绪时,某一轮才会真正替换已挂载的视图。

原生广告示例

原生广告是一种视图格式,因此会先仲裁,再渲染——但与横幅广告和 MREC 不同的是,原生广告在你创建视图之前并不存在视图。这正是原生广告最容易出错的原因:loadAd(into:) 会在加载时就渲染广告,而此时仲裁器还没有选出任何结果。

ArbiterNativeController 会在 CloudX 原生广告与 AdMob 原生广告之间进行仲裁,并只将其中一个渲染到容器视图中:

  1. 加载两个平台的广告,且不附加任何视图。
  2. 等两个平台都已确定结果后运行仲裁器。
  3. 只为获胜方构建视图,将其渲染进该视图,并将其附加。
  4. 保留落选平台已加载但未渲染的广告,留待下一轮使用。
ArbiterNativeController.swift
/// 在 CloudX 原生广告与 AdMob 原生广告之间进行仲裁,只将获胜方渲染到容器视图中。
///
/// 两个平台都以不带视图的方式加载:CLXNativeAdLoader.loadAd() 在调用时不传入广告视图,
/// 因此加载时不会渲染任何内容,AdMob 广告也会保持未渲染状态。落选出价的素材永远不会被
/// 渲染或附加,因此也不会触发展示事件。
final class ArbiterNativeController: NSObject {
    private weak var containerView: UIView?
    private let cloudXLoader: CLXNativeAdLoader
    private let adMobAdUnitId: String
    private let makeAdMobAdView: (GADNativeAd) -> GADNativeAdView
    private var cloudXAd: CLXAd?
    private var cloudXLoadDone = false
    private var adMobNativeAd: GADNativeAd?
    private var adMobLoadDone = false
    private var adMobAdLoader: GADAdLoader?
    private var cloudXLoadInFlight = false
    private var adMobLoadInFlight = false
    private var renderedPlatformName: String?
    private var renderedAdView: UIView?
    /// 已附加视图背后的广告。它们的生命周期长于各自的候选状态:已渲染的广告在展示事件触发后
    /// 仍会留在屏幕上,直到替换它的广告被附加为止。
    private var renderedCloudXAd: CLXAd?
    private var renderedAdMobAd: GADNativeAd?

    /// - Parameter makeAdMobAdView: 为已加载的 AdMob 广告构建并填充你自己的 GADNativeAdView,
    ///   方式与非仲裁场景下的 AdMob 原生广告集成完全相同。该闭包只会在 AdMob 出价
    ///   获胜时才会被调用。
    init(
        containerView: UIView,
        cloudXAdUnitId: String,
        adMobAdUnitId: String,
        makeAdMobAdView: @escaping (GADNativeAd) -> GADNativeAdView
    ) {
        self.containerView = containerView
        cloudXLoader = CloudXCore.shared.createNativeAdLoader(adUnitIdentifier: cloudXAdUnitId)
        self.adMobAdUnitId = adMobAdUnitId
        self.makeAdMobAdView = makeAdMobAdView
        super.init()
        cloudXLoader.nativeAdDelegate = self
        cloudXLoader.revenueDelegate = self
    }

    /// 启动第一轮:为尚未持有填充广告的每个平台发起加载。
    func start() {
        loadMissingAds()
    }

    /// 释放两个平台的广告并清空容器。
    func destroy() {
        detachRenderedAd()
        cloudXLoader.destroy()
        adMobNativeAd = nil
        adMobAdLoader = nil
    }

    /// 为既没有填充广告、也没有进行中请求的平台启动加载。
    ///
    /// 每次启动加载时都会清除该平台的 `LoadDone` 标志,因此只有当本轮发起的所有请求
    /// 都返回后才会进行仲裁。
    private func loadMissingAds() {
        // 不带广告视图调用 loadAd() 可以推迟渲染。此处绝不能使用 loadAd(into:):
        // 那样会在加载时就渲染 CloudX 广告,而此时仲裁器还没有选出获胜方。
        if cloudXAd == nil, !cloudXLoadInFlight {
            cloudXLoadInFlight = true
            cloudXLoadDone = false
            cloudXLoader.loadAd()
        }

        if adMobNativeAd == nil, !adMobLoadInFlight {
            adMobLoadInFlight = true
            adMobLoadDone = false
            loadAdMob()
        }
    }

    /// 仅在两个平台都已结束加载(无论填充还是失败)后运行,并渲染获胜方。
    private func maybeArbitrate() {
        guard cloudXLoadDone, adMobLoadDone else { return }

        var bids: [CLXArbiterBid] = []
        if let cloudXAd {
            bids.append(CLXArbiterBid.cloudX(ad: cloudXAd))
        }
        if adMobNativeAd != nil {
            bids.append(CLXArbiterBid.adMob(adUnitId: adMobAdUnitId))
        }

        guard !bids.isEmpty else {
            loadMissingAds()
            return
        }

        let configuration = CLXArbiterConfiguration.configuration(bids: bids, builderBlock: nil)
        CloudXCore.shared.arbiter(with: configuration) { [weak self] result in
            self?.renderWinner(result.platform)
        }
    }

    /// 只渲染获胜平台的素材;落选方保持未渲染、未附加的状态。
    private func renderWinner(_ platform: CLXArbiterPlatform) {
        // 渲染本身就会触发展示事件,因此绝不能把同一个广告渲染两次。每当落选候选重新加载时
        // maybeArbitrate() 都会再次运行,同一个平台在那一轮再次获胜是完全正常的结果——但那时
        // 已展示过的广告早已不再是候选,所以再次获胜的一定是新广告,会正常渲染。
        guard !isAlreadyRendered(platform) else { return }

        guard platform.name != CLXArbiterPlatform.none.name else {
            // 没有获胜方。保留当前屏幕上的广告——在这里移除会丢掉一个完全有效的广告——
            // 并且只丢弃落选的候选,好让下一轮有新的对象可以比较。
            discardUnrenderedCandidatesAndReload()
            return
        }

        detachRenderedAd()

        switch platform.name {
        case CLXArbiterPlatform.cloudX.name:
            renderCloudX()
        case CLXArbiterPlatform.adMob.name:
            renderAdMob()
        default:
            break
        }
    }

    /// 当该平台的当前候选广告就是已附加到容器中的那一个时返回 true。
    private func isAlreadyRendered(_ platform: CLXArbiterPlatform) -> Bool {
        switch platform.name {
        case CLXArbiterPlatform.cloudX.name:
            return cloudXAd != nil && cloudXAd === renderedCloudXAd
        case CLXArbiterPlatform.adMob.name:
            return adMobNativeAd != nil && adMobNativeAd === renderedAdMobAd
        default:
            return false
        }
    }

    /// 丢弃当前未被渲染的所有候选广告,然后开始新一轮周期。
    private func discardUnrenderedCandidatesAndReload() {
        if renderedPlatformName != CLXArbiterPlatform.cloudX.name {
            cloudXAd = nil
            cloudXLoadDone = false
        }

        if renderedPlatformName != CLXArbiterPlatform.adMob.name {
            adMobNativeAd = nil
            adMobLoadDone = false
        }

        loadMissingAds()
    }

    private func renderCloudX() {
        guard let cloudXAd, let containerView else { return }

        let adView = CLXNativeAdView()
        adView.bindViews(with: nativeAdViewBinder())

        guard cloudXLoader.renderNativeAdView(adView, with: cloudXAd) else {
            print("CloudX native ad could not be rendered; skipping this round")
            clearCloudXAndReload()
            return
        }

        adView.frame = containerView.bounds
        adView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
        containerView.addSubview(adView)
        renderedAdView = adView
        renderedPlatformName = CLXArbiterPlatform.cloudX.name
        renderedCloudXAd = cloudXAd
    }

    private func renderAdMob() {
        guard let adMobNativeAd, let containerView else { return }

        let adView = makeAdMobAdView(adMobNativeAd)
        adView.frame = containerView.bounds
        adView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
        containerView.addSubview(adView)
        renderedAdView = adView
        renderedPlatformName = CLXArbiterPlatform.adMob.name
        renderedAdMobAd = adMobNativeAd
    }

    private func loadAdMob() {
        let loader = GADAdLoader(
            adUnitID: adMobAdUnitId,
            rootViewController: nil,
            adTypes: [.native],
            options: nil
        )
        loader.delegate = self
        adMobAdLoader = loader
        loader.load(GADRequest())
    }

    /// 将 AdMob 的实际收益转发给 CloudX。这是集成中必需的环节,而不是可选的分析附加项:
    /// CloudX 会根据你回传的数据为未来的 AdMob 出价定价。
    ///
    /// revenuePrecision(from:) 就是上文"将 Google 付费事件回传给 CloudX(必需)"
    /// 一节中展示的映射方式。
    private func reportAdMobPaidEvent(_ ad: GADNativeAd?, adValue: GADAdValue) {
        let servedBy = ad?.responseInfo.loadedAdNetworkResponseInfo
        let data = CLXRevenueData.revenueData(
            platform: .adMob,
            revenue: adValue.value.doubleValue,
            adFormat: "native"
        ) { builder in
            builder.currencyCode = adValue.currencyCode
            builder.precision = revenuePrecision(from: adValue.precision)
            builder.networkName = servedBy?.adSourceName
            builder.adUnitId = self.adMobAdUnitId
            builder.thirdPartyAdPlacementId = servedBy?.adSourceInstanceName
        }

        // 当没有任何一方消费该事件时,reportRevenueData 返回 false。CloudX 会根据你在这里
        // 上报的数据为后续 AdMob 出价定价,因此静默的 false 值得记录下来。
        if !CloudXCore.shared.reportRevenueData(data) {
            print("AdMob native paid event was not accepted by the CloudX SDK")
        }
    }

    /// 只移除该控制器自己添加的广告视图,这样发布商放在容器中的其他内容——
    /// 占位图、加载指示器、文本标签——都能在本轮中保留下来。
    ///
    /// 这里也是唯一释放已渲染广告的地方。只要广告还在屏幕上,它就会一直存活;直到替换它的
    /// 广告被附加,或者控制器被销毁时才会被释放。
    private func detachRenderedAd() {
        renderedAdView?.removeFromSuperview()
        renderedAdView = nil
        renderedPlatformName = nil

        if let renderedCloudXAd { cloudXLoader.destroyAd(renderedCloudXAd) }
        renderedCloudXAd = nil
        renderedAdMobAd = nil
    }

    /// 丢弃 CloudX 候选广告,如果它当前正在屏幕上展示,则先将其移除。
    private func clearCloudXAndReload() {
        if renderedPlatformName == CLXArbiterPlatform.cloudX.name {
            detachRenderedAd()
        }

        cloudXAd = nil
        cloudXLoadDone = false
        cloudXLoadInFlight = false
        loadMissingAds()
    }

    /// 在获胜方的展示事件触发后开始下一轮周期。
    ///
    /// 已渲染的视图会留在屏幕上,其背后的广告也继续存活。原生广告位没有任何可回退的内容,
    /// 因此在广告刚计费时就把它移除,会让广告位在下一轮完成之前一直空白。这里只丢弃候选状态,
    /// 所以已消耗的广告不会再次参与出价或被重新渲染;广告本身会在 detachRenderedAd() 中、
    /// 即替换它的广告出现时才被释放。这与横幅广告控制器遵循的规则相同。
    private func onWinnerImpression(_ platformName: String) {
        switch platformName {
        case CLXArbiterPlatform.cloudX.name:
            cloudXAd = nil
            cloudXLoadDone = false
            cloudXLoadInFlight = false
        case CLXArbiterPlatform.adMob.name:
            adMobNativeAd = nil
            adMobLoadDone = false
            adMobLoadInFlight = false
        default:
            return
        }

        loadMissingAds()
    }

    private func nativeAdViewBinder() -> CLXNativeAdViewBinder {
        CLXNativeAdViewBinder { builder in
            builder.titleLabelTag = CLXNativeAdViewTagTitleLabel
            builder.bodyLabelTag = CLXNativeAdViewTagBodyLabel
            builder.iconImageViewTag = CLXNativeAdViewTagIconImageView
            builder.callToActionButtonTag = CLXNativeAdViewTagCallToActionButton
            builder.mediaContentViewTag = CLXNativeAdViewTagMediaViewContainer
            builder.optionsContentViewTag = CLXNativeAdViewTagOptionsContentView
            builder.advertiserLabelTag = CLXNativeAdViewTagAdvertiserLabel
        }
    }
}

extension ArbiterNativeController: CLXNativeAdDelegate {
    // 这里的 nativeAdView 为 nil,因为调用 loadAd() 时没有传入视图。该广告只有在
    // 赢得仲裁后才会被渲染。
    func didLoadNativeAd(_ nativeAdView: CLXNativeAdView?, for ad: CLXAd) {
        cloudXAd = ad
        cloudXLoadInFlight = false
        cloudXLoadDone = true
        maybeArbitrate()
    }

    func didFailToLoadNativeAd(forAdUnitIdentifier adUnitId: String, error: CLXError) {
        print("CloudX native ad failed to load: \(error.localizedDescription)")
        cloudXAd = nil
        cloudXLoadInFlight = false
        cloudXLoadDone = true
        maybeArbitrate()
    }

    func didClickNativeAd(_ ad: CLXAd) {}

    func didExpireNativeAd(_ ad: CLXAd) {
        // detachRenderedAd() 会释放已渲染的广告,因此把它交给那里处理,避免销毁两次。
        if ad !== renderedCloudXAd { cloudXLoader.destroyAd(ad) }
        clearCloudXAndReload()
    }

    func didCloseNativeAd(_ ad: CLXAd) {
        if ad !== renderedCloudXAd { cloudXLoader.destroyAd(ad) }
        clearCloudXAndReload()
    }
}

extension ArbiterNativeController: GADNativeAdLoaderDelegate {
    func adLoader(_ adLoader: GADAdLoader, didReceive nativeAd: GADNativeAd) {
        nativeAd.paidEventHandler = { [weak self, weak nativeAd] adValue in
            self?.reportAdMobPaidEvent(nativeAd, adValue: adValue)
        }
        nativeAd.delegate = self
        adMobNativeAd = nativeAd
        adMobLoadInFlight = false
        adMobLoadDone = true
        maybeArbitrate()
    }

    func adLoader(_ adLoader: GADAdLoader, didFailToReceiveAdWithError error: Error) {
        print("AdMob native ad failed to load: \(error.localizedDescription)")
        adMobNativeAd = nil
        adMobLoadInFlight = false
        adMobLoadDone = true
        maybeArbitrate()
    }
}

extension ArbiterNativeController: CLXAdRevenueDelegate {
    /// CloudX 的展示信号。已渲染的广告作为候选即被消耗,因此在这里请求下一个——
    /// 与横幅广告控制器遵循的规则相同。
    func didPayRevenue(for ad: CLXAd) {
        guard renderedPlatformName == CLXArbiterPlatform.cloudX.name else { return }
        onWinnerImpression(CLXArbiterPlatform.cloudX.name)
    }
}

extension ArbiterNativeController: GADNativeAdDelegate {
    /// 获胜的 AdMob 广告在展示事件触发的瞬间即被消耗,因此在这里请求下一个。
    /// 如果没有这一步,该控制器会在整个会话期间持续用一个已消耗的广告参与出价。
    func nativeAdDidRecordImpression(_ nativeAd: GADNativeAd) {
        guard renderedPlatformName == CLXArbiterPlatform.adMob.name else { return }
        onWinnerImpression(CLXArbiterPlatform.adMob.name)
    }
}

didLoadNativeAd(_:for:) 收到的 nativeAdView 为 nil,因为加载时没有传入视图——这个 nil 就是提示你渲染动作仍需自己触发的信号。请在 renderCloudX() 中构建 CLXNativeAdView,并且只有在仲裁器选定 CloudX 为获胜方之后,才将其传给 renderNativeAdView(_:with:)。当广告已经无法渲染时,该方法会返回 false,这里将其视为本轮落选处理,而不是直接忽略。

落选平台会保留已填充的广告,留待下一轮使用,因此只有被消耗或已过期的平台才会重新发起请求。已渲染的广告即被消耗,因此每个平台都会在展示事件触发时立即把它从候选中丢弃并请求新的广告——CloudX 通过 didPayRevenue(for:),AdMob 通过 nativeAdDidRecordImpression(_:)。

两个回调都不会移除视图。原生广告位没有任何可回退的内容,因此在广告刚计费时就把它移除,会让广告位在下一轮完成之前一直空白。已渲染的广告会继续留在屏幕上,并在 detachRenderedAd() 中释放——也就是替换它的广告被附加时,或控制器被销毁时——这与横幅广告控制器遵循的规则相同。唯一会提前移除的情况,是广告在屏幕上展示期间过期或被关闭,这也是需要跟踪 renderedPlatformName 的原因。

当仲裁器选出的平台就是当前已在屏幕上的平台时,renderWinner(_:) 会直接返回,因为渲染本身就会触发展示事件,而那一轮并没有改变获胜方。

如果需要按计时器刷新原生广告位,可以复用横幅广告和 MREC 仲裁中的同一套刷新周期:每隔 20-30 秒重新仲裁一次,并将渲染视图替换为新获胜方的视图。对于 Reels 式信息流,请为每个广告位创建一个独立的控制器——参见原生广告。

自定义出价输入

当需要让 Trusted Arbiter 比较 CloudX 与没有专用出价辅助方法的第三方平台时,可以使用 CLXArbiterBid.custom(...)。

CLXArbiterBid *customBid =
    [CLXArbiterBid customBidWithPlatformName:@"my_mediation_platform"
                                 networkName:@"winning_demand_source"
                     revenuePerImpressionUSD:0.00125
                                   precision:CLXArbiterPrecision.exact
                                      extras:@{@"ad_unit": @"third-party-ad-unit-id"}];

CLXArbiterConfiguration *configuration =
    [CLXArbiterConfiguration configurationWithBids:@[
        [CLXArbiterBid cloudXBidWithAd:cloudXAd],
        customBid
    ]];

当自定义出价获胜时,result.platform 为 CLXArbiterPlatform.custom,result.platformName 包含创建出价时传入的 platformName。revenuePerImpressionUSD 应传入单次展示的美元收益,而不是 CPM。使用 CLXArbiterPrecision.exact、estimated、publisherDefined 或 undefined 描述该收益值的精度。