Trusted Arbiter

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

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

为什么使用 Trusted Arbiter?

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

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

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

使用可用的出价值

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

示例应用

CloudX Android 示例应用以插屏广告运行本页描述的流程。CloudX 和 AdMob 并行加载,加载成功的广告作为出价参与,由 CloudX.arbiter() 选出胜出方,并从保存的结果中展示,因此展示路径不会发起网络请求。

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

何时运行仲裁器

请在候选广告加载完成时运行仲裁器——绝不要放在展示路径上。仲裁器调用是一次网络往返,因此当用户点击触发广告展示的按钮时,绝不能让他等待这次往返。提前于广告位完成仲裁,等到到达广告位时决策就已经做好了。

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

  1. 并行加载所有候选广告。
  2. 当每个候选广告都已确定结果(加载成功或失败)后,运行仲裁器并存储结果。
  3. 到达广告位时,立即展示已存储的获胜平台。此处不再调用仲裁器。
  4. 广告展示并关闭、展示失败或某个候选广告过期后,重新开始整个周期。
private var nextWinner: CloudXArbiterResult? = null

// 在候选广告加载完成后立刻运行,早于广告位。
private fun prepareWinner() {
    val configuration = CloudXArbiterConfiguration.builder(bids).build()
    CloudX.arbiter(configuration, object : CloudXArbiterListener {
        override fun onCompleted(result: CloudXArbiterResult) {
            nextWinner = result
        }
    })
}

// 在广告位处运行。此处没有网络调用。
fun showInterstitial(activity: Activity) {
    when (nextWinner?.platform) {
        CloudXArbiterPlatform.CLOUDX -> cloudXInterstitial.show(activity)
        CloudXArbiterPlatform.LEVELPLAY -> levelPlayInterstitial.showAd(activity)
        else -> { } // 未准备好获胜平台;不展示广告
    }
    nextWinner = null
}

如果在存储获胜平台之前就到达了广告位,可以选择不展示广告继续应用流程,或者展示唯一那个已加载成功的候选广告。展示单个候选广告是一种可接受的降级路径,但绝不能作为主路径——经过仲裁的获胜广告收益高于未经仲裁的填充。

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

视图格式没有由用户触发的展示动作,因此没有任何操作在等待这次网络往返。此时直接在完成回调中添加或渲染获胜平台的视图是正确做法:仲裁器完成本身就是展示的触发点。只有获胜方的视图或素材才可以被添加——添加与刷新规则参见横幅与 MREC 仲裁。

支持的广告格式

Trusted Arbiter 与广告格式无关:仲裁负载会附加到所有格式的 CloudX 广告上,任意已加载的 CloudX 广告都可以与传入的第三方出价进行比较。

  • 插屏广告、激励视频广告和应用开屏广告(全屏格式)会在到达广告位之前提前准备好获胜平台,参见何时运行仲裁器,并遵循本页后文展示的分步演练和控制器模式。完整示例:插屏广告示例和开屏广告示例。激励视频广告完全沿用插屏广告的模式,只是广告类型不同。
  • 横幅广告、MREC 和原生广告(视图格式)先仲裁,再渲染。它们需要横幅与 MREC 仲裁一节中介绍的额外处理,因为落选出价对应的视图绝不能被添加到视图层级中。完整示例:横幅广告示例和原生广告示例。

基础 API

从已加载的广告创建出价候选项,然后传给 CloudX.arbiter()。

// cloudXAd 是 CloudX onAdLoaded 回调中的 CloudXAd 对象。
// levelPlayAdInfo 是 Unity LevelPlay 广告信息对象。
// pobBid 是 PubMatic/OpenWrap 出价对象。
val bids = listOf(
    CloudXArbiterBid.cloudX(cloudXAd),
    CloudXArbiterBid.levelPlay(
        networkName = levelPlayAdInfo.adNetwork,
        revenue = levelPlayAdInfo.revenue,
        precision = levelPlayAdInfo.precision,
    ),
    CloudXArbiterBid.pubmatic(
        price = pobBid.price,
        partnerName = pobBid.partnerName,
    )
)

val configuration = CloudXArbiterConfiguration.builder(bids).build()

CloudX.arbiter(configuration, object : CloudXArbiterListener {
    override fun onCompleted(result: CloudXArbiterResult) {
        Log.d("CloudX", "选中的平台: ${result.platform.name}")
    }
})

CloudXArbiterBid.cloudX() 接收 CloudX 加载回调中的 CloudXAd 对象。levelPlay() 接收 Unity LevelPlay 广告信息值。pubmatic() 接收 PubMatic OpenWrap 出价价格和可选的合作伙伴名称。出价工厂方法中的 partnerName 和 extras 映射均为可选参数。onCompleted() 在主线程上回调。对于全屏格式,请在其中保存结果并在广告位到达时展示——参见何时运行仲裁器;视图格式则可以直接在其中渲染获胜方。

result.platform 在选中平台时为 CloudXArbiterPlatform.CLOUDX、LEVELPLAY 或 PUBMATIC;当无法选出获胜平台时,则为 CloudXArbiterPlatform.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 集成中的必需环节,而不是可选的分析增强项。在展示通过仲裁胜出的 AdMob 或 Ad Manager 广告之后,必须把 Google 为该广告上报的展示级收入交给 CloudX SDK。如果缺少这一步,CloudX 就无法得知 Google 需求方实际支付的价格,后续仲裁的估算质量也会随之下降。

Google 只通过每个广告的 OnPaidEventListener 下发这类收入数据——不存在全局展示事件总线——因此请为每个参与仲裁的 Google 广告设置该监听器,并通过 CloudX.reportRevenueData() 转发每个事件。AdValue.valueMicros 以所报告货币的微单位表示,需要除以 1_000_000.0 才能得到单次展示的收益。AdMob 广告请传入 CloudXRevenuePlatform.ADMOB,Ad Manager 广告请传入 CloudXRevenuePlatform.GAM。

private fun Int.toCloudXRevenuePrecision(): CloudXRevenuePrecision = when (this) {
    AdValue.PrecisionType.PRECISE -> CloudXRevenuePrecision.EXACT
    AdValue.PrecisionType.ESTIMATED -> CloudXRevenuePrecision.ESTIMATED
    AdValue.PrecisionType.PUBLISHER_PROVIDED -> CloudXRevenuePrecision.PUBLISHER_DEFINED
    else -> CloudXRevenuePrecision.UNDEFINED
}

