PAYATHON 2026

如何用 Kotlin 实现 Android Billing 首次优惠

数据小陈

结论

queryProductDetails() 不会返回订阅下配置的所有优惠。它只返回:

  • 当前 Google Play 账号有资格购买的 base plan;
  • 已启用、覆盖账号所在地区,且当前账号符合资格的 offer。

因此,如果结果中只有 base plan,通常不是 Billing Library 5.0 读取不到优惠,而是测试账号不符合 introductory offer 或 free trial 的资格,或者优惠尚未正确启用、发布或覆盖当前地区。

不要根据第一个 PricingPhase.billingPeriod 判断选项所属的套餐。免费试用的第一个阶段可能是 P7D,后续续费阶段才是 P1M 或 P1Y。选择优惠时,应匹配 basePlanId 和 offerId,再将对应 SubscriptionOfferDetails.offerToken 传入购买流程。

为什么只返回 base plan

ProductDetails.subscriptionOfferDetails 中的每个元素,代表当前账号可以买到的一个购买选项:

  • offerId == null:通常表示 base plan 的标准购买选项;
  • offerId != null:表示该 base plan 下的 introductory offer、free trial 或其他优惠;
  • basePlanId:该选项所属的基础方案;
  • pricingPhases:试用、优惠价、正常续费价等计费阶段;
  • offerToken:发起该购买选项时必须提交的令牌。

如果列表中只有 offerId == null 的项目,说明 Google Play 当前只向这个账号提供标准 base plan。

常见原因有:

  1. 测试账号以前订阅过相关产品,已经不符合“新用户”或免费试用资格。
  2. offer 在 Play Console 中仍是草稿,或者尚未启用。
  3. offer 所属的 base plan 没有启用。
  4. offer 未覆盖测试账号所在的国家或地区。
  5. 应用、订阅配置或优惠变更尚未通过对应测试轨道发布并同步到 Google Play。
  6. 测试安装包不是从 Google Play 测试轨道安装的,或者当前 Play 商店账号并非已配置的测试账号。
  7. 查询的 productId 与 Play Console 中配置优惠的订阅产品不一致。
  8. offer 设置了特定资格条件,而当前账号不符合这些条件。

backward-compatible 主要影响旧版订阅 API 与新 base plan 的映射方式。它不会强制 ProductDetails 返回当前账号没有资格购买的 offer。

正确检查返回结果

先按 basePlanId、offerId 和计费阶段打印所有可购买选项,不要只检查第一个元素:

val productDetails = productDetailsResult.productDetailsList
    ?.firstOrNull { it.productId == purchase.lowercase() }
    ?: error("Subscription product was not returned by Google Play")

val availableOffers = productDetails.subscriptionOfferDetails.orEmpty()

availableOffers.forEach { details ->
    Log.d(
        "Billing",
        "basePlanId=${details.basePlanId}, " +
            "offerId=${details.offerId}, " +
            "offerTags=${details.offerTags}, " +
            "offerToken=${details.offerToken}"
    )

    details.pricingPhases.pricingPhaseList.forEachIndexed { index, phase ->
        Log.d(
            "Billing",
            "phase=$index, " +
                "price=${phase.formattedPrice}, " +
                "billingPeriod=${phase.billingPeriod}, " +
                "billingCycleCount=${phase.billingCycleCount}, " +
                "recurrenceMode=${phase.recurrenceMode}"
        )
    }
}

免费试用的结果可能是:

basePlanId=monthly
offerId=monthly-free-trial

phase=0, price=Free, billingPeriod=P7D
phase=1, price=..., billingPeriod=P1M

原代码容易选错,原因就在这个判断:

it.pricingPhases.pricingPhaseList[0].billingPeriod == billingPeriod

七天免费试用的第一个阶段是 P7D,不是月度方案的 P1M。即使 offer 已经返回,这个条件也可能无法匹配它,甚至选中错误的标准 base plan。

按 basePlanId 和 offerId 选择优惠

应用应保存并使用 Play Console 中配置的 basePlanId 和 offerId。例如:

