大众号:字节数组

期望对你有所帮助

现如今,App 出海热度不减,是许多公司和个人开发者挑选的一个市场方向。App 为了完成盈利,除了接入广告这种最常见的变现方法外,便是经过供给各类虚拟产品或许是会员服务来吸引用户付费了,此刻 Google Play 结算体系(Google Play’s billing system)便是 Android 端运用有必要运用到的一个付出渠道了

Google 对 Google Play 结算体系的简介:Google Play’s billing system is a service that enables you to sell digital products and content in your Android app, whether you want to monetize through one-time purchases or offer subscriptions to your services. Google Play offers a full set of APIs for integration with both your Android app and your server backend that unlock the familiarity and safety of Google Play purchases for your users.

也便是说:Google Play 结算体系是一项能够让咱们在 Android 运用中销售数字产品和内容的服务。无论是要经过一次性购买买卖创收,仍是要为用户供给订阅服务,它都能帮咱们搞定。Google Play 供给了一整套 API,可集成到 Android 运用和服务器后端中,从而为用户供给熟悉又安全的 Google Play 购买买卖服务

在最近的一年多时间里,我一直在负责一个海外项目的开发作业,这个过程中也接入了 Google Play 结算体系。在刚开始时,因为对当中的各个概念不行了解,其整体付出流程又和国内常用的各类付出服务相差挺大的,导致我走了不少的弯路

这儿我就来写一篇文章,对 Google Play 结算体系进行详细介绍,期望对你有所帮助

一、概述

想要经过 Google Play 结算体系向用户展现并售卖产品,自然需求先创立产品,创立产品的方法有两种:

  • 在 Google Play Console 手动创立
  • 经过 Google Play Developer API 以代码的方法创立

在 Google Play 中创立的产品都归于虚拟产品,每个产品代表的都是 App 给用户供给的一种权益,而每个产品都包含一个仅有标识,也即 ProductId,咱们在事务上就需求依据 ProductId 的命名规矩来界说产品所代表的详细权益类型

每个产品又能够分为两种类型:

  • 一次性产品。用户经过单次付费取得的产品,归于买断制,对应 Google Play 结算库中的 BillingClient.ProductType.INAPP
  • 订阅型产品。用户以固定周期不断重复付费的产品,归于订阅制,对应 Google Play 结算库中的 BillingClient.ProductType.SUBS

当用户购买了产品后,App 还需求对这笔订单进行核销。处理流程和产品类型有关,分为两种:

  • 承认买卖。不管购买的产品是什么类型,App 都需求先对这笔买卖进行 承认,假设在限制的时间内未完成承认,Google Play 就会主动吊销这笔买卖并向用户退款。“承认买卖” 这个操作应该是 Google Play 为了让 App 确定现已向用户供给了权益,尽量防止呈现用户已付款但 App 没有向用户下发权益这种状况。承认操作能够由服务端或许移动端来完成,对应 acknowledgePurchase 操作
  • 耗费产品。耗费产品针对的是一次性产品中的耗费型产品,也即对其履行 耗费 操作。经过履行耗费操作,使得用户后续能够再次购买此产品。耗费操作能够由服务端或许移动端来完成,对应 consumePurchase 操作

二、一次性产品

一次性产品也称为运用内产品,归于一次性买断的产品,详细又能够细分为两种子类型:

  • 耗费型产品。也便是说,此产品在购买后能够被耗费,从而使得用户能够重复购买。例如,该产品能够用于表明游戏中的金币,用户在运用完金币后该产品代表的权益就失效了,用户需求再次购买产品才干再次取得金币
  • 非耗费型产品。也便是说,此产品在购买后是不可耗费的,用户能够永久取得该产品代表的权益。例如,该产品能够用于表明某课程的观看权益,用户只需购买产品后,就能够永久享有该课程的观看权益

一次性产品到底归于 耗费型 仍是 非耗费型 都取决于 App 在事务上的界说,在 Google Play Console 中都一致将其称为 运用内产品,在创立一次性产品时也没有区分子类型的选项

假定咱们对一件一次性产品在事务上的界说是耗费型的,那么就能够在适当的时候经过履行 consumePurchase 来对其履行 “耗费” 操作。例如,用户经过购买某个一次性产品取得了游戏金币,用户在后续过程中运用这些金币来购买游戏道具,那么开发者就需求一同履行 consumePurchase 来耗费掉产品,从而使得该产品变为无效状况,这样用户后续也能够再次购买此产品

