从 2026 年 8 月 31 日开始,Google Play 不再接受使用 Billing Library 7 或更低版本的新应用和应用更新。可以申请延期到 2026 年 11 月 1 日。
Billing 8 不是只改依赖版本。它改了商品模型、查询结果、失败原因、连接管理和 pending purchase 初始化方式。
如果项目里有内购、订阅、会员、虚拟商品,这次迁移应该提前做。
先看版本和时间线
如果项目直接接入 BillingClient,现在需要把依赖升到 8.x:
bash
dependencies {
implementation("com.android.billingclient:billing:8.3.0")
}
真正麻烦的不是这一行。
Billing 8 删除了一批旧 API,也把一些原来"查不到就没有"的结果变成了更明确的状态返回。

迁移前先确认项目属于哪种情况:
-
• 完全通过 RevenueCat 管内购
-
• RevenueCat 和自研 BillingClient 并存
-
• 完全自研 BillingClient
如果完全通过 RevenueCat,主要看 RevenueCat SDK 版本和 Dashboard 配置。
如果项目里还直接依赖 com.android.billingclient:billing,那就不能只升级 RevenueCat SDK。
一次性商品也有购买选项
Billing 8 里,一次性商品从 "in-app products" 转向 "one-time products" 的表达。
这个变化不只是命名。
一次性商品也开始支持多个 purchase options 和 offers。
以前很多项目会把一次性商品理解成:
bash
productId -> price -> purchase
Billing 8 之后,更接近:
bash
productId -> purchase option / offer -> price -> purchase
这会影响商品展示和购买参数。
如果一个一次性商品未来有普通购买、限时优惠、预购、特殊活动价,UI 层不能再默认一个 productId 只有一个价格。

更稳的做法是把 Billing 返回结果收敛成业务自己的商品模型:
bash
data class PaywallProduct(
val productId: String,
val title: String,
val priceText: String,
val productDetails: ProductDetails,
val offerToken: String?
)
UI 只关心 PaywallProduct。
Billing 层负责处理 ProductDetails、offer、价格阶段和 token。
这样以后商品配置变复杂,影响范围不会扩散到页面代码里。
商品查询结果变细了
Billing 8 改了 queryProductDetailsAsync() 的返回结果。
旧版本里,查不到的商品通常只是不出现在返回列表里。开发者只能看到"少了几个商品",但不知道为什么少。
Billing 8 会把未成功获取的商品也返回出来,并带上商品级状态。
可能原因包括:商品不存在、商品未激活、用户没有可用 offer、地区或账号条件不满足。
代码上要适配新的结果对象:
bash
val params = QueryProductDetailsParams.newBuilder()
.setProductList(productList)
.build()
billingClient.queryProductDetailsAsync(params) { billingResult, queryResult ->
if (billingResult.responseCode != BillingClient.BillingResponseCode.OK) {
reportBillingError(billingResult)
return@queryProductDetailsAsync
}
val products = queryResult.productDetailsList
val unfetchedProducts = queryResult.unfetchedProductList
products.forEach { productDetails ->
renderProduct(productDetails)
}
unfetchedProducts.forEach { unfetched ->
reportUnfetchedProduct(unfetched)
}
}
这类变化对线上排查很有用。
以前商品查不到,常见排查路径是 Play Console 配置、测试账号、商品状态、地区、版本轨道。
现在客户端可以把失败商品和状态码一起打到日志里。
不要只记录:
bash
query product failed
至少要记录:
bash
productId
productType
BillingResult.responseCode
BillingResult.debugMessage
unfetched product status code
很多商品问题不是代码 bug,而是配置和账号条件问题。
日志不够细,排查成本会很高。
购买流程要选对 offer
订阅从 Billing 5 开始已经进入 base plan / offer 模型。
Billing 8 又把一次性商品的购买选项能力往前推了一步。
购买参数不能只塞一个商品对象就结束。
对订阅来说,通常还要带上 offer token:
bash
val offerToken = productDetails.subscriptionOfferDetails
?.firstOrNull { offer ->
offer.basePlanId == "monthly"
}
?.offerToken
val productDetailsParams = BillingFlowParams.ProductDetailsParams.newBuilder()
.setProductDetails(productDetails)
.apply {
if (offerToken != null) {
setOfferToken(offerToken)
}
}
.build()
val billingFlowParams = BillingFlowParams.newBuilder()
.setProductDetailsParamsList(listOf(productDetailsParams))
.build()
真实项目里不要随手 firstOrNull()。
offer 选择应该来自明确规则:用户地区、订阅档位、实验分组、是否符合优惠条件、是否老用户、是否升级或降级。
否则代码可以跑,但用户看到的价格可能和产品策略不一致。