private fun reportGooglePaidEvent(
    platform: CloudXRevenuePlatform,
    adValue: AdValue,
    adFormat: String,
    adUnitId: String,
    responseInfo: ResponseInfo?,
): Boolean {
    val servedBy = responseInfo?.loadedAdapterResponseInfo

    return CloudX.reportRevenueData(
        CloudXRevenueData.builder(
            platform = platform,
            revenue = adValue.valueMicros / 1_000_000.0,
            adFormat = adFormat,
        )
            .currencyCode(adValue.currencyCode)
            .precision(adValue.precisionType.toCloudXRevenuePrecision())
            .networkName(servedBy?.adSourceName)
            .adUnitId(adUnitId)
            .thirdPartyAdPlacementId(servedBy?.adSourceInstanceName)
            .build(),
    )
}

// 在展示参与仲裁的 Google 广告之前,为它设置监听器。
adMobInterstitial.setOnPaidEventListener { adValue ->
    reportGooglePaidEvent(
        platform = CloudXRevenuePlatform.ADMOB,
        adValue = adValue,
        adFormat = "interstitial",
        adUnitId = adMobAdUnitId,
        responseInfo = adMobInterstitial.responseInfo,
    )
}

// Ad Manager 广告的上报方式相同,只需改用 GAM 平台。
adManagerInterstitial.setOnPaidEventListener { adValue ->
    reportGooglePaidEvent(
        platform = CloudXRevenuePlatform.GAM,
        adValue = adValue,
        adFormat = "interstitial",
        adUnitId = adManagerAdUnitId,
        responseInfo = adManagerInterstitial.responseInfo,
    )
}

当事件被接受进入 CloudX 收入管道时,reportRevenueData() 返回 true;如果 SDK 尚未初始化或服务端收入追踪已关闭,则返回 false。对于横幅广告和 MREC,只需为 AdView 设置一次监听器——Google 会在同一个视图上重复触发付费事件——并将格式传为 "banner" 或 "mrec"。完整的字段说明以及其他受支持的平台参见发布商上报收入数据。

用你已加载广告的广告单元 ID 创建出价:

// networkName 为可选参数;如果你知道获胜的广告来源,请传入它,
// 例如 responseInfo?.loadedAdapterResponseInfo?.adSourceName。
val adMobBid = CloudXArbiterBid.adMob(
    adUnitId = adMobAdUnitId,
    networkName = adMobNetworkName ?: "admob",
    manualRevenuePerImpressionUSD = null,
    extras = emptyMap(),
)

// Ad Manager 广告单元 ID 的格式为 /NNNNNNN/placement/name。
val adManagerBid = CloudXArbiterBid.gam(
    adUnitId = "/21775744923/example/interstitial",
    networkName = "gam",
    manualRevenuePerImpressionUSD = null,
    extras = emptyMap(),
)

val configuration = CloudXArbiterConfiguration.builder(
    listOf(CloudXArbiterBid.cloudX(cloudXAd), adMobBid, adManagerBid)
).build()

// 在候选广告加载完成后运行,早于广告位。存储获胜平台。
CloudX.arbiter(configuration, object : CloudXArbiterListener {
    override fun onCompleted(result: CloudXArbiterResult) {
        nextWinner = result
    }
})

// 在广告位处运行。此处不调用仲裁器。
fun showInterstitial(activity: Activity) {
    when (nextWinner?.platform) {
        CloudXArbiterPlatform.CLOUDX -> cloudXInterstitial.show(activity)
        CloudXArbiterPlatform.ADMOB -> adMobInterstitial.show(activity)
        CloudXArbiterPlatform.GAM -> adManagerInterstitial.show(activity)
        else -> { } // 未准备好获胜平台;不展示广告
    }
    nextWinner = null
}

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

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

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

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

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

val adMobBid = CloudXArbiterBid.adMob(
    adUnitId = adMobAdUnitId,
    networkName = adMobNetworkName ?: "admob",
    // valueMicros 以微单位(micros)表示:1,000,000 微单位等于 1 个货币单位。
    // 请勿再额外除以 1,000——该数值本身已是单次展示的收益,而不是 CPM。
    manualRevenuePerImpressionUSD = preBidAdValue.valueMicros / 1_000_000.0,
    extras = emptyMap(),
)

该数值的处理方式:

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

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

  • 以微单位(micros)表示的数值 必须除以 1,000,000。AdMob 在 Android 上以微单位报告广告价值,1,000,000 微单位等于 1 个货币单位,因此 AdValue.getValueMicros() 为 5000 时对应单次展示 0.005。请勿把微单位数值误当作 eCPM 而再额外除以 1,000。
  • 非美元金额 必须先换算为美元。

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

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

加载两个候选项

创建 CloudX 和 LevelPlay 插屏广告,绑定监听器,并为每个平台启动加载。

val cloudXInterstitial = CloudX.createInterstitial(context, "YOUR_CLOUDX_AD_UNIT_ID")
cloudXInterstitial.listener = cloudXListener
cloudXInterstitial.load()

val levelPlayInterstitial = LevelPlayInterstitialAd("YOUR_LEVELPLAY_AD_UNIT_ID")
levelPlayInterstitial.setListener(levelPlayListener)
levelPlayInterstitial.loadAd()

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

LevelPlay 在其 onAdLoaded 回调中提供 LevelPlayAdInfo;CloudX 在 onAdLoaded 中提供 CloudXAd。请保存两者——下一步会从中读取仲裁输入。

private var cloudXAd: CloudXAd? = null
private var levelPlayInfo: LevelPlayAdInfo? = null

// CloudXInterstitialListener
override fun onAdLoaded(cloudXAd: CloudXAd) {
    this.cloudXAd = cloudXAd
}

// LevelPlayInterstitialAdListener
override fun onAdLoaded(levelPlayAdInfo: LevelPlayAdInfo) {
    levelPlayInfo = levelPlayAdInfo
}

将值映射为出价

从 LevelPlayAdInfo 读取 LevelPlay 字段,并传给 CloudXArbiterBid.levelPlay()。CloudX 出价直接接收 CloudXAd。listOfNotNull 只会提交实际加载成功的平台。