而对于非耗费型产品,在事务上代表的是用户能够永久享有的某个权益,只需买了该产品权益就不会丢掉,因此用户也不应该再次购买,自然也就不需求也不能履行耗费操作了

三、订阅型产品

订阅型产品,也即需求用户以固定周期定时进行付费的产品,在付费周期内用户均能享有该产品代表的权益。最常见的运用场景便是各类会员服务:用户按月付费,App 在每个订阅周期内向用户供给会员独有的功能,直至用户取消订阅

订阅型产品包含四个比较重要的概念:

  • 根底计划
  • 续订类型
  • 优惠
  • 定价阶段

根底计划

根底计划,也称为 BasePaln,每个订阅型产品都有必要包含一个或多个根底计划才干让用户购买

根底计划就用于界说产品的售卖规矩,包含结算周期、续订类型、订阅价格、优惠战略等。例如,一个订阅型产品能够一同供给 按月付费按年付费 这两个根底计划供用户挑选,每个周期别离设定不同的价格,用户依据喜好来挑选不同的计划进行订阅

续订类型

每个根底计划均需求指定续订类型,用于指定用户的付费方法

续订类型分为两种:

  • 主动续订。在每个结算周期行将完毕时主动向用户扣款,从而主动延伸权益运用权的期限。付费操作对于用户来说是被迫的
  • 预付费。不会主动续订和扣款,用户需求经过主动付款来推延权益运用权的完毕日期,以此坚持不间断地享有订阅内容。付费操作对于用户来说是主动的

优惠

优惠,也称为 Offer,只需 主动续订型 的根底计划才干设定优惠

每个主动续订型的根底计划能够一同设定多个优惠,让用户能够在订阅初期享受必定的价格扣头或许是直接就免费运用,从而吸引用户购买

Offer 的类型分为三种,也即分为三种优惠战略。例如,假定现在有一个按月订阅的根底计划,咱们就能够为其增加以下三个 Offer 供用户挑选:

  • 免费试订。用户在前七天内免费试用,在七天后再正式进行按月付费
  • 单次付款。用户一次性预付三个月的订阅费用,总价享受七扣头头,三个月后再按原价进行按月订阅
  • 周期性付款扣头。用户仍是按月订阅,但前三个月每次付费时均能享受八扣头头,三个月后再按原价进行按月订阅

价格阶段

价格阶段,也称为 PricingPhases,能够看做是 Offer 的一个内部特点

因为一个 Offer 能够一同包含多个优惠战略,所以当用户在享受某个 Offer 时,其需求开销的价格就会随时间发生屡次改变,每个时间段别离对应的不同的价格,PricingPhases 就用于表明 Offer 在每一个时间段的收费规矩

例如,某个按月主动续订的根底计划包含一个 Offer,此 Offer 包含一个七天免费试订的优惠战略。那么,此 Offer 的价格阶段就别离是:

  • 用户先享受七天的免费试订
  • 七天后,用户再按原价按月付费

假设为这个 Offer 再增加一个 “扣头为七折,为期一个月的周期性付款” 的优惠战略,此刻 Offer 的价格阶段就变成了:

  • 用户先享受七天的免费试订
  • 七天后,用户按原价的七折进行付费,取得一个月的订阅期
  • 一个月后,用户再按原价按月付费

所以说,价格阶段就决定了用户在不一同间段下所需求开销的费用,每个 Offer 最多答应增加两个价格阶段,也即最多发生三次价格改变,用户会按顺序来接收价格改变

总结

Google Play 设定 BasePlan 和 Offer 的自由度很高。主动续订的 BasePlan 的付费周期能够从一周到一年,预付费的 BasePlan 的付费周期能够从一天到一年。每种优惠战略的优惠周期和优惠价也都能够很灵敏地设定。咱们能够经过设定多种不同的周期时长和优惠战略供用户挑选,从而尽量提高用户的付费率

此外,每个订阅型产品最多能够创立 250 个根底计划和优惠,但一同启用的根底计划和优惠不能超过 50 个,多出的根底计划和优惠有必要处于草稿或未启用状况

