如何用 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。
常见原因有:
- 测试账号以前订阅过相关产品,已经不符合“新用户”或免费试用资格。
- offer 在 Play Console 中仍是草稿,或者尚未启用。
- offer 所属的 base plan 没有启用。
- offer 未覆盖测试账号所在的国家或地区。
- 应用、订阅配置或优惠变更尚未通过对应测试轨道发布并同步到 Google Play。
- 测试安装包不是从 Google Play 测试轨道安装的,或者当前 Play 商店账号并非已配置的测试账号。
- 查询的
productId与 Play Console 中配置优惠的订阅产品不一致。 - 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 为准。免费试用、优惠阶段和正常续费阶段可能同时存在。展示价格时,应完整说明各个阶段,不能只读取列表中的第一个价格或第一个计费周期。
备注:内容仅供参考。