LevelPlayAdInfo 字段类型CloudXArbiterBid.levelPlay 参数
adNetworkStringnetworkName
revenueDoublerevenue
precisionStringprecision
val bids = listOfNotNull(
    cloudXAd?.let { CloudXArbiterBid.cloudX(it) },
    levelPlayInfo?.let { info ->
        CloudXArbiterBid.levelPlay(
            networkName = info.adNetwork,   // LevelPlayAdInfo.adNetwork
            revenue = info.revenue,         // LevelPlayAdInfo.revenue
            precision = info.precision,     // LevelPlayAdInfo.precision
        )
    },
)

运行仲裁器并存储获胜平台

当两个候选广告都已确定结果后,将出价封装进 CloudXArbiterConfiguration,连同监听器传给 CloudX.arbiter()。存储结果——此时先不要展示任何广告。onCompleted() 在主线程上回调。

private var nextWinner: CloudXArbiterResult? = null

private fun prepareWinner() {
    val configuration = CloudXArbiterConfiguration.builder(bids).build()
    CloudX.arbiter(configuration, object : CloudXArbiterListener {
        override fun onCompleted(result: CloudXArbiterResult) {
            nextWinner = result
        }
    })
}

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

到达广告位时,根据已存储的 platform 进行分支,展示该平台的广告。此路径上没有仲裁器调用。已存储的结果为 CloudXArbiterPlatform.NONE,或根本没有存储任何结果,都表示没有可展示的获胜平台——此时不展示广告,继续应用流程。

fun showAtPlacement(activity: Activity) {
    when (nextWinner?.platform) {
        CloudXArbiterPlatform.CLOUDX -> cloudXInterstitial.show(activity)
        CloudXArbiterPlatform.LEVELPLAY -> levelPlayInterstitial.showAd(activity)
        else -> { } // 未准备好获胜平台;不展示广告
    }
    nextWinner = null
}

下方的 ArbiterInterstitialController 将上述步骤封装为一个可复用的组件。

插屏广告示例

这个控制器是全屏格式”提前准备”规则的参考实现。它会在两个平台之间仲裁——CloudX 和 Unity LevelPlay——并在到达广告位之前先准备好获胜平台:

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

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

ArbiterInterstitialController.kt
/**
 * 提前准备好 Trusted Arbiter 的获胜平台,以便在到达广告位时立即展示插屏广告。
 *
 * 并行加载 [cloudXInterstitial] 和 [levelPlayInterstitial],等待两者都加载完成或加载失败后,
 * 将已加载的候选项提交给 [CloudX.arbiter],并将选中的 [CloudXArbiterPlatform] 缓存到
 * [nextWinner] 中。
 */
class ArbiterInterstitialController(
    private val cloudXInterstitial: CloudXInterstitialAd,
    private val levelPlayInterstitial: LevelPlayInterstitialAd,
) {
    /** 在获胜平台准备就绪后接收仲裁结果。 */
    interface Listener {
        /** 当仲裁器为下一次展示选定 [platform] 时调用。 */
        fun onWinnerPrepared(platform: CloudXArbiterPlatform)
    }

    /** 设置该属性以监听 [Listener.onWinnerPrepared] 回调。 */
    var listener: Listener? = null
    private var cloudXAd: CloudXAd? = null
    private var cloudXLoadDone = false
    private var levelPlayAdInfo: LevelPlayAdInfo? = null
    private var levelPlayLoadDone = false
    private var nextWinner: CloudXArbiterPlatform? = null

    init {
        cloudXInterstitial.listener = createCloudXListener()
        levelPlayInterstitial.setListener(createLevelPlayListener())
    }

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

    /**
     * 在 [activity] 上展示已准备好的获胜广告,仅在实际调用展示方法时返回 true。
     *
     * 当没有就绪的获胜平台,或缓存的广告已不再可用时返回 false,此时会启动新的加载周期。
     */
    fun showAtPlacement(activity: Activity): Boolean {
        return when (nextWinner) {
            CloudXArbiterPlatform.CLOUDX -> showCloudX(activity)
            CloudXArbiterPlatform.LEVELPLAY -> showLevelPlay(activity)
            else -> false
        }
    }

    /**
     * 在两个平台都完成加载后运行仲裁器,然后缓存获胜平台。
     *
     * 在两个加载都完成之前会提前返回。如果两个平台都未加载成功,则重新启动加载周期;
     * 否则将已加载的候选项提交给 [CloudX.arbiter]。
     */
    private fun maybePrepareWinner() {
        if (!cloudXLoadDone || !levelPlayLoadDone) return

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

        val bids = listOfNotNull(
            cloudXAd?.let { CloudXArbiterBid.cloudX(it) },
            levelPlayAdInfo?.let { adInfo ->
                CloudXArbiterBid.levelPlay(
                    networkName = adInfo.adNetwork,
                    revenue = adInfo.revenue,
                    precision = adInfo.precision,
                )
            }
        )

        CloudX.arbiter(
            configuration = CloudXArbiterConfiguration.builder(bids).build(),
            listener = object : CloudXArbiterListener {
                override fun onCompleted(result: CloudXArbiterResult) {
                    nextWinner = result.platform
                    listener?.onWinnerPrepared(result.platform)
                }
            }
        )
    }

    private fun showCloudX(activity: Activity): Boolean {
        if (cloudXInterstitial.isAdReady) {
            cloudXInterstitial.show(activity)
            return true
        }

        clearCloudXAndLoadMissingAds()
        return false
    }

    private fun showLevelPlay(activity: Activity): Boolean {
        if (levelPlayInterstitial.isAdReady) {
            levelPlayInterstitial.showAd(activity)
            return true
        }

        clearLevelPlayAndLoadMissingAds()
        return false
    }

    private fun clearCloudXAndLoadMissingAds() {
        cloudXAd = null
        cloudXLoadDone = false
        nextWinner = null
        loadMissingAds()
    }

    private fun clearLevelPlayAndLoadMissingAds() {
        levelPlayAdInfo = null
        levelPlayLoadDone = false
        nextWinner = null
        loadMissingAds()
    }

    private fun createCloudXListener() = object : CloudXInterstitialListener {
        override fun onAdLoaded(cloudXAd: CloudXAd) {
            this@ArbiterInterstitialController.cloudXAd = cloudXAd
            cloudXLoadDone = true
            maybePrepareWinner()
        }

        override fun onAdLoadFailed(adUnitId: String, cloudXError: CloudXError) {
            cloudXAd = null
            cloudXLoadDone = true
            maybePrepareWinner()
        }

        override fun onAdDisplayed(cloudXAd: CloudXAd) = Unit

        override fun onAdDisplayFailed(cloudXAd: CloudXAd, cloudXError: CloudXError) {
            clearCloudXAndLoadMissingAds()
        }

        override fun onAdHidden(cloudXAd: CloudXAd) {
            clearCloudXAndLoadMissingAds()
        }

        override fun onAdClicked(cloudXAd: CloudXAd) = Unit
    }

    private fun createLevelPlayListener() = object : LevelPlayInterstitialAdListener {
        override fun onAdLoaded(levelPlayAdInfo: LevelPlayAdInfo) {
            this@ArbiterInterstitialController.levelPlayAdInfo = levelPlayAdInfo
            levelPlayLoadDone = true
            maybePrepareWinner()
        }

        override fun onAdLoadFailed(levelPlayAdError: LevelPlayAdError) {
            levelPlayAdInfo = null
            levelPlayLoadDone = true
            maybePrepareWinner()
        }

        override fun onAdInfoChanged(levelPlayAdInfo: LevelPlayAdInfo) {
            this@ArbiterInterstitialController.levelPlayAdInfo = levelPlayAdInfo
        }

        override fun onAdDisplayed(levelPlayAdInfo: LevelPlayAdInfo) = Unit

        override fun onAdDisplayFailed(
            levelPlayAdError: LevelPlayAdError,
            levelPlayAdInfo: LevelPlayAdInfo
        ) {
            clearLevelPlayAndLoadMissingAds()
        }

        override fun onAdClosed(levelPlayAdInfo: LevelPlayAdInfo) {
            clearLevelPlayAndLoadMissingAds()
        }

        override fun onAdClicked(levelPlayAdInfo: LevelPlayAdInfo) = Unit
    }
}

