作者:业志陈
现如今,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 中创建的商品都属于虚拟商品,每个商品代表的都是 App 给用户提供的一种权益,而每个商品都包含一个唯一标识,也即 ProductId,我们在业务上就需要根据 ProductId 的命名规则来定义商品所代表的具体权益类型
每个商品又可以分为两种类型:
BillingClient.ProductType.INAPPBillingClient.ProductType.SUBS当用户购买了商品后,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 对应起来
本文所有的代码示例使用的均是当前 Google Play 结算系统在 Android 端最新版本的 SDK,且是协程版本,读者需要对协程有一定了解
dependencies {
val billingVersion = "6.0.1"
implementation("com.android.billingclient:billing-ktx:$billingVersion")
}
整个支付流程可以总结为以下几点:
BillingClient 是 Google Play 结算库与 App 进行通信的主接口,App 在执行任何与支付相关的操作之前,都需要先通过 BillingClient 和 Google Play 建立连接。在初始化 BillingClient 实例时,需要同时绑定 PurchasesUpdatedListener,以便得到支付结果的回调通知。也正因为如此,App 在同一时间段最多只能保持一个活跃的 BillingClient 连接,以免同一个支付事件同时回调多个 PurchasesUpdatedListener
private val purchasesUpdatedListener =
PurchasesUpdatedListener { billingResult: BillingResult, purchases: List? ->
}
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 需要两个查询参数: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,
productType: String
): List? {
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 对应的是一次性商品的详情,数据结构比较简单,主要就是价格信息了
{
"priceAmountMicros": 548000000,
"priceCurrencyCode": "HKD",
"formattedPrice": "HK$548.00"
}
需要注意,Google Play 返回的价格信息都是做了本地化处理的,会自动根据当前设备的 Google Play 账号所对应的国家地区来返回详情,所以商品的价格货币代号 priceCurrencyCode 和格式化好的商品价格 formattedPrice 都会因实际情况而变化
subscriptionOfferDetails 对应的是订阅型商品的详情
由于订阅型商品是可以包含多个 BasePlan 的,每个 BasePlan 又可以包含多个 Offer,所以 subscriptionOfferDetails 字段在 ProductDetails 中对应的数据类型是 List。每个 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 中,当中就可以解读出以下商品信息:
所以说,想要解读出 BasePlan 的定价策略和 Offer 的优惠策略,就需要结合所有字段来进行解析。首先,不管我们在创建 BasePlan 时有没有为其指定优惠策略,Google Play 都会将 BasePlan 的原价视为一个 Offer 并返回,这种情况下 Offer 也只会有一个定价阶段。而对于真实的优惠策略,其 offerId 是必须设定的,自然也就不会为 null,也会有最多三个定价阶段。我们要区分出 “虚假的” Offer 和 "真实的” Offer。然后,再通过 pricingPhases 来解析出 BasePlan 的订阅周期和价格、Offer 的优惠策略、Offer 的价格阶段具体是如何设定的。这样我们才能向用户完整展示整个商品的价格信息
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? ->
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 -> {
}
}
}
用户支付成功后,就需要对订单进行确认了,否则 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
}
}
如果用户购买的是消耗型的一次性商品,那么就需要根据实际业务择机对订单执行消耗操作了
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 名下:
挂载在 gpUser 名下:
queryPurchasesAsync 方法重新找回该账号名下所有的订单信息,不用担心出现权益丢失的情况。同个 Google Play 账号在不同设备上也能共同享有 App 的权益,用户体验是最好的所以说,App 需要根据自己的业务类型和用户属性,来决定是否要允许游客也能进行购买操作,用户应该以哪种维度来进行身份鉴权,当发现同笔订单在多台设备上生效时,又应该如何避免资产损失
本文主要是以移动端的角度来进行阐述,虽然 Google Play 结算系统也允许在没有 App 后端服务参与的情况下就直接完成整个支付流程并完成用户鉴权,但为了安全性考虑,最好还是需要将订单信息同步保存到服务端,并由服务端对订单进行校验后再决定是否要下发权益。此外,用户是可以在不经过 App 的情况下,直接从 Google Play 中取消订阅或者恢复订阅,App 无法实时获知该笔订单的状态变化,此时 Google Play 也只会通过 开发者实时通知 将这种变化通知给服务端,这种情况下也需要服务端的参与才能完整记录下用户的整个付费状态变化
Android 性能优化篇:https://qr18.cn/FVlo89
Android 车载篇:https://qr18.cn/F05ZCM
Android 逆向安全学习笔记:https://qr18.cn/CQ5TcL
Android Framework底层原理篇:https://qr18.cn/AQpN4J
Android 音视频篇:https://qr18.cn/Ei3VPD
Jetpack全家桶篇(内含Compose):https://qr18.cn/A0gajp
Kotlin 篇:https://qr18.cn/CdjtAF
Gradle 篇:https://qr18.cn/DzrmMB
OkHttp 源码解析笔记:https://qr18.cn/Cw0pBD
Flutter 篇:https://qr18.cn/DIvKma
Android 八大知识体:https://qr18.cn/CyxarU
Android 核心笔记:https://qr21.cn/CaZQLo
Android 往年面试题锦:https://qr18.cn/CKV8OZ
2023年最新Android 面试题集:https://qr18.cn/CgxrRy
Android 车载开发岗位面试习题:https://qr18.cn/FTlyCJ
音视频面试题锦:https://qr18.cn/AcV6Ap