四、Billing SDK

了解了以上的根底概念后,再来看这些概念怎么和 Billing SDK 对应起来

本文一切的代码示例运用的均是当时 Google Play 结算体系在 Android 端最新版别的 SDK,且是协程版别,读者需求对协程有必定了解

dependencies {
    val billingVersion = "6.0.1"
    implementation("com.android.billingclient:billing-ktx:$billingVersion")
}

整个付出流程能够总结为以下几点:

  1. 经过 BillingClient 和 Google Play 建立衔接,一同绑定用于回调付出成果的 PurchasesUpdatedListener 接口
  2. 经过 BillingClient 查询到本地化处理的产品信息,也即 ProductDetails,从而拿到 产品描绘、根底计划、价格信息、优惠战略 等特点
  3. 依据查到的 ProductDetails,向 BillingClient 建议付出请求,调起付出弹窗
  4. 在 PurchasesUpdatedListener 里拿到付出成果,判断用户的付出状况
  5. 当确定用户付出成功后,依据产品类型择机对产品进行 承认耗费

BillingClient

BillingClient 是 Google Play 结算库与 App 进行通讯的主接口,App 在履行任何与付出相关的操作之前,都需求先经过 BillingClient 和 Google Play 建立衔接。在初始化 BillingClient 实例时,需求一同绑定 PurchasesUpdatedListener,以便得到付出成果的回调告诉。也正因为如此,App 在同一时间段最多只能坚持一个活跃的 BillingClient 衔接,防止同一个付出事件一同回调多个 PurchasesUpdatedListener

private val purchasesUpdatedListener =
    PurchasesUpdatedListener { billingResult: BillingResult, purchases: List<Purchase>? ->
    }
private lateinit var billingClient: BillingClient
suspend fun startConnection(context: Context) {
    billingClient = buildBillingClient(context = context, purchasesUpdatedListener)
    startConnection(billingClient = mBillingClient)
}
private fun buildBillingClient(
    context: Context,
    listener: PurchasesUpdatedListener
): BillingClient {
    return BillingClient.newBuilder(context)
        .setListener(listener)
        .enablePendingPurchases()
        .build()
}
private suspend fun startConnection(billingClient: BillingClient): BillingResult? {
    return withContext(context = Dispatchers.Default) {
        if (billingClient.isReady) {
            return@withContext null
        }
        return@withContext suspendCancellableCoroutine { continuation ->
            billingClient.startConnection(object : BillingClientStateListener {
                override fun onBillingSetupFinished(billingResult: BillingResult) {
                    if (!continuation.isCompleted) {
                        continuation.resume(value = billingResult)
                    }
                }
                override fun onBillingServiceDisconnected() {
                    if (!continuation.isCompleted) {
                        continuation.resume(value = null)
                    }
                }
            })
        }
    }
}

ProductDetails

ProductDetails 也即产品概况,不管是一次性产品仍是订阅型产品,都经过 ProductDetails 来承载详细的产品信息

查询 ProductDetails 需求两个查询参数:ProductId 和 产品类型,产品类型也即 一次性产品 INAPP订阅型产品 SUBS 两种

private suspend fun queryProductDetails() {
    //查询一次性产品
    queryProductDetails(
        billingClient = mBillingClient,
        productIdList = setOf("1", "2"),
        productType = BillingClient.ProductType.INAPP
    )
    //查询订阅型产品
    queryProductDetails(
        billingClient = mBillingClient,
        productIdList = setOf("1", "2"),
        productType = BillingClient.ProductType.SUBS
    )
}
private suspend fun queryProductDetails(
    billingClient: BillingClient,
    productIdList: Set<String>,
    productType: String
): List<ProductDetails>? {
    return withContext(context = Dispatchers.Default) {
        if (!billingClient.isReady || productIdList.isEmpty()) {
            return@withContext null
        }
        val productDetailParamsList = productIdList.map {
            QueryProductDetailsParams
                .Product
                .newBuilder()
                .setProductId(it)
                .setProductType(productType)
                .build()
        }
        val queryProductDetailsParams = QueryProductDetailsParams
            .newBuilder()
            .setProductList(productDetailParamsList)
            .build()
        val productDetailsResult = billingClient.queryProductDetails(queryProductDetailsParams)
        productDetailsResult.productDetailsList
    }
}