showAtPlacement() 只有在实际调用广告展示方法时才会返回 true。onAdInfoChanged() 会在 LevelPlay 广告仍然处于已加载状态时,保持缓存的 LevelPlay 候选项为最新。

对于 PubMatic OpenWrap,使用 CloudXArbiterBid.pubmatic(price, partnerName) 创建第三方出价。如果仲裁服务不可用,SDK 会在传入的受支持出价中回退选择可比较美元出价最高的平台。

开屏广告示例

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

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

  1. 在启动时加载两个平台,并在每次展示后重新加载。
  2. 等两个平台都已确定结果后运行仲裁器,并存储获胜方。
  3. 在下一次前台切换时,展示已存储的获胜方,并开始下一个周期。
  4. 如果没有存储的获胜方,则让用户直接返回应用,不展示广告,并开始新的加载。
ArbiterAppOpenController.kt
/**
 * 在 CloudX 应用开屏广告与 AdMob 应用开屏广告之间进行仲裁,并在下一次前台切换时展示获胜方。
 *
 * 应用开屏广告没有用户触发的展示动作,因此当用户返回应用时,获胜方必须已经选定。
 * [loadMissingAds] 会启动两个平台的加载,仲裁器会在两者都已确定结果后立即运行,
 * 而 [showOnForeground] 只会展示此前已经存储好的结果。
 */