支付失败原因更具体
Billing 8 给购买流程相关的 BillingResult 增加了 sub-response code。
它不是每次都有值,但在部分失败场景里可以表达更具体原因。
RevenueCat 文章里提到的典型值包括:
-
•
PAYMENT_DECLINED_DUE_TO_INSUFFICIENT_FUNDS -
•
USER_INELIGIBLE -
•
NO_APPLICABLE_SUB_RESPONSE_CODE
这对支付页体验有影响。
以前很多项目只区分成功、用户取消、失败:
bash
val result = billingClient.launchBillingFlow(activity, billingFlowParams)
when (result.responseCode) {
BillingClient.BillingResponseCode.OK -> {
// 等待 PurchasesUpdatedListener 回调
}
BillingClient.BillingResponseCode.USER_CANCELED -> {
// 用户取消
}
else -> showGenericBillingError()
}
Billing 8 之后,可以把部分失败原因拆出来:
bash
val result = billingClient.launchBillingFlow(activity, billingFlowParams)
if (result.responseCode != BillingClient.BillingResponseCode.OK) {
when (result.onPurchasesUpdatedSubResponseCode) {
BillingClient.OnPurchasesUpdatedSubResponseCode
.PAYMENT_DECLINED_DUE_TO_INSUFFICIENT_FUNDS -> {
showInsufficientFundsMessage()
}
BillingClient.OnPurchasesUpdatedSubResponseCode.USER_INELIGIBLE -> {
showOfferUnavailableMessage()
}
else -> showGenericBillingError()
}
}
这里的重点是:不要把所有失败都塞进一个"支付失败,请重试"。
余额不足、优惠资格不满足、商品不可用,用户下一步动作不一样。
自动重连减少样板代码
BillingClient 需要连接 Google Play 服务。
旧实现里经常有一套手写重连:
bash
billingClient.startConnection(object : BillingClientStateListener {
override fun onBillingSetupFinished(result: BillingResult) {
if (result.responseCode == BillingClient.BillingResponseCode.OK) {
queryProducts()
}
}
override fun onBillingServiceDisconnected() {
retryConnectLater()
}
})
Billing 8 提供了自动重连:
bash
val billingClient = BillingClient.newBuilder(context)
.setListener(purchasesUpdatedListener)
.enablePendingPurchases(
PendingPurchasesParams.newBuilder()
.enableOneTimeProducts()
.build()
)
.enableAutoServiceReconnection()
.build()
打开后,如果 API 调用发生时服务已经断开,Billing Library 会尝试重新建立连接。
这不是说业务不用处理失败。
BillingResult 的错误码仍然要处理,尤其是服务不可用、网络异常、用户取消、开发者参数错误。

pending purchase 初始化要改
Billing 8 删除了无参数版本的 enablePendingPurchases()。
旧代码:
bash
val billingClient = BillingClient.newBuilder(context)
.setListener(purchasesUpdatedListener)
.enablePendingPurchases()
.build()
新版要传 PendingPurchasesParams:
bash
val pendingPurchasesParams = PendingPurchasesParams.newBuilder()
.enableOneTimeProducts()
.build()
val billingClient = BillingClient.newBuilder(context)
.setListener(purchasesUpdatedListener)
.enablePendingPurchases(pendingPurchasesParams)
.enableAutoServiceReconnection()
.build()
如果要支持预付费订阅的 pending 状态,还要启用:
bash
val pendingPurchasesParams = PendingPurchasesParams.newBuilder()
.enableOneTimeProducts()
.enablePrepaidPlans()
.build()
pending purchase 不能当成购买成功。
处理购买时要看 purchaseState:
bash
private fun handlePurchase(purchase: Purchase) {
when (purchase.purchaseState) {
Purchase.PurchaseState.PURCHASED -> {
verifyOnServer(purchase)
acknowledgeIfNeeded(purchase)
}
Purchase.PurchaseState.PENDING -> {
showPendingState()
}
else -> refreshEntitlement()
}
}
pending 状态下发放权益,会造成未付款解锁。
pending 状态下提示失败,也会让用户误以为支付流程已经结束。
旧 API 需要清掉
Billing 8 删除了多项此前废弃的 API。
比较常见的有:
-
•
querySkuDetailsAsync() -
•
queryPurchaseHistory()/queryPurchaseHistoryAsync() -
• 无参数
enablePendingPurchases() -
•
queryPurchasesAsync(String skuType, PurchasesResponseListener listener) -
• 旧的订阅替换参数 API,例如
setOldSkuPurchaseToken
商品查询要迁到 queryProductDetailsAsync()。
查询当前购买要使用带参数的 queryPurchasesAsync():
bash
val params = QueryPurchasesParams.newBuilder()
.setProductType(BillingClient.ProductType.SUBS)
.build()
billingClient.queryPurchasesAsync(params) { billingResult, purchases ->
if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
purchases.forEach { purchase ->
handlePurchase(purchase)
}
}
}
购买历史这块要单独看。
RevenueCat 文章里明确提到,Billing 8 不再支持通过旧 API 查询过期订阅和已消耗的一次性商品。
如果项目依赖这些历史交易做恢复、画像或补数据,不能再指望客户端 SDK 查回来。
替代方案通常是:
-
• 当前有效、待确认、pending 的购买:用
queryPurchasesAsync -
• 已消耗的一次性商品:自己服务端记录
-
• 取消或作废购买:看 Voided Purchases API
-
• 历史数据迁移:做服务端导入或后台补数
这也是 Billing 8 迁移里最容易被低估的地方。
代码能编译,不代表历史权益恢复还正确。

迁移顺序
Billing 8 的迁移不要从"哪里编译报错"开始。
更稳的顺序是:
-
• 先确认项目是否直接依赖 BillingClient
-
• 清理
SkuDetails和querySkuDetailsAsync -
• 统一商品模型到
ProductDetails -
• 适配
queryProductDetailsAsync新结果,记录 unfetched products -
• 购买流程里明确 offer 选择规则
-
• 改造
enablePendingPurchases(PendingPurchasesParams) -
• 打开
enableAutoServiceReconnection -
• 细分
BillingResult和 sub-response code -
• 检查历史购买、恢复购买、已消耗商品的服务端逻辑
内购迁移不要只测成功支付。
至少要覆盖商品查询为空、商品部分失败、多个 offer、用户不符合 offer 条件、用户取消、余额不足、pending purchase、未 acknowledge、服务断开后再次查询、订阅升级降级、已消耗商品恢复策略。
最后
Billing 8 的核心不是 API 名字变化,而是商品模型和购买状态更细。
一次性商品有了更复杂的购买选项,商品查询能返回失败明细,支付失败也能表达更具体原因。
项目越早把 Billing 逻辑收敛到单独模块,后续迁移越轻。