ProductDetails 的数据结构如下所示,咱们能够依靠这些信息来向用户展现产品概况。oneTimePurchaseOfferDetails 和 subscriptionOfferDetails 这两个字段就别离用来承载一次性产品和订阅型产品的价格信息

{
	"productId": "",
	"productType": "",
	"title": "",
	"name": "",
	"description": "",
	"oneTimePurchaseOfferDetails": {},
	"subscriptionOfferDetails": []
}

oneTimePurchaseOfferDetails

oneTimePurchaseOfferDetails 对应的是一次性产品的概况,数据结构比较简单,首要便是价格信息了

{
	"priceAmountMicros": 548000000,
	"priceCurrencyCode": "HKD",
	"formattedPrice": "HK$548.00"
}

需求注意,Google Play 回来的价格信息都是做了本地化处理的,会主动依据当时设备的 Google Play 账号所对应的国家区域来回来概况,所以产品的价格钱银代号 priceCurrencyCode 和格局化好的产品价格 formattedPrice 都会因实践状况而改变

subscriptionOfferDetails

subscriptionOfferDetails 对应的是订阅型产品的概况

因为订阅型产品是能够包含多个 BasePlan 的,每个 BasePlan 又能够包含多个 Offer,所以 subscriptionOfferDetails 字段在 ProductDetails 中对应的数据类型是 List<SubscriptionOfferDetails>。每个 SubscriptionOfferDetails 都对应一个 Offer,每个 Offer 又关联一个 BasePlan,Google Play 以 Offer 为单位来回来价格信息

[
    {
        "basePlanId": "yearly",
        "offerId": null,
        "offerToken": "xxx",
        "pricingPhases": {
            "pricingPhaseList": [
                {
                    "formattedPrice": "HK$469.00",
                    "priceAmountMicros": 469000000,
                    "priceCurrencyCode": "HKD",
                    "billingPeriod": "P1Y",
                    "billingCycleCount": 0,
                    "recurrenceMode": 1
                }
            ]
        }
    },
    {
        "basePlanId": "yearly",
        "offerId": "xxx",
        "offerToken": "xxx",
        "pricingPhases": {
            "pricingPhaseList": [
                {
                    "formattedPrice": "免費",
                    "priceAmountMicros": 0,
                    "priceCurrencyCode": "HKD",
                    "billingPeriod": "P1W",
                    "billingCycleCount": 1,
                    "recurrenceMode": 2
                },
                {
                    "formattedPrice": "HK$469.00",
                    "priceAmountMicros": 469000000,
                    "priceCurrencyCode": "HKD",
                    "billingPeriod": "P1Y",
                    "billingCycleCount": 0,
                    "recurrenceMode": 1
                }
            ]
        }
    }
]

上文有讲到,Offer 是包含价格阶段 PricingPhases 这个概念的,这个概念就体现在以上 Json 中,当中就能够解读出以下产品信息:

  • 该产品包含一个 Id 为 yearly 的 basePlan,总共包含两个 Offer
  • offerToken 用于仅有标识每一个 Offer,具有仅有性
  • billingPeriod 用于表明计费周期,以 ISO 8601 格局来指定。例如,P1W 表明一周,P1Y 表明一年,P1M3D 表明一个月加三天
  • billingCycleCount 用于表明计费周期的周期数。例如,以上的第二个 Offer 的第一个 PricingPhases,就表明答使用户免费试用一周;假设 billingCycleCount 是 2,就表明答使用户免费试用两周
  • recurrenceMode 用于表明价格阶段的重复形式,当值为 1 或 3 时,billingCycleCount 值都会是 0
    • 值为 1 就表明将在无限的计费周期内重复进行,除非用户主动取消
    • 值为 2 就表明将在 billingCycleCount 指定的周期内重复扣费
    • 值为 3 表明是一次性收费,不会重复
  • 第一个 Offer 的 offerId 为 null,阐明此 Offer 不包含实践的优惠战略,代表的其实是 BasePlan 的原价,所以 pricingPhaseList 也会只需一个值。且因为 billingPeriod 是 P1Y,阐明关联的 BasePlan 的付费周期是一年。选中此 Offer 后用户就需求直接付 HK$469.00 的原价来进行订阅
  • 第二个 Offer 的 offerId 不为 null,阐明此 Offer 包含真实的优惠战略,所以 pricingPhaseList 的大小就会大于一。该 Offer 答使用户先免费试用一周,然后再和第一个 Offer 同样的价格和周期来进行订阅