class ArbiterAppOpenController(
    private val context: Context,
    cloudXAdUnitId: String,
    private val adMobAdUnitId: String,
) {
    /** 在获胜平台准备就绪后接收仲裁结果。 */
    interface Listener {
        /** 当仲裁器为下一次前台展示选定 [platform] 时调用。 */
        fun onWinnerPrepared(platform: CloudXArbiterPlatform)
    }

    /** 设置该属性以监听 [Listener.onWinnerPrepared] 回调。 */
    var listener: Listener? = null
    private val cloudXAppOpen: CloudXAppOpenAd = CloudX.createAppOpen(context, cloudXAdUnitId)
    private var cloudXAd: CloudXAd? = null
    private var cloudXLoadDone = false
    private var adMobAppOpen: AppOpenAd? = null
    /** 正在展示或最近展示过的广告。保留它,好让迟到的付费事件仍能找到对应的广告。 */
    private var presentedAdMobAd: AppOpenAd? = null
    private var adMobLoadDone = false
    private var cloudXLoadInFlight = false
    private var adMobLoadInFlight = false
    private var nextWinner: CloudXArbiterPlatform? = null
    private var isShowingAd = false

    init {
        cloudXAppOpen.listener = createCloudXListener()
    }

    /** 为当前没有缓存广告的每个平台启动加载。 */
    fun loadMissingAds() {
        if (cloudXAd == null && !cloudXLoadInFlight) {
            cloudXLoadInFlight = true
            cloudXLoadDone = false
            cloudXAppOpen.load()
        }

        if (adMobAppOpen == null && !adMobLoadInFlight) {
            adMobLoadInFlight = true
            adMobLoadDone = false
            loadAdMob()
        }
    }

    /**
     * 在 [activity] 上展示已准备好的获胜广告,仅在实际调用展示方法时返回 true。
     *
     * 请从前台观察者中调用该方法。它绝不会拖慢应用返回前台的流程:如果已有广告在屏幕上展示,
     * 或者还没有准备好获胜方,它会返回 false,并启动下一个加载周期。
     */
    fun showOnForeground(activity: Activity): Boolean {
        if (isShowingAd) return false

        return when (nextWinner) {
            CloudXArbiterPlatform.CLOUDX -> showCloudX(activity)
            CloudXArbiterPlatform.ADMOB -> showAdMob(activity)
            CloudXArbiterPlatform.NONE -> {
                // 仲裁器判定没有获胜方,因此两个缓存的广告都不可用,需要丢弃:
                // loadMissingAds() 只会为空槽位发起请求,如果不丢弃,这两个候选会一直留在
                // 原处,后续任何一轮都无法再运行。
                discardCandidatesAndReload()
                false
            }
            // nextWinner 为 null —— 仲裁周期仍在进行中,不要干预。
            else -> false
        }
    }

    /** 释放两个平台的广告。请在所属组件销毁时调用该方法。 */
    fun destroy() {
        cloudXAppOpen.destroy()
        adMobAppOpen = null
        presentedAdMobAd = null
    }

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

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

        val bids = listOfNotNull(
            cloudXAd?.let { CloudXArbiterBid.cloudX(it) },
            adMobAppOpen?.let { CloudXArbiterBid.adMob(adUnitId = adMobAdUnitId) },
        )

        CloudX.arbiter(
            configuration = CloudXArbiterConfiguration.builder(bids).build(),
            listener = object : CloudXArbiterListener {
                override fun onCompleted(result: CloudXArbiterResult) {
                    nextWinner = result.platform
                    listener?.onWinnerPrepared(result.platform)
                }
            }
        )
    }

    private fun showCloudX(activity: Activity): Boolean {
        if (cloudXAppOpen.isAdReady) {
            isShowingAd = true
            cloudXAppOpen.show(activity, "app_foreground")
            return true
        }

        clearCloudXAndLoadMissingAds()
        return false
    }

    private fun showAdMob(activity: Activity): Boolean {
        val ad = adMobAppOpen
        if (ad == null) {
            clearAdMobAndLoadMissingAds()
            return false
        }

        ad.fullScreenContentCallback = object : FullScreenContentCallback() {
            override fun onAdDismissedFullScreenContent() {
                clearAdMobAndLoadMissingAds()
            }

            override fun onAdFailedToShowFullScreenContent(adError: AdError) {
                Log.e("CloudX", "AdMob App Open failed to show: ${adError.message}")
                clearAdMobAndLoadMissingAds()
            }
        }

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

    private fun loadAdMob() {
        AppOpenAd.load(
            context,
            adMobAdUnitId,
            AdRequest.Builder().build(),
            object : AppOpenAd.AppOpenAdLoadCallback() {
                override fun onAdLoaded(ad: AppOpenAd) {
                    ad.setOnPaidEventListener { adValue -> reportAdMobPaidEvent(ad, adValue) }
                    adMobAppOpen = ad
                    adMobLoadInFlight = false
                    adMobLoadDone = true
                    maybePrepareWinner()
                }

                override fun onAdFailedToLoad(error: LoadAdError) {
                    Log.w("CloudX", "AdMob App Open failed to load: ${error.message}")
                    adMobAppOpen = null
                    adMobLoadInFlight = false
                    adMobLoadDone = true
                    maybePrepareWinner()
                }
            },
        )
    }

    /**
     * 将 AdMob 的实际收益转发给 CloudX。这是集成中必需的环节,而不是可选的分析附加项:
     * CloudX 会根据你回传的数据为未来的 AdMob 出价定价。
     *
     * toCloudXRevenuePrecision() 就是上文"将 Google 付费事件回传给 CloudX(必需)"
     * 一节中展示的映射方式。
     */
    private fun reportAdMobPaidEvent(ad: AppOpenAd, adValue: AdValue) {
        val servedBy = ad.responseInfo.loadedAdapterResponseInfo

        val accepted = CloudX.reportRevenueData(
            CloudXRevenueData.builder(
                platform = CloudXRevenuePlatform.ADMOB,
                revenue = adValue.valueMicros / 1_000_000.0,
                adFormat = "app_open",
            )
                .currencyCode(adValue.currencyCode)
                .precision(adValue.precisionType.toCloudXRevenuePrecision())
                .networkName(servedBy?.adSourceName)
                .adUnitId(adMobAdUnitId)
                .thirdPartyAdPlacementId(servedBy?.adSourceInstanceName)
                .build(),
        )

        if (!accepted) {
            Log.w("CloudX", "AdMob App Open paid event was not accepted by the CloudX SDK")
        }
    }

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

    private fun clearCloudXAndLoadMissingAds() {
        cloudXAd = null
        cloudXLoadDone = false
        nextWinner = null
        isShowingAd = false
        loadMissingAds()
    }

    private fun clearAdMobAndLoadMissingAds() {
        adMobAppOpen = null
        adMobLoadDone = false
        nextWinner = null
        isShowingAd = false
        loadMissingAds()
    }

    private fun createCloudXListener() = object : CloudXAppOpenListener {
        override fun onAdLoaded(cloudXAd: CloudXAd) {
            this@ArbiterAppOpenController.cloudXAd = cloudXAd
            cloudXLoadInFlight = false
            cloudXLoadDone = true
            maybePrepareWinner()
        }

        override fun onAdLoadFailed(adUnitId: String, cloudXError: CloudXError) {
            Log.w("CloudX", "CloudX App Open failed to load: ${cloudXError.message}")
            cloudXAd = null
            cloudXLoadInFlight = false
            cloudXLoadDone = true
            maybePrepareWinner()
        }

        override fun onAdDisplayed(cloudXAd: CloudXAd) = Unit

        override fun onAdDisplayFailed(cloudXAd: CloudXAd, cloudXError: CloudXError) {
            Log.e("CloudX", "CloudX App Open failed to display: ${cloudXError.message}")
            clearCloudXAndLoadMissingAds()
        }

        override fun onAdHidden(cloudXAd: CloudXAd) {
            clearCloudXAndLoadMissingAds()
        }

        override fun onAdClicked(cloudXAd: CloudXAd) = Unit
    }
}

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

请通过进程生命周期来驱动它,这样只有在应用真正返回前台时才会展示广告,而不是在每次 Activity 恢复时都展示:

// 在持有该控制器的类中,例如你的 Application 子类:
private val controller = ArbiterAppOpenController(context, cloudXAdUnitId, adMobAdUnitId)

ProcessLifecycleOwner.get().lifecycle.addObserver(object : DefaultLifecycleObserver {
    override fun onStart(owner: LifecycleOwner) {
        currentActivity?.let { controller.showOnForeground(it) }
    }
})

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

横幅与 MREC 仲裁

横幅广告和 MREC 属于视图格式:多个网络可以同时持有已加载的广告,但屏幕上只能展示一个视图。相较于全屏格式,横幅和 MREC 的仲裁多出三项要求。

禁用自动刷新

必须在各处都关闭自动刷新,让仲裁器(而不是各网络内部的计时器)来控制何时展示新广告:

  • 在 CloudX 控制台中为该广告单元关闭自动刷新。
  • 创建 CloudXAdView 后立即调用 stopAutoRefresh()。
  • 同时在其他参与仲裁的网络的横幅广告 API 上禁用自动刷新。

视图附加

所有候选广告都应在屏幕外加载。只有获胜出价对应的横幅视图可以被添加到视图层级或展示。落选的视图应保持未附加状态——不要将其添加到任何父视图,哪怕只是临时添加——因为添加视图会使其渲染,并可能为仲裁器拒绝的出价触发展示。与标准横幅集成不同(后者在创建时就将视图添加到布局中,参见 横幅与 MREC),使用 Trusted Arbiter 时视图不得在创建时附加——只有在仲裁选中其为获胜方后才能附加。

