RxBilling: бібліотека для роботи з білінгом на Android

Всім привіт! Мене звати Діма Остаповець, я Android інженер проекту BetterMe, компанія Genesis.

Основним способом монетизації наших проектів є підписки. Все виглядає просто, якщо у вашому додатку 1-2 екрани з пропозицією підписатись, і 1-2 точки доступу в платний контент. Проте ця простота триває до моменту, коли вам потрібно додати перевірку статусу підписки мало не на кожен клік юзера, відправити аналітику про відмінені/ неуспішні спроби підписатись і запустити декілька A/B тестів.

У цій статті я поділюсь нашим досвідом роботи з такими інструментами, як Google Play Billing Library і IInappBillingService, а також розповім про нашу невелику бібліотеку для роботи з білінгом.

Довгий час ми використовували IInappBillingService, обгорнувши його в простий Helper — клас (в принципі, думаю, як і всі). Реалізація виглядала, м’яко кажучи, не дуже.

Порівняно недавно Google випустив бібліотеку для роботи з покупками — Play Billing Library, яка обіцяла вирішити мало не всі муки при роботі з IInappBillingService.

Після випробувань бібліотеки, вивчення коду та issues на GitHub, нас не влаштовували декілька моментів:

  • підключення: перед кожною операцією необхідно перевіряти стейт BillingClient-а. Якщо він ще не підключений, то зачекати і покласти нашу операцію в «чергу». Хлопці з Google рекомендують робити це якось так:
private void executeServiceRequest(Runnable runnable) {
   if (mIsServiceConnected) {
       runnable.run();
   } else {
       // If billing service was disconnected, we try to reconnect 1 time.
       // (feel free to introduce your retry policy here).
       startServiceConnection(runnable);
   }
}
  • занадто узагальнений лісенер — onPurchasesUpdated, який ловить апдейти від будь-яких операцій. Якщо вам не потрібна чітка аналітика, наприклад, покупка якого саме продукту була відмінена користувачем, то цей колбек вам, можливо, підійде;
  • той же onPurchasesUpdated може викликатись по два рази у відповідь на одну операцію. Якщо ваші аналітики спробують проаналізувати івенти типу success і cancel, цілком можливо, що вони неприємно здивуються. Лінк на баг: github.com/...​id-play-billing/issues/83.

Типова логіка перевірки статусу підписки (чи з BillingClient, чи з IInappBillingService) зазвичай виглядає приблизно так:

override fun onStart() {
   super.onStart()
   billingManager.connectBilling(this)
}

override fun onStop() {
   billingManager.disconnectBilling()
   super.onStop()
}

override fun onConnected() {
   billingManager.getPurchases()
}

Досить незручно, якщо врахувати близько 10 екранів з такими перевірками. До цього додадуться операції типу getHistory() і getDetails() зі своїми колбеками, і все це потрібно якось змерджити. До того ж, трохи проблематично сховати логіку перевірки, скажімо, у UseCase, і при цьому прив’язати connect / disconnect до життєвого циклу UI.

Тому ми вирішили написати невеликий врапер над BillingClient і IInappBillingService.

Основні вимоги до нього:

  • connect / disconnect відповідно до життєвого циклу LifecycleOwner-а;
  • перевірка статусу BillingClient перед кожною операцією (приконектитись, зачекати і виконати операцію);
  • шарінг уже підключеного BillingClient всім підписникам;
  • repeat / retry логіка;
  • чітке розмежування івентів покупки: який конкретно продукт, результат операції.

RxBilling

Для контролю над підключенням (та й для всієї бібліотеки) ми скористались RxJava (зараз не модно писати про Rx, але все ж таки...)

Для початку зробимо невеликий інтерфейс, який собою представляє всі (або майже всі) функції BillingClient, обгорнуті в Observable:

interface RxBilling : Connectable<BillingClient> {

   override fun connect(): Flowable<BillingClient>

   fun observeUpdates(): Flowable<PurchasesUpdate>

   fun getPurchases(): Single<List<Purchase>>

   fun getSubscriptions(): Single<List<Purchase>>