所以说,想要解读出 BasePlan 的定价战略和 Offer 的优惠战略,就需求结合一切字段来进行解析。首先,不管咱们在创立 BasePlan 时有没有为其指定优惠战略,Google Play 都会将 BasePlan 的原价视为一个 Offer 并回来,这种状况下 Offer 也只会有一个定价阶段。而对于真实的优惠战略,其 offerId 是有必要设定的,自然也就不会为 null,也会有最多三个定价阶段。咱们要区分出 “虚伪的” Offer 和 “真实的” Offer。然后,再经过 pricingPhases 来解分出 BasePlan 的订阅周期和价格、Offer 的优惠战略、Offer 的价格阶段详细是怎么设定的。这样咱们才干向用户完整展现整个产品的价格信息

launchBillingFlow

launchBillingFlow 用于调起付出弹窗建议付出操作,依据产品类型,其调用方法分为两种

假设要购买的是一次性产品,付出参数仅需求 ProductDetails 即可

private suspend fun launchBilling(
    activity: Activity,
    billingClient: BillingClient,
    productDetails: ProductDetails
): BillingResult {
    return withContext(context = Dispatchers.Main.immediate) {
        val productDetailsParams = BillingFlowParams
            .ProductDetailsParams
            .newBuilder()
            .setProductDetails(productDetails)
            .build()
        val billingFlowParams = BillingFlowParams
            .newBuilder()
            .setProductDetailsParamsList(listOf(productDetailsParams))
            .build()
        billingClient.launchBillingFlow(activity, billingFlowParams)
    }
}

假设要购买的是订阅型产品,则需求一同传递 ProductDetails 和 offerToken

因为一个订阅型产品可能一同包含多个 BasePlan 和多个 Offer,每个 Offer 的优惠战略又各不相同。因此 App 在建议付出操作时,就需求经过 offerToken 来标明用户想要购买的到底是哪个 BasePlan,选中的又是哪个 Offer。而因为 Google Play 也会将 BasePlan 的原价视为一个 Offer 并回来,所以咱们是能够自主挑选要不要让用户享受优惠的,自由度仍是比较高的

private suspend fun launchBilling(
    activity: Activity,
    billingClient: BillingClient,
    productDetails: ProductDetails,
    offerToken: String
): BillingResult {
    return withContext(context = Dispatchers.Main.immediate) {
        val productDetailsParams = BillingFlowParams
            .ProductDetailsParams
            .newBuilder()
            .setProductDetails(productDetails)
            .setOfferToken(offerToken)
            .build()
        val billingFlowParams = BillingFlowParams
            .newBuilder()
            .setProductDetailsParamsList(listOf(productDetailsParams))
            .build()
        billingClient.launchBillingFlow(activity, billingFlowParams)
    }
}

之后,咱们在 PurchasesUpdatedListener 回调里来获取用户的付出成果

假设用户已付出成功,Purchase 就包含了此笔订单的详细信息,包含 ProductId、OrderId、Quantity、PurchaseTime 等

private val purchasesUpdatedListener =
    PurchasesUpdatedListener { billingResult: BillingResult, purchases: List<Purchase>? ->
        when (billingResult.responseCode) {
            BillingClient.BillingResponseCode.OK -> {
                if (!purchases.isNullOrEmpty()) {
                    purchases.forEach {
                        when (it.purchaseState) {
                            Purchase.PurchaseState.PURCHASED -> {
                                //用户付出成功
                            }
                            Purchase.PurchaseState.PENDING -> {
                                //用户仅是预创立了订单,还未真正付款
                            }
                            Purchase.PurchaseState.UNSPECIFIED_STATE -> {
                                //未知
                            }
                        }
                    }
                }
            }
            BillingClient.BillingResponseCode.USER_CANCELED -> {
                //用户取消付出
            }
            else -> {
            }
        }
    }

acknowledgePurchase

用户付出成功后,就需求对订单进行承认了,否则 Google Play 会在限制时间内退款给用户