刷新周期

禁用自动刷新后,仲裁器必须驱动自己的刷新循环:

运行第一轮

并行加载所有网络的候选广告,运行仲裁器,并附加获胜视图。

为获胜方启动下一次加载

获胜出价的展示一旦触发,立即为获胜网络启动新一轮加载,以便为下一轮做好准备。

保留未获胜的已填充广告

保留未获胜网络已经填充的广告,留待下一轮仲裁使用。只对上一轮未填充的网络重新发起加载请求。

重新仲裁

等待未完成的加载响应全部返回后,针对完整的候选集合再次运行仲裁器。

按周期切换

每隔 20-30 秒刷新一次展示中的广告,将新的获胜视图替换掉旧视图。间隔短于 20 秒会降低 CPM 表现。

横幅广告示例

这个控制器是视图格式”先仲裁,再渲染”规则的参考实现。它在 CloudX 横幅广告与 LevelPlay 横幅广告之间进行仲裁,并驱动上述刷新周期。MREC 的结构相同——将 CloudX.createBanner() 替换为 CloudX.createMREC(),并将 LevelPlay 横幅类型替换为 LevelPlay MREC 类型即可。

ArbiterBannerController.kt
/**
 * 为横幅广告位驱动 Trusted Arbiter:并行加载 [cloudXBanner] 和 [levelPlayBanner],
 * 在已填充的候选项之间进行仲裁,将获胜视图添加到 [container],并按
 * [refreshIntervalMs] 周期性刷新。
 *
 * 必须在两个网络上都禁用自动刷新(CloudX 通过控制台设置,此处通过
 * `stopAutoRefresh()`,LevelPlay 通过其对应设置)——刷新周期改由本控制器接管。
 */
class ArbiterBannerController(
    private val cloudXBanner: CloudXAdView,
    private val levelPlayBanner: LevelPlayBannerAdView,
    private val container: ViewGroup,
    private val refreshIntervalMs: Long = 25_000L,
) {
    private val handler = Handler(Looper.getMainLooper())
    private var cloudXAd: CloudXAd? = null
    private var cloudXFilled = false
    private var cloudXLoadDone = false
    private var levelPlayAdInfo: LevelPlayAdInfo? = null
    private var levelPlayFilled = false
    private var levelPlayLoadDone = false
    private var currentWinner: CloudXArbiterPlatform? = null

    private val refreshRunnable = Runnable { runArbitrationRound() }

    init {
        cloudXBanner.stopAutoRefresh()
        cloudXBanner.listener = createCloudXListener()
        cloudXBanner.revenueListener = createCloudXRevenueListener()
        levelPlayBanner.bannerListener = createLevelPlayListener()
    }

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

    fun stop() {
        handler.removeCallbacks(refreshRunnable)
    }

    private fun loadMissingAds() {
        if (!cloudXFilled) cloudXBanner.load()
        if (!levelPlayFilled) levelPlayBanner.loadAd()
    }

    /** 仅在两个网络都已结束加载(无论填充还是失败)时运行;只针对待处理的候选项重新仲裁。 */
    private fun maybeArbitrate() {
        if (!cloudXLoadDone || !levelPlayLoadDone) return

        val bids = listOfNotNull(
            cloudXAd?.let { CloudXArbiterBid.cloudX(it) },
            levelPlayAdInfo?.let { info ->
                CloudXArbiterBid.levelPlay(
                    networkName = info.adNetwork,
                    revenue = info.revenue,
                    precision = info.precision,
                )
            },
        )

        if (bids.isEmpty()) {
            loadMissingAds()
            return
        }

        CloudX.arbiter(
            configuration = CloudXArbiterConfiguration.builder(bids).build(),
            listener = object : CloudXArbiterListener {
                override fun onCompleted(result: CloudXArbiterResult) {
                    showWinner(result.platform)
                }
            }
        )
    }

    private fun runArbitrationRound() {
        maybeArbitrate()
    }

    /** 只附加获胜视图;落选视图永远不会被添加到 [container] 中。 */
    private fun showWinner(platform: CloudXArbiterPlatform) {
        currentWinner = platform
        container.removeAllViews()

        when (platform) {
            CloudXArbiterPlatform.CLOUDX -> container.addView(cloudXBanner)
            CloudXArbiterPlatform.LEVELPLAY -> container.addView(levelPlayBanner)
            else -> return // CloudXArbiterPlatform.NONE——无获胜平台;container 保持为空
        }

        handler.removeCallbacks(refreshRunnable)
        handler.postDelayed(refreshRunnable, refreshIntervalMs)
    }

    /** 获胜网络的展示一旦触发,立即启动其下一次加载。 */
    private fun onWinnerImpression(platform: CloudXArbiterPlatform) {
        when (platform) {
            CloudXArbiterPlatform.CLOUDX -> {
                cloudXFilled = false
                cloudXLoadDone = false
                cloudXBanner.load()
            }
            CloudXArbiterPlatform.LEVELPLAY -> {
                levelPlayFilled = false
                levelPlayLoadDone = false
                levelPlayBanner.loadAd()
            }
            else -> Unit
        }
    }

    private fun createCloudXListener() = object : CloudXAdViewListener {
        override fun onAdLoaded(cloudXAd: CloudXAd) {
            this@ArbiterBannerController.cloudXAd = cloudXAd
            cloudXFilled = true
            cloudXLoadDone = true
            maybeArbitrate()
        }

        override fun onAdLoadFailed(adUnitId: String, cloudXError: CloudXError) {
            cloudXAd = null
            cloudXFilled = false
            cloudXLoadDone = true
            maybeArbitrate()
        }

        override fun onAdClicked(cloudXAd: CloudXAd) = Unit
        override fun onAdExpanded(cloudXAd: CloudXAd) = Unit
        override fun onAdCollapsed(cloudXAd: CloudXAd) = Unit
    }

    private fun createCloudXRevenueListener() = object : CloudXAdRevenueListener {
        override fun onAdRevenuePaid(cloudXAd: CloudXAd) {
            // onAdRevenuePaid 在 CloudX 展示发生时触发;仅当 CloudX 正在展示时才响应。
            if (currentWinner == CloudXArbiterPlatform.CLOUDX) {
                onWinnerImpression(CloudXArbiterPlatform.CLOUDX)
            }
        }
    }

    private fun createLevelPlayListener() = object : LevelPlayBannerAdViewListener {
        override fun onAdLoaded(levelPlayAdInfo: LevelPlayAdInfo) {
            this@ArbiterBannerController.levelPlayAdInfo = levelPlayAdInfo
            levelPlayFilled = true
            levelPlayLoadDone = true
            maybeArbitrate()
        }

        override fun onAdLoadFailed(levelPlayAdError: LevelPlayAdError) {
            levelPlayAdInfo = null
            levelPlayFilled = false
            levelPlayLoadDone = true
            maybeArbitrate()
        }

        override fun onAdDisplayed(levelPlayAdInfo: LevelPlayAdInfo) {
            if (currentWinner == CloudXArbiterPlatform.LEVELPLAY) {
                onWinnerImpression(CloudXArbiterPlatform.LEVELPLAY)
            }
        }

        override fun onAdClicked(levelPlayAdInfo: LevelPlayAdInfo) = Unit
    }
}