   fun getPurchaseHistory(): Single<List<Purchase>>

   fun getSubscriptionHistory(): Single<List<Purchase>>

   fun getPurchaseSkuDetails(ids: List<String>): Single<List<SkuDetails>>

   fun getSubscriptionSkuDetails(ids: List<String>): Single<List<SkuDetails>>

   fun launchFlow(activity: Activity, params: BillingFlowParams): Completable

   fun consumeProduct(purchaseToken: String): Completable
}

Для початку поговоримо про fun connect(): Flowable<BillingClient>

Задумка така: клієнт викликає connect(), підписується на Flowable і тримає підключення BillingClient до виклику disposable.dispose().

Для підключення / відключення BillingClient відповідно до життєвого циклу Activity / Fragment etc. створюємо BillingConnectionManager, який реалізує LifecycleObserver і викликає subscribe / dispose в onStart / onStop:

@OnLifecycleEvent(Lifecycle.Event.ON_START)
fun connect() {
     disposable = connectable.connect()
           .subscribe()
}

@OnLifecycleEvent(Lifecycle.Event.ON_STOP)
fun disconnect() {
   disposable?.dispose()
}

Ну і реєструємо наш BillingConnectionManager в LifecycleOwner:

lifecycle.addObserver(billingConnectionManager)

Перед виконанням будь-якої операції ми повинні впевнитись у правильному стейті BillingClient. Якщо він ще не підключений — підключитись і після цього виконати задуману операцію. Як це виглядає в коді:

private fun getBoughtItems(type: String): Single<List<Purchase>> {
   return connectionFlowable
           .flatMap {
               val purchasesResult = it.queryPurchases(type)
               return@flatMap if (isSuccess(purchasesResult.responseCode)) {
                   Flowable.just(purchasesResult.purchasesList.orEmpty())
               } else {
                   Flowable.error<List<Purchase>>(BillingException.fromCode(purchasesResult.responseCode))
               }
           }.firstOrError()
}

Важливий нюанс: всі методи BillingClient повинні викликатись із UI потоку. А так як BillingClient може використовуватись, наприклад, в Repository (функції якого часто виносяться в бекграунд потоки), то краще впевнитись у «правильному» треді:

private val connectionFlowable =
       Completable.complete()
               .observeOn(AndroidSchedulers.mainThread()) // just to be sure billing client is called from main thread
               .andThen(billingFactory.createBillingFlowable(updatedListener))

Для створення BillingClient реалізуєм BillingClientFactory, задачами якої будуть:

  • створити Flowable;
  • закешувати BillingClient і віддавати той самий об’єкт кожному наступному клієнту;
  • реалізувати логіку repeat / retry;
  • завершити підключення після того, як відписався останній клієнт.

Створення Flowable виглядає так:

fun createBillingFlowable(listener: PurchasesUpdatedListener): Flowable<BillingClient> {
   val flowable = Flowable.create<BillingClient>({
       val billingClient = BillingClient.newBuilder(context).setListener(listener).build()
       billingClient.startConnection(object : BillingClientStateListener {
           override fun onBillingServiceDisconnected() {
             
           }

           override fun onBillingSetupFinished(responseCode: Int) {
              
           }
       })
       it.setCancellable {
          
       }
   }, BackpressureStrategy.LATEST)

   return flowable
}

При виклику startConnection() ми повинні реалізувати два колбеки: BillingClientStateListener: onBillingSetupFinished() і onBillingServiceDisconnected()

При успішному підключенні, ми повинні передати BillingClient підписникам, а при неможливості підключення — сповістити їх про помилку. При цьому можлива ситуація, коли на момент підключення уже немає жодного підписника. В такому разі потрібно завершити підключення billingClient.endConnection().

Релізація onBillingSetupFinished():

override fun onBillingSetupFinished(responseCode: Int) {
   if (!it.isCancelled) {
       if (responseCode == BillingClient.BillingResponse.OK) {
           it.onNext(billingClient)
       } else {
           it.onError(BillingException.fromCode(responseCode))
       }
   } else {
       if (billingClient.isReady) {
           billingClient.endConnection()
       }
   }
}

