Report: State of in-app subscriptions in the US 2023 Get a report

Android 인앱 구매, 2부: Google Play 결제 라이브러리 (Google Play Billing Library)로 구매 처리하기

Vlad Guriev

Updated: 3월 20, 2023

Content

62fdf1c4519df62803e8fafe jp android tutorial 1 configuration 2 4

이전기사에서 결제 라이브러리와 함께 작동하는 래퍼클래스를 생성했습니다.

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    } }

구매구현(purchaseimplementation)으로진행하여 클래스를 개선해 보겠습니다.

구독화면 디자인하기

인앱구매 기능이 있는 모든 앱 (app)에는페이월 (paywall)화면이있습니다.Google정책이이러한 화면에 반드시 있어야 하는 최소한의 요소와지침 텍스트를 정의합니다.요약하자면다음과 같습니다.페이월화면에서 구독 조건,비용및 기간을 명시하고 앱을 사용하기 위해 구독이 필요한지여부를 구체적으로 지정해야 합니다.또한사용자가 조건을 검토하기 위해 추가 작업을 수행하도록강요해서는 안 됩니다.

여기서는단순화된 페이월 화면을 예로 사용합니다.

android sample paywall
Android샘플페이월

페이월화면에는 다음 요소가 있습니다.

  • 제목.
  • 구매 절차를 시작하도록 설정된 버튼입니다. 이 버튼은 사용자에게 제목 및 비용과 같은 구독 옵션의 일반 세부 정보에 대해 안내하며, 비용의 경우 현지 통화로 표시됩니다.
  • 지침 텍스트.
  • 이전에 구매한 항목을 복원하는 버튼입니다. 이 요소는 구독 또는 비소모성 구매 기능이 있는 모든 앱의 필수 요소입니다.

코드를수정하여 제품 세부 정보 표시

이예제에는 4가지제품이 있습니다.

  • 자동 갱신 구독 2개 (“premium_sub_month” 및 “premium_sub_year”),
  • 두 번 구매할 수 없는 제품 또는 비소모성 제품 (“unlock_feature”),
  • 여러 번 구매할 수 있는 상품 또는 소모성 제품 (“coin_pack_large”).

예제를단순화하기 위해,이전기사에서 만든 BillingClientWrapper와함께 삽입할 액티비티와 고정된 수의 구매 버튼이 있는레이아웃을 사용하겠습니다.

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()메소드를불러와야 합니다.

이를위해 BillingClientWrapper에purchase()메소드를추가합니다.

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클래스객체)또는이전가이드에서선언한 오류 메시지 중 하나를 받게 됩니다.다음시간에는 구매가 성공하더라도 Purchase클래스객체가 무효로 될 수 있는 경우에 대해 다룰 것입니다.

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            }        })    } }

Subscribe to Adapty newsletter

Get fresh paywall ideas, subscription insights, and mobile app news every month!

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가비어 있지 않으면,먼저purchaseState이PurchaseState.PURCHASED와동일한 아이템을 골라내야 하는데,보류중인구매도 있기 때문입니다.이러한경우,유저플로우는 여기에서 끝납니다.문서에따르면,이때에는 우리 서버에서 구매를 인증해야 합니다.이시리즈의 후속 기사에서 이 점에 대해 다룰 것입니다.서버인증이 완료되면 콘텐츠를 전달하고 Google에알려야 합니다.후자대로하지 않으면,구매는3일후에 자동으로 환불됩니다.이정책이 GooglePlay에만적용된다는 점은 매우 흥미롭습니다.iOS는유사한 정책을 부과하지 않습니다.사용자에게콘텐츠 전달을 승인하는 방법에는 두 가지가 있습니다.

소모성제품을 다루는 경우,이대신 consumeAsync()메소드를불러와야 합니다.그것은내부 구매를 승인하는 동시에,제품을다시 구매할 수 있도록 합니다.이는결제 라이브러리에서만 수행할 수 있습니다.어떤이유로인가,Google Play 개발자API는백엔드 측에서 이 작업을 수행할 수 있는 방법을 제공하지않습니다.Google Play와달리 AppStore와AppGallery는각각 AppStore Connect와AppGalleryConnect를통해 제품을 소모품으로 구성한다는 점이 흥미롭습니다.그러나이러한 AppGallery제품도명시적인 방식으로 소비되어야 합니다.

자체백엔드를 실행 중인지 여부를 보고하는 두 가지 버전의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            }        }    } }

다음기사에서는 구매에 대한 서버 인증에 대해 더 자세히다룰 것입니다.

물론,클라이언트측의 두 번째 예에서 승인을 구현할 수도 있습니다.그러나백엔드에서 무언가를 할 수 있는 경우라면,그렇게해야 합니다.승인또는 소비 프로세스 중 하나에서 오류가 발생하면,이를무시해서는 안 됩니다.구매가PurchaseState.PURCHASED상태를수신한 후 3일이내에 이들 중 아무 것도 실행되지 않은 경우,취소및 환불됩니다.따라서백엔드에서 수행이 불가능하고 여러 번 시도한 후에도오류가 계속 발생하는 경우에는,신뢰할수 있는 차선책이 있습니다.사용자의현재 구매를 onStart()또는onResume()과같은 수명 주기 메소드를 통해 가져와야 하고,사용자가인터넷에 연결된 상태에서 3일이내에 앱을 실행하기를 바라며 계속 시도합니다.:)

따라서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)            }        }    } }

사용자가이미 구매했는지 여부에 관계없이,모든제품에 대해 버튼이 활성화된 이유가 궁금할 수 있습니다.또는두 구독을 모두 구입하면 어떻게 되는지에 대해 질문이있을 수 있습니다.첫번째 구독이 두 번째 구독을 대체합니까,아니면둘 다 유지됩니까?다음기사에서 모두 답해드립니다.:)