onAdRevenuePaid()(CloudX)和 onAdDisplayed()(LevelPlay)分别是各自网络展示发生的位置;获胜网络的下一次加载正是在这里启动,从而在不影响当前展示的情况下为下一轮准备好新的填充广告。已经持有已填充、未附加广告的网络会直接跳过 loadMissingAds(),直到该填充被使用或过期。

原生广告示例

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

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

  1. 加载两个平台的广告,且不附加任何视图。
  2. 等两个平台都已确定结果后运行仲裁器。
  3. 只为获胜方构建视图,将其渲染进该视图,并将其附加。
  4. 保留落选平台已加载但未渲染的广告,留待下一轮使用。
ArbiterNativeController.kt
/**
 * 在 CloudX 原生广告与 AdMob 原生广告之间进行仲裁,只将获胜方渲染到 [container] 中。
 *
 * 两个平台都以不带视图的方式加载:[CloudXNativeAdLoader.loadAd] 在调用时不传入广告视图,
 * 因此加载时不会渲染任何内容,AdMob 广告也会保持未渲染状态。落选出价的素材永远不会被
 * 渲染或附加,因此也不会触发展示事件。
 */
class ArbiterNativeController(
    private val context: Context,
    private val container: ViewGroup,
    private val binder: CloudXNativeAdViewBinder,
    cloudXAdUnitId: String,
    private val adMobAdUnitId: String,
) {
    private val cloudXLoader: CloudXNativeAdLoader =
        CloudX.createNativeAdLoader(context, cloudXAdUnitId)
    private var cloudXAd: CloudXAd? = null
    private var cloudXLoadDone = false
    private var adMobNativeAd: NativeAd? = null
    private var adMobLoadDone = false
    private var cloudXLoadInFlight = false
    private var adMobLoadInFlight = false
    private var renderedPlatform: CloudXArbiterPlatform? = null
    private var renderedAdView: View? = null
    // 已附加视图背后的广告。它们的生命周期长于各自的候选状态:已渲染的广告在展示事件触发后
    // 仍会留在屏幕上,直到替换它的广告被附加为止。
    private var renderedCloudXAd: CloudXAd? = null
    private var renderedAdMobAd: NativeAd? = null

    init {
        cloudXLoader.nativeAdListener = createCloudXListener()
        cloudXLoader.revenueListener = createCloudXRevenueListener()
    }

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

    /** 释放两个平台的广告并清空容器。 */
    fun destroy() {
        // detachRenderedAd() 会释放已渲染的 AdMob 广告;只有当候选广告是另一个从未渲染过的
        // 实例时,才需要在这里销毁它。
        val unrenderedAdMobAd = adMobNativeAd.takeIf { it !== renderedAdMobAd }
        detachRenderedAd()
        cloudXLoader.destroy()
        unrenderedAdMobAd?.destroy()
        adMobNativeAd = null
    }

    private fun loadMissingAds() {
        // 不带广告视图调用 loadAd() 可以推迟渲染。此处绝不能使用 loadAd(adView):
        // 那样会在加载时就渲染 CloudX 广告,而此时仲裁器还没有选出获胜方。
        if (cloudXAd == null && !cloudXLoadInFlight) {
            cloudXLoadInFlight = true
            cloudXLoadDone = false
            cloudXLoader.loadAd()
        }

        if (adMobNativeAd == null && !adMobLoadInFlight) {
            adMobLoadInFlight = true
            adMobLoadDone = false
            loadAdMob()
        }
    }

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

        val bids = listOfNotNull(
            cloudXAd?.let { CloudXArbiterBid.cloudX(it) },
            adMobNativeAd?.let { CloudXArbiterBid.adMob(adUnitId = adMobAdUnitId) },
        )

        if (bids.isEmpty()) {
            loadMissingAds()
            return
        }

        CloudX.arbiter(
            configuration = CloudXArbiterConfiguration.builder(bids).build(),
            listener = object : CloudXArbiterListener {
                override fun onCompleted(result: CloudXArbiterResult) {
                    renderWinner(result.platform)
                }
            }
        )
    }

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

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

        detachRenderedAd()

        when (platform) {
            CloudXArbiterPlatform.CLOUDX -> renderCloudX()
            CloudXArbiterPlatform.ADMOB -> renderAdMob()
            else -> Unit
        }
    }

    /** 当该平台的当前候选广告就是已附加到容器中的那一个时返回 true。 */
    private fun isAlreadyRendered(platform: CloudXArbiterPlatform): Boolean = when (platform) {
        CloudXArbiterPlatform.CLOUDX -> cloudXAd != null && cloudXAd === renderedCloudXAd
        CloudXArbiterPlatform.ADMOB -> adMobNativeAd != null && adMobNativeAd === renderedAdMobAd
        else -> false
    }

    /** 丢弃当前未被渲染的所有候选广告,然后开始新一轮周期。 */
    private fun discardUnrenderedCandidatesAndReload() {
        if (renderedPlatform != CloudXArbiterPlatform.CLOUDX) {
            cloudXAd = null
            cloudXLoadDone = false
        }

        if (renderedPlatform != CloudXArbiterPlatform.ADMOB) {
            adMobNativeAd = null
            adMobLoadDone = false
        }

        loadMissingAds()
    }

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

        renderedCloudXAd?.let { cloudXLoader.destroy(it) }
        renderedCloudXAd = null
        renderedAdMobAd?.destroy()
        renderedAdMobAd = null
    }

    private fun renderCloudX() {
        val ad = cloudXAd ?: return
        val adView = CloudXNativeAdView(context, binder)
        cloudXLoader.render(adView, ad)
        container.addView(adView)
        renderedAdView = adView
        renderedPlatform = CloudXArbiterPlatform.CLOUDX
        renderedCloudXAd = ad
    }

    private fun renderAdMob() {
        val ad = adMobNativeAd ?: return
        // 加载你自己的 NativeAdView 布局并绑定其素材视图,方式与非仲裁场景下的
        // AdMob 原生广告集成完全相同。
        val adView = LayoutInflater.from(context)
            .inflate(R.layout.admob_native_ad_layout, container, false) as NativeAdView
        adView.setNativeAd(ad)
        container.addView(adView)
        renderedAdView = adView
        renderedPlatform = CloudXArbiterPlatform.ADMOB
        renderedAdMobAd = ad
    }

    private fun loadAdMob() {
        AdLoader.Builder(context, adMobAdUnitId)
            .forNativeAd { nativeAd ->
                nativeAd.setOnPaidEventListener { adValue ->
                    reportAdMobPaidEvent(nativeAd, adValue)
                }
                adMobNativeAd = nativeAd
                adMobLoadInFlight = false
                adMobLoadDone = true
                maybeArbitrate()
            }
            .withAdListener(object : AdListener() {
                override fun onAdFailedToLoad(error: LoadAdError) {
                    Log.w("CloudX", "AdMob native ad failed to load: ${error.message}")
                    adMobNativeAd = null
                    adMobLoadInFlight = false
                    adMobLoadDone = true
                    maybeArbitrate()
                }

                // 获胜的 AdMob 广告在展示事件触发的瞬间即被消耗,因此在这里请求下一个。
                // 如果没有这一步,该控制器会在整个会话期间持续用一个已消耗的广告参与出价。
                override fun onAdImpression() {
                    if (renderedPlatform == CloudXArbiterPlatform.ADMOB) {
                        onWinnerImpression(CloudXArbiterPlatform.ADMOB)
                    }
                }
            })
            .build()
            .loadAd(AdRequest.Builder().build())
    }

    /**
     * 将 AdMob 的实际收益转发给 CloudX。这是集成中必需的环节,而不是可选的分析附加项:
     * CloudX 会根据你回传的数据为未来的 AdMob 出价定价。
     *
     * toCloudXRevenuePrecision() 就是上文"将 Google 付费事件回传给 CloudX(必需)"
     * 一节中展示的映射方式。
     */
    private fun reportAdMobPaidEvent(ad: NativeAd, adValue: AdValue) {
        val servedBy = ad.responseInfo?.loadedAdapterResponseInfo

        val accepted = CloudX.reportRevenueData(
            CloudXRevenueData.builder(
                platform = CloudXRevenuePlatform.ADMOB,
                revenue = adValue.valueMicros / 1_000_000.0,
                adFormat = "native",
            )
                .currencyCode(adValue.currencyCode)
                .precision(adValue.precisionType.toCloudXRevenuePrecision())
                .networkName(servedBy?.adSourceName)
                .adUnitId(adMobAdUnitId)
                .thirdPartyAdPlacementId(servedBy?.adSourceInstanceName)
                .build(),
        )

        if (!accepted) {
            Log.w("CloudX", "AdMob native paid event was not accepted by the CloudX SDK")
        }
    }

    private fun createCloudXListener() = object : CloudXNativeAdListener {
        // 这里的 adView 为 null,因为调用 loadAd() 时没有传入视图。该广告只有在
        // 赢得仲裁后才会被渲染。
        override fun onNativeAdLoaded(adView: CloudXNativeAdView?, ad: CloudXAd) {
            cloudXAd = ad
            cloudXLoadInFlight = false
            cloudXLoadDone = true
            maybeArbitrate()
        }

        override fun onNativeAdLoadFailed(adUnitId: String, error: CloudXError) {
            Log.w("CloudX", "CloudX native ad failed to load: ${error.message}")
            cloudXAd = null
            cloudXLoadInFlight = false
            cloudXLoadDone = true
            maybeArbitrate()
        }

        override fun onNativeAdClicked(ad: CloudXAd) = Unit

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

        override fun onNativeAdClosed(ad: CloudXAd) {
            if (renderedCloudXAd !== ad) cloudXLoader.destroy(ad)
            clearCloudXAndReload()
        }
    }

    private fun createCloudXRevenueListener() = object : CloudXAdRevenueListener {
        override fun onAdRevenuePaid(cloudXAd: CloudXAd) {
            // onAdRevenuePaid 在 CloudX 展示事件发生时触发;只有当 CloudX 正是屏幕上的平台时
            // 才处理它。
            if (renderedPlatform == CloudXArbiterPlatform.CLOUDX) {
                onWinnerImpression(CloudXArbiterPlatform.CLOUDX)
            }
        }
    }

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

        loadMissingAds()
    }

    /** 丢弃 CloudX 候选广告,如果它当前正在屏幕上展示,则先将其移除。 */
    private fun clearCloudXAndReload() {
        if (renderedPlatform == CloudXArbiterPlatform.CLOUDX) {
            detachRenderedAd()
        }

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

onNativeAdLoaded() 收到的 adView 为 null,因为加载时没有传入视图——这个 null 就是提示你渲染动作仍需自己触发的信号。请在 renderCloudX() 中构建 CloudXNativeAdView,并且只有在仲裁器选定 CloudX 为获胜方之后,才将其传给 render()。

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

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

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

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

自定义出价输入

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

val customBid = CloudXArbiterBid.custom(
    platformName = "my_mediation_platform",
    networkName = "winning_demand_source",
    revenuePerImpressionUSD = 0.00125,
    precision = CloudXArbiterPrecision.EXACT,
    extras = mapOf("ad_unit" to "third-party-ad-unit-id"),
)

val configuration = CloudXArbiterConfiguration.builder(
    listOf(CloudXArbiterBid.cloudX(cloudXAd), customBid)
).build()

当自定义出价胜出时,result.platform 为 CloudXArbiterPlatform.CUSTOM,result.platformName 为出价中传入的 platformName。revenuePerImpressionUSD 应传入单次展示的美元收益,而不是 CPM。请使用 CloudXArbiterPrecision.EXACT、ESTIMATED、PUBLISHER_DEFINED 或 UNDEFINED 描述该收益值的精度。