В onBillingServiceDisconnected потрібно сповістити клієнтів, що підключення завершене, при цьому клієнт може реалізувати логіку для repeat. Реалізація onBillingServiceDisconnected:

override fun onBillingServiceDisconnected() {
   if (!it.isCancelled) {
       it.onComplete()
   }
}

Для реалізації завершення підключення після відписки останнього клієнта, скористаємся FlowableEmitter.setCancellable :

it.setCancellable {
   if (billingClient.isReady) {
       billingClient.endConnection()
   }
}

Як я раніше згадував, було б непогано реалізувати кешування і шарінг одного і того ж об’єкта BillingClient для всіх підписників (наприклад, декілька фрагментів в Activity або навіть декілька Activity). Це б дозволило «миттєво» підключатись до біллінг клієнта. Ця логіка реалізована за допомою FlowableTransformer, дефолтна реалізація — RepeatConnectionTransformer:

class RepeatConnectionTransformer<T> : FlowableTransformer<T, T> {
   override fun apply(upstream: Flowable<T>): Publisher<T> {
       return upstream
               .share()//all observers will wait connection
               .repeat()//repeat when billing client disconnected
               .replay()//return same instance for all observers
               .refCount()//keep connection if at least one observer exists
   }
}

Трохи пояснень про оператори:

share() — можливий випадок, коли декілька підписників спробують підключитись одночасно (або майже одночасно), тоді створиться декілька біллінг клієнтів. Для уникнення таких ситуацій використаємо оператор share(), таким чином всі клієнти будуть підписані на один Flowable.

repeat() — дозволяє повторити підключення після onBillingServiceDisconnected.

replay() — кешує всі івенти (в нашому випадку це один івент — BillingClient) і віддасть їх наступним підписникам.

BillingClientFactory приймає в конструктор будь-який FlowableTransformer (по дефолту це RepeatConnectionTransformer), тому можна реалізувати власну логіку repeat / retry.

RxBillingFlow

Як я уже казав, нам необхідно забезпечити чіткі івенти типу success, cancelled, failed, які б відносились до конкретного продукту. Наскільки мені відомо, BillingClient не дозволяє цього зробити (або я не знайшов цього) — він видає загальні івенти без прив’язки до конкретної дії юзера, відповідно без прив’язки до конкретного продукту. До того ж, існує баг із дублюванням івентів.

Тому для здійснення покупок ми вирішили використовувати «сирий» IInappBillingService, а не BillingClient. Точніше обгортку над ним — RxBillingFlow. IInappBillingService, на відміну від BillingClient, дозволяє «руками запускати» UI покупки, а також самостійно обробляти результат (BillingClient робить це за нас).

По аналогії із BillingClientFactory ми реалізували BillingServiceFactory — об’єкт, який створює підключення до IInappBillingService. Логіка ідентична, крім специфічних для сервіса деталей:

fun createConnection(): Flowable<IInAppBillingService> {
   val flowable = Flowable.create<IInAppBillingService>({ emitter ->
       var bound = false
       val serviceIntent = Intent(BIND_ACTION)
       serviceIntent.`package` = BILLING_PACKAGE
       val serviceConnection = object : ServiceConnection {
           override fun onServiceDisconnected(p0: ComponentName?) {
               if (!emitter.isCancelled) {
                   emitter.onComplete()
               }
           }

           override fun onServiceConnected(p0: ComponentName?, p1: IBinder?) {
               if (!emitter.isCancelled) {
                   bound = true
                   val service = IInAppBillingService.Stub.asInterface(p1)
                   emitter.onNext(service!!)
               } else {
                   context.unbindService(this)
               }
           }
       }
       val bindService = context.bindService(serviceIntent, serviceConnection, Context.BIND_AUTO_CREATE)
       if (!bindService && !emitter.isCancelled) {
           emitter.onError(BillingException.BillingUnavailableException())
           return@create
       }
       emitter.setCancellable {
           if (bound) {
               context.unbindService(serviceConnection)
           }
       }
   }, BackpressureStrategy.LATEST)
   return flowable.compose(transformer)
}