private suspend fun acknowledgePurchase(
    billingClient: BillingClient,
    purchase: Purchase
): Boolean {
    return withContext(context = Dispatchers.Default) {
        if (purchase.purchaseState != Purchase.PurchaseState.PURCHASED) {
            return@withContext false
        }
        if (purchase.isAcknowledged) {
            return@withContext true
        }
        if (!billingClient.isReady) {
            return@withContext false
        }
        val acknowledgePurchaseParams = AcknowledgePurchaseParams
            .newBuilder()
            .setPurchaseToken(purchase.purchaseToken)
            .build()
        val acknowledgePurchase = billingClient.acknowledgePurchase(acknowledgePurchaseParams)
        acknowledgePurchase.responseCode == BillingClient.BillingResponseCode.OK
    }
}

consumePurchase

假设用户购买的是耗费型的一次性产品,那么就需求依据实践事务择机对订单履行耗费操作了

private suspend fun consumePurchase(
    billingClient: BillingClient,
    purchase: Purchase
): Boolean {
    return withContext(context = Dispatchers.Default) {
        if (purchase.purchaseState != Purchase.PurchaseState.PURCHASED) {
            return@withContext false
        }
        if (!billingClient.isReady) {
            return@withContext false
        }
        val consumeParams = ConsumeParams
            .newBuilder()
            .setPurchaseToken(purchase.purchaseToken)
            .build()
        val consumeResult = billingClient.consumePurchase(consumeParams)
        consumeResult.billingResult.responseCode == BillingClient.BillingResponseCode.OK
    }
}

五、鉴权

当用户购买产品后,就需求来考虑怎么对用户进行鉴权了。假设鉴权失败或许是鉴权错了,不仅会给用户带来不良体会,引来用户投诉,也有可能会给项目带来不可估量的资金损失

依照一般状况,App 在供用户运用时,App 都会为当时用户创立一个自己账户体系下的用户身份,咱们能够称之为 appUser。当用户购买产品后,这笔订单也会和当时设备付款的 Google Play 账号绑定在一同,咱们能够称之为 gpUser

如此一来,这笔订单就会和两个不同视点下的用户发生关联。这也就连锁带来一个问题:产品代表的权益应该挂载在哪个用户的名下?appUser 仍是 gpUser ?

这两个挑选都各有优缺陷

挂载在 appUser 名下:

  • 长处:用户权益清晰明确,能够精准隔离用户的权益状况
  • 缺陷:在国外,以游客身份来购买虚拟产品是很常见的状况,假设 App 只答应正式用户(绑定了邮箱或许电话号码)才干购买产品的话,很有可能会流失大部分的潜在付费用户。因此,假设 appUser 是游客的话,当用户卸载运用、替换或许重置设备后,就有可能导致已付费的用户再也找不回这笔订单了

挂载在 gpUser 名下:

  • 长处:即运用户卸载运用、替换或许重置设备,只需当时设备登录的便是付款时的 Google Play 账号,App 都能经过 Billing SDK 的 queryPurchasesAsync 方法重新找回该账号名下一切的订单信息,不用忧虑呈现权益丢掉的状况。同个 Google Play 账号在不同设备上也能一同享有 App 的权益,用户体会是最好的
  • 缺陷:App 是无法拿到 gpUser 的仅有身份标识的,容易呈现账号倒卖的状况,多个用户经过同享同一个 Google Play 账号来一同享有同一笔订单的权益

所以说,App 需求依据自己的事务类型和用户特点,来决定是否要答应游客也能进行购买操作,用户应该以哪种维度来进行身份鉴权,当发现同笔订单在多台设备上生效时,又应该怎么防止财物损失

六、最终

本文首要是以移动端的视点来进行阐述,虽然 Google Play 结算体系也答应在没有 App 后端服务参加的状况下就直接完成整个付出流程并完成用户鉴权,但为了安全性考虑,最好仍是需求将订单信息同步保存到服务端,并由服务端对订单进行校验后再决定是否要下发权益。此外,用户是能够在不经过 App 的状况下,直接从 Google Play 中取消订阅或许康复订阅,App 无法实时获知该笔订单的状况改变,此刻 Google Play 也只会经过 开发者实时告诉 将这种改变告诉给服务端,这种状况下也需求服务端的参加才干完整记录下用户的整个付费状况改变