private fun findSubscriptionOffer(
    productDetails: ProductDetails,
    basePlanId: String,
    offerId: String
): ProductDetails.SubscriptionOfferDetails? {
    return productDetails.subscriptionOfferDetails
        ?.firstOrNull { details ->
            details.basePlanId == basePlanId &&
                details.offerId == offerId
        }
}

启动购买流程:

val productDetails = productDetailsResult.productDetailsList
    ?.firstOrNull { it.productId == purchase.lowercase() }
    ?: error("Subscription product is unavailable")

val selectedOffer = findSubscriptionOffer(
    productDetails = productDetails,
    basePlanId = "monthly",
    offerId = "monthly-free-trial"
)

if (selectedOffer == null) {
    // 当前账号没有资格购买该优惠。
    // 此时可以显示标准价格,也可以提示优惠不可用。
    Log.d("Billing", "The requested offer is not available for this account")
    return
}

val productDetailsParams = BillingFlowParams.ProductDetailsParams
    .newBuilder()
    .setProductDetails(productDetails)
    .setOfferToken(selectedOffer.offerToken)
    .build()

val flowParams = BillingFlowParams.newBuilder()
    .setProductDetailsParamsList(listOf(productDetailsParams))
    .build()

val billingResult = billingClient.launchBillingFlow(activity, flowParams)

if (billingResult.responseCode != BillingClient.BillingResponseCode.OK) {
    Log.e(
        "Billing",
        "Unable to launch billing flow: " +
            "${billingResult.responseCode}, ${billingResult.debugMessage}"
    )
}

如果不需要指定具体的 offerId,也可以根据预先约定的 offerTags 选择,但仍要同时限制 basePlanId:

val selectedOffer = productDetails.subscriptionOfferDetails
    ?.firstOrNull { details ->
        details.basePlanId == "monthly" &&
            "free-trial" in details.offerTags
    }

offerTags 需要提前在 Play Console 中配置,其含义也应在应用代码中保持稳定。

获取标准 base plan

当用户不符合优惠资格时,可以选择同一 base plan 的标准购买选项。标准选项通常满足 offerId == null:

val standardPlan = productDetails.subscriptionOfferDetails
    ?.firstOrNull { details ->
        details.basePlanId == "monthly" &&
            details.offerId == null
    }

之后使用该选项自己的 offerToken:

val selectedDetails = selectedOffer ?: standardPlan

if (selectedDetails == null) {
    Log.e("Billing", "No eligible purchase option was returned")
    return
}

val productDetailsParams = BillingFlowParams.ProductDetailsParams
    .newBuilder()
    .setProductDetails(productDetails)
    .setOfferToken(selectedDetails.offerToken)
    .build()

是否自动回退到标准价格,要根据产品设计决定。如果界面明确承诺“免费试用”,用户不符合资格时,不应静默发起原价订阅。应用应先更新界面,并让用户确认实际价格。

测试时应检查什么

在 Play Console 中逐项确认:

  • subscription product 已启用;
  • 对应的 base plan 已启用;
  • free trial 或 introductory offer 已启用;
  • offer 的资格条件符合测试目标;
  • base plan 和 offer 覆盖测试账号所在地区;
  • 应用已发布到内部测试、封闭测试或其他适用轨道;
  • 测试账号已加入测试名单,并使用同一账号打开 Google Play;
  • 应用通过 Google Play 的测试链接安装或更新;
  • 测试账号没有因历史订阅记录失去新用户优惠资格。

配置变更可能需要一段时间才能在 Google Play 客户端生效。代码不应对具体同步时间作固定假设。

其他注意事项

不要对 subscriptionOfferDetails、查找结果或 offerToken 使用 !!。优惠资格可能发生变化,列表为空是正常的业务状态,并不一定是程序错误。

也不要长期缓存 offerToken。展示购买页面或启动购买流程前,应重新查询 ProductDetails,并使用本次查询返回的 token。

最终价格应以 pricingPhases 为准。免费试用、优惠阶段和正常续费阶段可能同时存在。展示价格时,应完整说明各个阶段,不能只读取列表中的第一个价格或第一个计费周期。

备注:内容仅供参考。