Для BillingServiceFactory використовуємо той же RepeatConnectionTransformer, для прив’язки RxBillingFlow до життєвого циклу Actvity / Fragment — BillingConnectionManager.

Функції RxBillingFlow :

fun buyItem(request: BuyItemRequest, delegate: FlowDelegate): Completable  — покупка продукту або підписки. Повертає Completable — операція або виконалась, або ні;

fun replaceItem(request: ReplaceItemRequest, delegate: FlowDelegate): Completable — апдейт уже купленої підписки;

fun handleActivityResult(activityResultCode: Int, data: Intent?): Single<Purchase> — обробка результату операції, при успішній покупці повертає Purchase, інакше — одну із BillingException.

FlowDelegate

RxBillingFlow делегує запуск UI покупки до FlowDelegate для можливості запуску із Fragment та інших компонентів. Ми реалізували кілька стандартних делегатів — ActivityFlowDelegate, FragmentFlowDelegate, ConductorFlowDelegate, будь-який інший можна реалізувати по необхідності. Реалізація досить проста:

class ActivityFlowDelegate(private val activity: AppCompatActivity) : FlowDelegate {

   override fun startFlow(pendingIntent: PendingIntent, requestCode: Int) {
       activity.startIntentSenderForResult(
               pendingIntent.intentSender, requestCode, Intent(), 0, 0, 0)
   }
}

BillingException

У відповідь на будь-яку неуспішну операцію з RxBilling і RxBillingFlow повертається помилка BillingException.

BillingException представляє собою sealed class з усіма можливими помилками білінг сервісу :

sealed class BillingException(
       open val code: Int
) : Exception("Billing error, code $code") {

   class FeatureNotSupportedException : BillingException(BillingClient.BillingResponse.FEATURE_NOT_SUPPORTED)
   class ServiceDisconnectedException : BillingException(BillingClient.BillingResponse.SERVICE_DISCONNECTED)
   class UserCanceledException : BillingException(BillingClient.BillingResponse.USER_CANCELED)
   class ServiceUnavailableException : BillingException(BillingClient.BillingResponse.SERVICE_UNAVAILABLE)
   class BillingUnavailableException : BillingException(BillingClient.BillingResponse.BILLING_UNAVAILABLE)
   class ItemUnavailableException : BillingException(BillingClient.BillingResponse.ITEM_UNAVAILABLE)
   class DeveloperErrorException : BillingException(BillingClient.BillingResponse.DEVELOPER_ERROR)
   class FatalException : BillingException(BillingClient.BillingResponse.ERROR)
   class AlreadyOwnedException : BillingException(BillingClient.BillingResponse.ITEM_ALREADY_OWNED)
   class NotOwnedException : BillingException(BillingClient.BillingResponse.ITEM_NOT_OWNED)
   class UnknownException(code: Int) : BillingException(code)
}

Висновки

Завдяки даному підходу ми вирішили декілька своїх давніх проблем: постійна перевірка статусу підключення, пінг-понг Activity — Presenter, а також це дозволило нам «сховати» роботу з підписками (крім самої покупки, звичайно) на рівень Repository і Domain і досить легко покрити це тестами.

Якщо у вас є досвід роботи з білінгом або корисні поради на цю тему — діліться, з радістю читатиму

Код бібліотеки з прикладом використання доступний на Github.

Похожие статьи:
З 10 квітня обов’язки ректора НАУ тимчасово виконує Ксенія Семенова, випускниця і викладачка університету, а також депутатка...
Vector Software, в якій працюють орієнтовно 200 фахівців, нещодавно відкрила офіси у Варшаві (Польща) та в Малазі (Іспанія). Про це DOU...
221-й выпуск подкаста «Откровенно про IT карьеризм». В подкасте пойдет речь о науке и карьере. В программе: Про...
С гигантскими кнопками, это великанский пульт легко использовать и тяжело потерять. Это пульт 6 в 1 позволяет...
В рубриці DOU Labs ми запрошуємо IT-компанії ділитись досвідом власних цікавих розробок та внутрішніх...
Яндекс.Метрика