Привет всем, я Влад, core разработчик Android SDK для обработки платежей в мобильных приложениях в Adapty
Это вторая статья из нашего цикла о реализации покупок на Android. В первой статье мы рассказывали о том, как создавать продукты в Google Play Console, сконфигурировать подписки и получить список продуктов в приложении. Cоветую познакомиться и с остальными:
Android in-app purchases, часть 1: конфигурация и добавление в проект.
Android in-app purchases, часть 2: инициализация и обработка покупок. — Вы тут
Android in-app purchases, часть 3: получение активных покупок и смена подписки.
Android in-app purchases, часть 5: серверная валидация покупок.
В прошлой части мы создали класс-обертку для работы с Billing Library:
Класс-обертка для работы с Billing Library
import android.content.Context import com.android.billingclient.api.* class BillingClientWrapper(context: Context) : PurchasesUpdatedListener { interface OnQueryProductsListener { fun onSuccess(products: List<SkuDetails>) fun onFailure(error: Error) } class Error(val responseCode: Int, val debugMessage: String) private val billingClient = BillingClient .newBuilder(context) .enablePendingPurchases() .setListener(this) .build() fun queryProducts(listener: OnQueryProductsListener) { val skusList = listOf("premium_sub_month", "premium_sub_year", "some_inapp") queryProductsForType( skusList, BillingClient.SkuType.SUBS ) { billingResult, skuDetailsList -> if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) { val products = skuDetailsList ?: mutableListOf() queryProductsForType( skusList, BillingClient.SkuType.INAPP ) { billingResult, skuDetailsList -> if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) { products.addAll(skuDetailsList ?: listOf()) listener.onSuccess(products) } else { listener.onFailure( Error(billingResult.responseCode, billingResult.debugMessage) ) } } } else { listener.onFailure( Error(billingResult.responseCode, billingResult.debugMessage) ) } } } private fun queryProductsForType( skusList: List<String>, @BillingClient.SkuType type: String, listener: SkuDetailsResponseListener ) { onConnected { billingClient.querySkuDetailsAsync( SkuDetailsParams.newBuilder().setSkusList(skusList).setType(type).build(), listener ) } } private fun onConnected(block: () -> Unit) { billingClient.startConnection(object : BillingClientStateListener { override fun onBillingSetupFinished(billingResult: BillingResult) { block() } override fun onBillingServiceDisconnected() { // Try to restart the connection on the next request to // Google Play by calling the startConnection() method. } }) } override fun onPurchasesUpdated(billingResult: BillingResult, purchaseList: MutableList<Purchase>?) { // here come callbacks about new purchases } }
Перейдем дальше к реализации покупки и дополним наш класс.
Создание экрана с подписками
В любом приложении, которое использует встроенные покупки, присутствует пейволл. Есть требования от Google, которые определяют минимальный набор необходимых элементов и поясняющих текстов для подобных экранов. Если коротко, на пейволле вы должны прозрачно показать пользователю условия, цену и длительность подписки и обязательна ли подписка для использования приложения. Нельзя принуждать пользователя к дополнительным действиям ради получения информации об условиях подписки.
На данном этапе для примера мы сделали упрощённый вариант пейволла:

Итак, на нашем пейволле располагаются следующие элементы:
Заголовок.
Набор кнопок для инициализации процесса покупки. На них указаны основные свойства продуктов: название и цена в местной валюте.
Поясняющий текст.
Кнопка восстановления прошлых покупок. Этот элемент необходим для всех приложений, в которых используются подписки либо non-consumable покупки.
Доработка кода для отображения информации о продуктах
В нашем примере четыре продукта:
две автовозобновляемые подписки ("premium_sub_month" и "premium_sub_year");
продукт, который нельзя купить повторно, — non-consumable (“unlock_feature”);
продукт, который можно покупать много раз, — consumable (“coin_pack_large”).
Для упрощения примера будем использовать Activity, в которую заинжектим BillingClientWrapper из предыдущей статьи, и layout с жестко заданным количеством кнопок для покупки.
class PaywallActivity: BaseActivity() { @Inject lateinit var billingClientWrapper: BillingClientWrapper override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_paywall) displayProducts() //to be declared below } }
Для удобства добавим словарь, где ключом является sku продукта, а значением - соответствующая кнопка на экране.
private val purchaseButtonsMap: Map<String, Button> by lazy(LazyThreadSafetyMode.NONE) { mapOf( "premium_sub_month" to monthlySubButton, "premium_sub_year" to yearlySubButton, "coin_pack_large" to coinPackLargeButton, "unlock_feature" to unlockFeatureButton, ) }
Объявим метод для отображения продуктов в UI, опираясь на логику из предыдущей статьи:
private fun displayProducts() { billingClientWrapper.queryProducts(object : BillingClientWrapper.OnQueryProductsListener { override fun onSuccess(products: List<SkuDetails>) { products.forEach { product -> purchaseButtonsMap[product.sku]?.apply { text = "${product.description} for ${product.price}" setOnClickListener { billingClientWrapper.purchase(this@PaywallActivity, product) //will be declared below } } } } override fun onFailure(error: BillingClientWrapper.Error) { //handle error } }) }
product.price — это уже отформатированная строка с указанием местной валюты для данного аккаунта, здесь никакое дополнительное форматирование не нужно; остальные поля в объекте класса SkuDetails также приходят уже с учетом локализации.
Запуск процесса покупки
Для проведения покупки необходимо вызвать метод launchBillingFlow() из главного потока приложения.
Добавим для этого метод purchase() в BillingClientWrapper:
fun purchase(activity: Activity, product: SkuDetails) { onConnected { activity.runOnUiThread { billingClient.launchBillingFlow( activity, BillingFlowParams.newBuilder().setSkuDetails(product).build() ) } } }
У метода launchBillingFlow() нет колбэка, ответ вернется в метод onPurchasesUpdated(). Помните, мы в прошлой статье его объявили, но оставили на потом? Вот сейчас он нам понадобится.
Метод onPurchasesUpdated() вызывается при каком-либо результате после взаимодействия пользователя с диалогом покупки. Это может быть успешная покупка, отмена покупки (пользователь закрыл диалог, в этом случае приходит код BillingResponseCode.USER_CANCELED) или же какая-то другая ошибка.
По аналогии с интерфейсом OnQueryProductsListener из предыдущей статьи, объявим в классе BillingClientWrapper интерфейс OnPurchaseListener, с помощью которого будем получать либо покупку (объект класса Purchase — о кейсе, когда он может быть null даже в случае успеха, расскажем в следующей статье), либо ошибку, которую мы также объявляли в предыдущей статье:
interface OnPurchaseListener { fun onPurchaseSuccess(purchase: Purchase?) fun onPurchaseFailure(error: Error) } var onPurchaseListener: OnPurchaseListener? = null
И реализуем его в PaywallActivity:
class PaywallActivity: BaseActivity(), BillingClientWrapper.OnPurchaseListener { @Inject lateinit var billingClientWrapper: BillingClientWrapper private val purchaseButtonsMap: Map<String, Button> by lazy(LazyThreadSafetyMode.NONE) { mapOf( "premium_sub_month" to monthlySubButton, "premium_sub_year" to yearlySubButton, "coin_pack_large" to coinPackLargeButton, "unlock_feature" to unlockFeatureButton, ) } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_paywall) billingClientWrapper.onPurchaseListener = this displayProducts() } override fun onPurchaseSuccess(purchase: Purchase?) { //handle successful purchase } override fun onPurchaseFailure(error: BillingClientWrapper.Error) { //handle error or user cancelation } private fun displayProducts() { billingClientWrapper.queryProducts(object : BillingClientWrapper.OnQueryProductsListener { override fun onSuccess(products: List<SkuDetails>) { products.forEach { product -> purchaseButtonsMap[product.sku]?.apply { text = "${product.description} for ${product.price}" setOnClickListener { billingClientWrapper.purchase(this@PaywallActivity, product) } } } } override fun onFailure(error: BillingClientWrapper.Error) { //handle error } }) } }
Добавим логику в onPurchaseUpdated():
override fun onPurchasesUpdated( billingResult: BillingResult, purchaseList: MutableList<Purchase>? ) { when (billingResult.responseCode) { BillingClient.BillingResponseCode.OK -> { if (purchaseList == null) { //to be discussed in the next article onPurchaseListener?.onPurchaseSuccess(null) return } purchaseList.forEach(::processPurchase) //to be declared below } else -> { //error occured or user canceled onPurchaseListener?.onPurchaseFailure( BillingClientWrapper.Error( billingResult.responseCode, billingResult.debugMessage ) ) } } }
Если purchaseList не пустой, для начала для каждой покупки делаем проверку, что ее purchasedState равен PurchaseState.PURCHASED, потому что покупки также могут быть отложенными, и в этом случае флоу на данном этапе прекращается. Далее, согласно документации, нужно сделать серверную верификацию покупки, о ней мы расскажем в следующих статьях. После этого надо предоставить пользователю доступ к контенту и сообщить об этом в Google. Если не сообщить, то через три дня покупка автоматически отменится. Интересно, что это характерно только для Google Play, в то время как на iOS такого нет. Сообщить о предоставлении доступа к контенту можно двумя способами:
с помощью acknowledgePurchase() со стороны клиента;
либо Purchases.Products.Acknowledge/Purchases.Subscriptions.Acknowledge со стороны бэка.
В случае с consumable-продуктом вместо него нужно вызвать метод consumeAsync(), который под капотом делает acknowledge, а заодно дает возможность покупать этот продукт повторно. Это можно сделать только с помощью Billing Library: Google Play Developer API почему-то не предоставляет возможность делать это на бэкенде. Любопытно, что, в отличие от Google Play, в App Store и в AppGallery свойство consumable за продуктом жестко определено на уровне App Store Connect и AppGallery Connect соответственно. Справедливости ради, замечу, что консьюмить такие продукты из AppGallery нужно всё-таки явно.
Напишем методы для acknowledge и consume, а также две версии метода processPurchase() — в случае, когда у нас есть свой бэкенд и когда его нет.
private fun acknowledgePurchase( purchase: Purchase, callback: AcknowledgePurchaseResponseListener ) { onConnected { billingClient.acknowledgePurchase( AcknowledgePurchaseParams.newBuilder().setPurchaseToken(purchase.purchaseToken) .build(), callback::onAcknowledgePurchaseResponse ) } } private fun consumePurchase(purchase: Purchase, callback: ConsumeResponseListener) { onConnected { billingClient.consumeAsync( ConsumeParams.newBuilder().setPurchaseToken(purchase.purchaseToken).build() ) { billingResult, purchaseToken -> callback.onConsumeResponse(billingResult, purchaseToken) } } }
Без серверной верификации:
private fun processPurchase(purchase: Purchase) { if (purchase.purchaseState == Purchase.PurchaseState.PURCHASED) { onPurchaseListener?.onPurchaseSuccess(purchase) if (purchase.skus.firstOrNull() == "coin_pack_large") { //consuming our only consumable product consumePurchase(purchase) { billingResult, purchaseToken -> if (billingResult.responseCode != BillingClient.BillingResponseCode.OK) { //implement retry logic or try to consume again in onResume() } } } else if (!purchase.isAcknowledged) { acknowledgePurchase(purchase) { billingResult -> if (billingResult.responseCode != BillingClient.BillingResponseCode.OK) { //implement retry logic or try to acknowledge again in onResume() } } } } }
С серверной верификацией:
private fun processPurchase(purchase: Purchase) { if (purchase.purchaseState == Purchase.PurchaseState.PURCHASED) { api.verifyPurchase(purchase.purchaseToken) { error -> if (error != null) { onPurchaseListener?.onPurchaseSuccess(purchase) if (purchase.skus.firstOrNull() == "coin_pack_large") { //consuming our only consumable product billingClient.consumeAsync( ConsumeParams.newBuilder().setPurchaseToken(purchase.purchaseToken) .build() ) { billingResult, purchaseToken -> if (billingResult.responseCode != BillingClient.BillingResponseCode.OK) { //implement retry logic or try to consume again in onResume() } } } } else { //handle verification error } } } }
Подробнее о серверной верификации покупок мы расскажем в одной из следующих статей.
Во втором примере acknowledge, конечно, тоже можно было сделать на клиенте, но так как здесь у нас есть бэкенд, всё, что можно сделать на бэке, лучше отдать бэку. Что касается ошибок на acknowledge и consume, их нельзя игнорировать, потому что если ни одно из этих действий не произойдет в течение трёх дней после того, как покупка получила статус PurchaseState.PURCHASED, она отменится, а пользователю вернут средства. Поэтому, если мы не можем это сделать на бэкенде, и даже после нескольких повторных попыток всё еще получаем ошибку, самый надежный способ — получать текущие покупки пользователя в каком-нибудь методе жизненного цикла, например в onStart() или onResume(), и пытаться повторить в надежде, что пользователь в течение трёх дней зайдет в наше приложение при работающем интернете :).
Таким образом, текущая версия класса BillingClientWrapper будет выглядеть так:
import android.app.Activity import android.content.Context import com.android.billingclient.api.* class BillingClientWrapper(context: Context, private val api: Api) : PurchasesUpdatedListener { interface OnQueryProductsListener { fun onSuccess(products: List<SkuDetails>) fun onFailure(error: Error) } interface OnPurchaseListener { fun onPurchaseSuccess(purchase: Purchase?) fun onPurchaseFailure(error: Error) } var onPurchaseListener: OnPurchaseListener? = null class Error(val responseCode: Int, val debugMessage: String) private val billingClient = BillingClient .newBuilder(context) .enablePendingPurchases() .setListener(this) .build() fun purchase(activity: Activity, product: SkuDetails) { onConnected { activity.runOnUiThread { billingClient.launchBillingFlow( activity, BillingFlowParams.newBuilder().setSkuDetails(product).build() ) } } } fun queryProducts(listener: OnQueryProductsListener) { val skusList = listOf("premium_sub_month", "premium_sub_year", "coin_pack_large", "unlock_feature") queryProductsForType( skusList, BillingClient.SkuType.SUBS ) { billingResult, skuDetailsList -> if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) { val products = skuDetailsList ?: mutableListOf() queryProductsForType( skusList, BillingClient.SkuType.INAPP ) { billingResult, skuDetailsList -> if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) { products.addAll(skuDetailsList ?: listOf()) listener.onSuccess(products) } else { listener.onFailure( Error(billingResult.responseCode, billingResult.debugMessage) ) } } } else { listener.onFailure( Error(billingResult.responseCode, billingResult.debugMessage) ) } } } private fun queryProductsForType( skusList: List<String>, @BillingClient.SkuType type: String, listener: SkuDetailsResponseListener ) { onConnected { billingClient.querySkuDetailsAsync( SkuDetailsParams.newBuilder().setSkusList(skusList).setType(type).build(), listener ) } } private fun onConnected(block: () -> Unit) { billingClient.startConnection(object : BillingClientStateListener { override fun onBillingSetupFinished(billingResult: BillingResult) { block() } override fun onBillingServiceDisconnected() { // Try to restart the connection on the next request to // Google Play by calling the startConnection() method. } }) } override fun onPurchasesUpdated( billingResult: BillingResult, purchaseList: MutableList<Purchase>? ) { when (billingResult.responseCode) { BillingClient.BillingResponseCode.OK -> { if (purchaseList == null) { //to be discussed in the next article onPurchaseListener?.onPurchaseSuccess(null) return } purchaseList.forEach(::processPurchase) } else -> { //error occured or user canceled onPurchaseListener?.onPurchaseFailure( BillingClientWrapper.Error( billingResult.responseCode, billingResult.debugMessage ) ) } } } private fun processPurchase(purchase: Purchase) { if (purchase.purchaseState == Purchase.PurchaseState.PURCHASED) { api.verifyPurchase(purchase.purchaseToken) { error -> if (error != null) { onPurchaseListener?.onPurchaseSuccess(purchase) if (purchase.skus.firstOrNull() == "coin_pack_large") { //consuming our only consumable product billingClient.consumeAsync( ConsumeParams.newBuilder().setPurchaseToken(purchase.purchaseToken) .build() ) { billingResult, purchaseToken -> if (billingResult.responseCode != BillingClient.BillingResponseCode.OK) { //implement retry logic or try to consume again in onResume() } } } } else { //handle verification error } } } } private fun acknowledgePurchase( purchase: Purchase, callback: AcknowledgePurchaseResponseListener ) { onConnected { billingClient.acknowledgePurchase( AcknowledgePurchaseParams.newBuilder().setPurchaseToken(purchase.purchaseToken) .build(), callback::onAcknowledgePurchaseResponse ) } } private fun consumePurchase(purchase: Purchase, callback: ConsumeResponseListener) { onConnected { billingClient.consumeAsync( ConsumeParams.newBuilder().setPurchaseToken(purchase.purchaseToken).build() ) { billingResult, purchaseToken -> callback.onConsumeResponse(billingResult, purchaseToken) } } } }
Вы могли задаться вопросом, почему кнопки активны для всех продуктов, независимо от того, покупал их пользователь или нет. Или что будет, если купить обе подписки: заменит ли вторая первую или они обе будут сосуществовать. Всё это в следующих статьях :)
Про Adapty
Cоветую познакомиться с Adapty — SDK для встроенных покупок.
Он не только упрощает работу по добавлению покупок:
Встроенная аналитика позволяет быстро понять основные метрики приложения.
Когортный анализ отвечает на вопрос, как быстро сходится экономика.
А/Б тесты увеличивают выручку приложения.
Интеграции с внешними системами позволяют отправлять транзакции в сервисы атрибуции и продуктовой аналитики.
Промо-кампании уменьшают отток аудитории.
Open source SDK позволяет интегрировать подписки в приложение за несколько часов.
Серверная валидация и API для работы с другими платформами.
Познакомьтесь подробнее с этими возможностями, чтобы быстрее внедрить подписки в своё приложение и улучшить конверсии.
