Play 결제 라이브러리로 앱에서 정기 결제 판매 5

1. 소개

Google Play 결제 시스템은 Android 앱에서 디지털 제품 및 콘텐츠를 판매할 수 있게 해주는 서비스입니다. 이는 인앱 상품을 판매하여 앱에서 수익을 창출할 수 있는 가장 직접적인 방법입니다. 이 Codelab에서는 구매를 앱의 나머지 부분과 통합할 때 핵심 세부정보를 캡슐화하는 방식으로 Google Play 결제 라이브러리를 사용하여 프로젝트에서 정기 결제를 판매하는 방법을 보여줍니다.

또한 기본 요금제, 혜택, 태그, 선불 요금제와 같은 정기 결제 관련 개념도 소개합니다. Google Play 결제의 정기 결제에 관해 자세히 알아보려면 고객센터를 참고하세요.

빌드할 항목

이 Codelab에서는 간단한 정기 결제 기반 프로필 앱에 최신 결제 라이브러리 (버전 5.0.0)를 추가합니다. 앱이 이미 빌드되어 있으므로 결제 부분만 추가하면 됩니다. 그림 1과 같이 이 앱에서 사용자는 두 가지 재생 가능한 정기 결제 제품 (기본 및 프리미엄)을 통해 제공되는 기본 요금제 또는 혜택에 가입하거나 갱신할 수 없는 선불 상품에 가입합니다. 여기까지입니다. 기본 요금제는 각각 월간 및 연간 정기 결제입니다. 사용자는 선불 정기 결제를 업그레이드 또는 다운그레이드하거나 갱신 가능한 정기 결제로 전환할 수 있습니다.

d7dba51f800a6cc4.png 2220c15b849d2ead.png

Google Play 결제 라이브러리를 앱에 통합하려면 다음을 만듭니다.

  • BillingClientWrapper - BillingClient 라이브러리의 래퍼입니다. Play 결제 라이브러리의 BillingClient와의 상호작용을 캡슐화하려고 하지만 자체 통합에는 필요하지 않습니다.
  • SubscriptionDataRepository: 앱의 정기 결제 제품 인벤토리 (즉, 판매 중인 상품) 목록과 구매 상태 및 제품 세부정보를 수집하는 데 도움이 되는 ShareFlow 변수 목록이 포함된 앱의 결제 저장소입니다.
  • MainViewModel: 앱의 나머지 부분이 결제 저장소와 통신하는 ViewModel입니다. 다양한 구매 방법을 사용하여 UI에서 결제 흐름을 시작하는 데 도움이 됩니다.

완료되면 앱의 아키텍처가 아래 그림과 같이 표시됩니다.

c83bc759f32b0a63.png

학습할 내용

  • Play 결제 라이브러리를 통합하는 방법
  • Play Console을 통해 정기 결제 제품, 기본 요금제, 혜택, 태그를 만드는 방법
  • 앱에서 사용 가능한 기본 요금제 및 혜택을 가져오는 방법
  • 적절한 매개변수로 결제 절차를 시작하는 방법
  • 선불 정기 결제 제품을 제공하는 방법

이 Codelab에서는 Google Play 결제에 중점을 둡니다. 따라서 이와 관련 없는 개념과 코드 블록은 그냥 넘어가겠습니다. 단, 필요할 때 복사해서 붙여넣을 수 있도록 다른 설명 없이 제공만 해드리겠습니다.

필요한 항목

  • 최신 버전의 Android 스튜디오(Arctic Fox | 2020.3.1 이상)
  • Android 8.0 이상을 실행하는 Android 기기
  • GitHub에서 제공되는 샘플 코드 (이후 섹션의 안내)
  • Android 스튜디오의 Android 개발에 관한 보통 수준의 지식
  • Google Play 스토어에 앱을 게시하는 방법에 관한 지식
  • Kotlin 코드 작성 경험 보통
  • Google Play 결제 라이브러리 버전 5.0.0

2. 설정

GitHub에서 코드 가져오기

이 프로젝트에 필요한 모든 것을 Git 저장소에 넣었습니다. 시작하려면 코드를 가져와 원하는 개발 환경에서 열어야 합니다. 이 Codelab에서는 Android 스튜디오를 사용하는 것이 좋습니다.

시작하는 코드는 GitHub 저장소에 저장되어 있습니다. 다음 명령어를 통해 저장소를 클론할 수 있습니다.

git clone https://github.com/android/play-billing-samples.git
cd PlayBillingCodelab

3. 기본 작업

시작점

이 Codelab을 위해 설계된 기본 사용자 프로필 앱에서 시작해 보겠습니다. 이 코드는 설명하려는 개념을 보여주기 위해 단순화되었으며 프로덕션 용도로 설계되지 않았습니다. 프로덕션 앱에서 이 코드의 일부를 재사용하려면 권장사항을 따르고 모든 코드를 완전히 테스트해야 합니다.

Android 스튜디오로 프로젝트 가져오기

PlayBillingCodelab은 Google Play 결제 구현을 포함하지 않는 기본 앱입니다. Android 스튜디오를 시작하고 Open > billing/PlayBillingCodelab를 선택하여 결제 Codelab을 가져옵니다.

프로젝트에는 다음 두 가지 모듈이 있습니다.

  • start에는 스켈레톤 앱이 있지만 필요한 종속 항목과 구현해야 하는 모든 메서드가 없습니다.
  • finished에는 완료된 프로젝트가 있으며, 어려움을 겪을 때 가이드 역할을 할 수 있습니다.

앱은 8개의 클래스 파일 BillingClientWrapper, SubscriptionDataRepository, Composables, MainState, MainViewModel, MainViewModelFactory, MainActivity로 구성됩니다.

  • BillingClientWrapper는 간단한 구현에 필요한 Google Play 결제의 [BillingClient] 메서드를 격리하고 처리를 위해 응답을 데이터 저장소로 내보내는 래퍼입니다.
  • SubscriptionDataRepository는 Google Play 결제 데이터 소스(즉, 결제 클라이언트 라이브러리)를 추상화하고 BillingClientWrapper에서 내보낸 StateFlow 데이터를 Flow로 변환하는 데 사용됩니다.
  • ButtonModel은 UI에서 버튼을 빌드하는 데 사용되는 데이터 클래스입니다.
  • Composables은 UI의 모든 구성 가능한 메서드를 하나의 클래스로 추출합니다.
  • MainState는 상태 관리를 위한 데이터 클래스입니다.
  • MainViewModel은 UI에서 사용되는 결제 관련 데이터와 상태를 보유하는 데 사용됩니다. SubscriptionDataRepository의 모든 흐름을 하나의 상태 객체로 결합합니다.
  • MainActivity는 사용자 인터페이스의 컴포저블을 로드하는 기본 활동 클래스입니다.
  • 상수는 여러 클래스에서 사용하는 상수를 포함하는 객체입니다.

Gradle

앱에 Google Play 결제를 추가하려면 Gradle 종속 항목을 추가해야 합니다. 앱 모듈의 build.gradle 파일을 열고 다음을 추가합니다.

dependencies {
    val billing_version = "5.0.0"

    implementation("com.android.billingclient:billing:$billing_version")
}

Google Play Console

이 Codelab의 목적상 Google Play Console의 정기 결제 섹션에서 다음 두 가지 정기 결제 제품을 만들어야 합니다.

  • 제품 ID가 up_basic_sub인 기본 정기 결제 1개

제품에는 연결된 태그가 있는 기본 요금제 3개 (자동 갱신 2개, 선불 1개)가 있어야 합니다. monthlybasic 태그가 있는 월간 기본 정기 결제 1개, yearlybasic 태그가 있는 연간 기본 정기 결제 1개, prepaidbasic 태그가 있는 선불 정기 결제 1개가 있어야 합니다.

기본 요금제에 혜택을 추가할 수 있습니다. 혜택은 연결된 기본 요금제의 태그를 상속합니다.

  • 제품 ID가 up_premium_sub인 프리미엄 구독 1개

제품에는 연결된 태그가 있는 기본 요금제 3개(자동 갱신 2개, 선불 1개)가 있어야 합니다. monthlypremium 태그가 있는 월간 기본 정기 결제 1개, yearlypremium 태그가 적용된 연간 기본 정기 결제 1개, prepaidpremium 태그가 있는 선불 정기 결제 1개 등입니다.

a9f6fd6e70e69fed.png

기본 요금제에 혜택을 추가할 수 있습니다. 혜택은 연결된 기본 요금제의 태그를 상속합니다.

정기 결제 제품, 기본 요금제, 혜택, 태그를 만드는 방법을 자세히 알아보려면 Google Play 고객센터를 참고하세요.

4. 결제 클라이언트 설정

이 섹션에서는 BillingClientWrapper 클래스로 작업합니다.

마지막으로 결제 클라이언트를 인스턴스화하는 데 필요한 모든 것과 관련 메서드가 갖추게 됩니다.

  1. BillingClient 초기화

Google Play 결제 라이브러리의 종속 항목을 추가한 후에는 BillingClient 인스턴스를 초기화해야 합니다.

BillingClientWrapper.kt

private val billingClient = BillingClient.newBuilder(context)
   .setListener(this)
   .enablePendingPurchases()
   .build()
  1. Google Play 연결 설정

BillingClient를 만든 후에는 Google Play와의 연결을 설정해야 합니다.

Google Play에 연결하기 위해 startConnection()를 호출합니다. 연결 프로세스는 비동기적이며, 클라이언트 설정이 완료되고 추가 요청을 할 준비가 되면 BillingClientStateListener를 구현하여 콜백을 수신해야 합니다.

BillingClientWrapper.kt

fun startBillingConnection(billingConnectionState: MutableLiveData<Boolean>) {

   billingClient.startConnection(object : BillingClientStateListener {
       override fun onBillingSetupFinished(billingResult: BillingResult) {
           if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
               Log.d(TAG, "Billing response OK")
               // The BillingClient is ready. You can query purchases and product details here
               queryPurchases()
               queryProductDetails()
               billingConnectionState.postValue(true)
           } else {
               Log.e(TAG, billingResult.debugMessage)
           }
       }

       override fun onBillingServiceDisconnected() {
           Log.i(TAG, "Billing connection disconnected")
           startBillingConnection(billingConnectionState)
       }
   })
}
  1. Google Play 결제로 기존 구매 문의

Google Play에 연결을 설정한 후에는 사용자가 이전에 queryPurchasesAsync()를 호출하여 구매한 항목을 쿼리할 준비가 된 것입니다.

BillingClientWrapper.kt

fun queryPurchases() {
   if (!billingClient.isReady) {
       Log.e(TAG, "queryPurchases: BillingClient is not ready")
   }
   // Query for existing subscription products that have been purchased.
   billingClient.queryPurchasesAsync(
       QueryPurchasesParams.newBuilder().setProductType(BillingClient.ProductType.SUBS).build()
   ) { billingResult, purchaseList ->
       if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
           if (!purchaseList.isNullOrEmpty()) {
               _purchases.value = purchaseList
           } else {
               _purchases.value = emptyList()
           }

       } else {
           Log.e(TAG, billingResult.debugMessage)
       }
   }
}
  1. 구매 가능한 제품 표시

이제 구매 가능한 제품을 쿼리하여 사용자에게 표시할 수 있습니다. Google Play에 정기 결제 제품 세부정보를 쿼리하기 위해 queryProductDetailsAsync()를 호출합니다. 제품 세부정보 쿼리는 현지화된 제품 정보를 반환하므로 사용자에게 제품을 표시하기 전에 중요한 단계입니다.

BillingClientWrapper.kt

fun queryProductDetails() {
   val params = QueryProductDetailsParams.newBuilder()
   val productList = mutableListOf<QueryProductDetailsParams.Product>()
   for (product in LIST_OF_PRODUCTS) {

       productList.add(
           QueryProductDetailsParams.Product.newBuilder()
               .setProductId(product)
               .setProductType(BillingClient.ProductType.SUBS)
               .build()
       )

       params.setProductList(productList).let { productDetailsParams ->
           Log.i(TAG, "queryProductDetailsAsync")
           billingClient.queryProductDetailsAsync(productDetailsParams.build(), this)
       }
   }
}
  1. ProductDetails 쿼리의 리스너 설정

참고: 이 메서드는 맵에서 쿼리 결과를 _productWithProductDetails에 내보냅니다.

또한 쿼리는 ProductDetails를 반환해야 합니다. 이러한 일이 발생하지 않으면 Play Console에 설정된 제품이 활성화되지 않았거나 출시 트랙에 결제 클라이언트 종속 항목이 있는 빌드를 게시하지 않았기 때문일 수 있습니다.

BillingClientWrapper.kt

override fun onProductDetailsResponse(
   billingResult: BillingResult,
   productDetailsList: MutableList<ProductDetails>
) {
   val responseCode = billingResult.responseCode
   val debugMessage = billingResult.debugMessage
   when (responseCode) {
       BillingClient.BillingResponseCode.OK -> {
           var newMap = emptyMap<String, ProductDetails>()
           if (productDetailsList.isNullOrEmpty()) {
               Log.e(
                   TAG,
                   "onProductDetailsResponse: " +
                           "Found null or empty ProductDetails. " +
                           "Check to see if the Products you requested are correctly " +
                           "published in the Google Play Console."
               )
           } else {
               newMap = productDetailsList.associateBy {
                   it.productId
               }
           }
           _productWithProductDetails.value = newMap
       }
       else -> {
           Log.i(TAG, "onProductDetailsResponse: $responseCode $debugMessage")
       }
   }
}
  1. 구매 흐름 시작

launchBillingFlow는 사용자가 항목을 구매하기 위해 클릭할 때 호출되는 메서드입니다. 그러면 Google Play에서 제품의 ProductDetails로 구매 흐름을 시작하라는 메시지를 표시합니다.

BillingClientWrapper.kt

fun launchBillingFlow(activity: Activity, params: BillingFlowParams) {
   if (!billingClient.isReady) {
       Log.e(TAG, "launchBillingFlow: BillingClient is not ready")
   }
   billingClient.launchBillingFlow(activity, params)

}
  1. 구매 작업 결과의 리스너 설정

사용자가 '구매' 버튼을 탭하여 구매를 완료하거나 뒤로 버튼을 탭하여 구매를 취소하여 Google Play 구매 화면을 종료하면 onPurchaseUpdated() 콜백이 구매 흐름의 결과를 앱으로 다시 전송합니다. 그러면 BillingResult.responseCode에 따라 사용자가 제품을 성공적으로 구매했는지 확인할 수 있습니다. responseCode == OK이면 구매가 성공적으로 완료된 것입니다.

onPurchaseUpdated()는 사용자가 앱을 통해 구매한 모든 항목이 포함된 Purchase 객체 목록을 전달합니다. 여러 필드 중에서 각 Purchase 객체에는 제품 ID, purchaseToken 및 isAcknowledged 속성이 포함됩니다. 이러한 필드를 사용하면 각 Purchase 객체에 대해 처리해야 하는 새로운 구매인지 아니면 추가 처리가 필요하지 않은 기존 구매인지 확인할 수 있습니다.

정기 결제 구매의 경우 처리 과정은 신규 구매를 확인하는 것과 유사합니다.

BillingClientWrapper.kt

override fun onPurchasesUpdated(
   billingResult: BillingResult,
   purchases: List<Purchase>?
) {
   if (billingResult.responseCode == BillingClient.BillingResponseCode.OK
       && !purchases.isNullOrEmpty()
   ) {
       // Post new purchase List to _purchases
       _purchases.value = purchases

       // Then, handle the purchases
       for (purchase in purchases) {
           acknowledgePurchases(purchase)
       }
   } else if (billingResult.responseCode == BillingClient.BillingResponseCode.USER_CANCELED) {
       // Handle an error caused by a user cancelling the purchase flow.
       Log.e(TAG, "User has cancelled")
   } else {
       // Handle any other error codes.
   }
}
  1. 구매 처리 (구매 인증 및 확인)

사용자가 구매를 완료하면 앱에서는 구매를 확인하여 처리해야 합니다.

또한 확인이 성공적으로 처리되면 _isNewPurchaseAcknowledged의 값이 true로 설정됩니다.

BillingClientWrapper.kt

private fun acknowledgePurchases(purchase: Purchase?) {
   purchase?.let {
       if (!it.isAcknowledged) {
           val params = AcknowledgePurchaseParams.newBuilder()
               .setPurchaseToken(it.purchaseToken)
               .build()

           billingClient.acknowledgePurchase(
               params
           ) { billingResult ->
               if (billingResult.responseCode == BillingClient.BillingResponseCode.OK &&
                   it.purchaseState == Purchase.PurchaseState.PURCHASED
               ) {
                   _isNewPurchaseAcknowledged.value = true
               }
           }
       }
   }
}
  1. 결제 연결 종료

마지막으로 활동이 소멸되면 Google Play 연결을 종료하려고 하므로 endConnection()가 호출됩니다.

BillingClientWrapper.kt

fun terminateBillingConnection() {
   Log.i(TAG, "Terminating connection")
   billingClient.endConnection()
}

5. SubscriptionDataRepository

BillingClientWrapper에서 QueryPurchasesAsyncQueryProductDetails의 응답은 구매 및 productWithProductDetails와 함께 클래스 외부에 노출되는 MutableStateFlow _purchases_productWithProductDetails에 각각 게시됩니다.

SubscriptionDataRepository에서는 반환된 구매의 제품에 따라 세 가지 Flow(hasRenewableBasic, hasPrepaidBasic, hasRenewablePremium, hasPremiumPrepaid)로 구매가 처리됩니다.

또한 productWithProductDetails는 각 basicProductDetailspremiumProductDetails Flow로 처리됩니다.

6. MainViewModel

어려운 부분은 끝났습니다. 이제 MainViewModel를 정의합니다. 이는 클라이언트의 공개 인터페이스일 뿐이므로 클라이언트가 BillingClientWrapperSubscriptionDataRepository의 내부를 알 필요가 없습니다.

먼저 MainViewModel에서 viewModel이 초기화되면 결제 연결을 시작합니다.

MainViewModel.kt

init {
   billingClient.startBillingConnection(billingConnectionState = _billingConnectionState)
}

그런 다음 저장소의 Flow가 저장소 클래스에서 처리되는 대로 각각 productsForSaleFlows (사용 가능한 제품의 경우) 및 userCurrentSubscriptionFlow (사용자의 현재 정기 결제와 활성 정기 결제의 경우)로 결합됩니다.

currentPurchasesFlow를 통해 UI에서도 현재 구매 목록을 사용할 수 있습니다.

MainViewModel.kt

val productsForSaleFlows = combine(
   repo.basicProductDetails,
   repo.premiumProductDetails
) { basicProductDetails,
   premiumProductDetails
   ->
   MainState(
       basicProductDetails = basicProductDetails,
       premiumProductDetails = premiumProductDetails
   )
}

// The userCurrentSubscriptionFlow object combines all the possible subscription flows into one
// for emission.
private val userCurrentSubscriptionFlow = combine(
   repo.hasRenewableBasic,
   repo.hasPrepaidBasic,
   repo.hasRenewablePremium,
   repo.hasPrepaidPremium
) { hasRenewableBasic,
   hasPrepaidBasic,
   hasRenewablePremium,
   hasPrepaidPremium
   ->
   MainState(
       hasRenewableBasic = hasRenewableBasic,
       hasPrepaidBasic = hasPrepaidBasic,
       hasRenewablePremium = hasRenewablePremium,
       hasPrepaidPremium = hasPrepaidPremium
   )
}

// Current purchases.
val currentPurchasesFlow = repo.purchases

결합된 userCurrentSubscriptionFlow는 init 블록에 수집되고 값은 _destinationScreen라는 MutableLiveData 객체에 게시됩니다.

init {
   viewModelScope.launch {
       userCurrentSubscriptionFlow.collectLatest { collectedSubscriptions ->
           when {
               collectedSubscriptions.hasRenewableBasic == true &&
                       collectedSubscriptions.hasRenewablePremium == false -> {
                   _destinationScreen.postValue(DestinationScreen.BASIC_RENEWABLE_PROFILE)
               }
               collectedSubscriptions.hasRenewablePremium == true &&
                       collectedSubscriptions.hasRenewableBasic == false -> {
                   _destinationScreen.postValue(DestinationScreen.PREMIUM_RENEWABLE_PROFILE)
               }
               collectedSubscriptions.hasPrepaidBasic == true &&
                       collectedSubscriptions.hasPrepaidPremium == false -> {
                   _destinationScreen.postValue(DestinationScreen.BASIC_PREPAID_PROFILE_SCREEN)
               }
               collectedSubscriptions.hasPrepaidPremium == true &&
                       collectedSubscriptions.hasPrepaidBasic == false -> {
                   _destinationScreen.postValue(
                       DestinationScreen.PREMIUM_PREPAID_PROFILE_SCREEN
                   )
               }
               else -> {
                   _destinationScreen.postValue(DestinationScreen.SUBSCRIPTIONS_OPTIONS_SCREEN)
               }
           }
       }

   }
}

MainViewModel는 몇 가지 매우 유용한 메서드도 추가합니다.

  1. 기본 요금제 및 혜택 토큰 검색

Play 결제 라이브러리 버전 5.0.0부터 모든 정기 결제 제품에 여러 기본 요금제와 혜택을 포함할 수 있습니다(혜택을 포함할 수 없는 선불 기본 요금제 제외).

이 메서드를 사용하면 관련 혜택을 그룹화하는 데 사용되는 새로 도입된 태그 개념을 사용하여 사용자가 이용할 수 있는 모든 혜택과 기본 요금제를 검색할 수 있습니다.

예를 들어 사용자가 월간 기본 정기 결제를 구매하려고 하면 월간 기본 정기 결제 제품과 연결된 모든 기본 요금제 및 혜택이 monthlyBasic 문자열로 태그됩니다.

MainViewModel.kt

private fun retrieveEligibleOffers(
   offerDetails: MutableList<ProductDetails.SubscriptionOfferDetails>,
   tag: String
): List<ProductDetails.SubscriptionOfferDetails> {
   val eligibleOffers = emptyList<ProductDetails.SubscriptionOfferDetails>().toMutableList()
   offerDetails.forEach { offerDetail ->
       if (offerDetail.offerTags.contains(tag)) {
           eligibleOffers.add(offerDetail)
       }
   }

   return eligibleOffers
}
  1. 최저가 혜택 계산

사용자가 여러 혜택을 받을 자격이 있는 경우 leastPricedOfferToken() 메서드는 retrieveEligibleOffers()에서 반환된 혜택 중 가장 낮은 혜택을 계산하는 데 사용됩니다.

이 메서드는 선택된 혜택의 혜택 ID 토큰을 반환합니다.

이 구현은 단순히 pricingPhases 집합의 측면에서 최저 가격이 책정된 제품을 반환하며 평균을 고려하지 않습니다.

또 다른 구현에서는 평균 가격이 최저가인 상품을 대신 찾아보는 방법도 있습니다.

MainViewModel.kt

private fun leastPricedOfferToken(
   offerDetails: List<ProductDetails.SubscriptionOfferDetails>
): String {
   var offerToken = String()
   var leastPricedOffer: ProductDetails.SubscriptionOfferDetails
   var lowestPrice = Int.MAX_VALUE

   if (!offerDetails.isNullOrEmpty()) {
       for (offer in offerDetails) {
           for (price in offer.pricingPhases.pricingPhaseList) {
               if (price.priceAmountMicros < lowestPrice) {
                   lowestPrice = price.priceAmountMicros.toInt()
                   leastPricedOffer = offer
                   offerToken = leastPricedOffer.offerToken
               }
           }
       }
   }
   return offerToken
}
  1. BillingFlowParams 빌더

특정 제품의 구매 흐름을 시작하기 위해 ProductDetails 및 선택한 혜택의 토큰을 설정하고 BilingFlowParams를 빌드하는 데 사용해야 합니다.

다음과 같은 두 가지 방법이 있습니다.

upDowngradeBillingFlowParamsBuilder()는 업그레이드 및 다운그레이드 매개변수를 빌드합니다.

MainViewModel.kt

private fun upDowngradeBillingFlowParamsBuilder(
   productDetails: ProductDetails,
   offerToken: String,
   oldToken: String
): BillingFlowParams {
   return BillingFlowParams.newBuilder().setProductDetailsParamsList(
       listOf(
           BillingFlowParams.ProductDetailsParams.newBuilder()
               .setProductDetails(productDetails)
               .setOfferToken(offerToken)
               .build()
       )
   ).setSubscriptionUpdateParams(
       BillingFlowParams.SubscriptionUpdateParams.newBuilder()
           .setOldPurchaseToken(oldToken)
           .setReplaceProrationMode(
               BillingFlowParams.ProrationMode.IMMEDIATE_AND_CHARGE_FULL_PRICE
           )
           .build()
   ).build()
}

billingFlowParamsBuilder()는 일반 구매의 매개변수를 빌드합니다.

MainViewModel.kt

private fun billingFlowParamsBuilder(
   productDetails: ProductDetails,
   offerToken: String
): BillingFlowParams.Builder {
   return BillingFlowParams.newBuilder().setProductDetailsParamsList(
       listOf(
           BillingFlowParams.ProductDetailsParams.newBuilder()
               .setProductDetails(productDetails)
               .setOfferToken(offerToken)
               .build()
       )
   )
}
  1. 구매 방법

구매 메서드는 BillingClientWrapperlaunchBillingFlow()BillingFlowParams를 사용하여 구매를 실행합니다.

MainViewModel.kt

fun buy(
   productDetails: ProductDetails,
   currentPurchases: List<Purchase>?,
   activity: Activity,
   tag: String
) {
   val offers =
       productDetails.subscriptionOfferDetails?.let {
           retrieveEligibleOffers(
               offerDetails = it,
               tag = tag.lowercase()
           )
       }
   val offerToken = offers?.let { leastPricedOfferToken(it) }
   val oldPurchaseToken: String

   // Get current purchase. In this app, a user can only have one current purchase at
   // any given time.
   if (!currentPurchases.isNullOrEmpty() &&
       currentPurchases.size == MAX_CURRENT_PURCHASES_ALLOWED
   ) {
       // This either an upgrade, downgrade, or conversion purchase.
       val currentPurchase = currentPurchases.first()

       // Get the token from current purchase.
       oldPurchaseToken = currentPurchase.purchaseToken

       val billingParams = offerToken?.let {
           upDowngradeBillingFlowParamsBuilder(
               productDetails = productDetails,
               offerToken = it,
               oldToken = oldPurchaseToken
           )
       }

       if (billingParams != null) {
           billingClient.launchBillingFlow(
               activity,
               billingParams
           )
       }
   } else if (currentPurchases == null) {
       // This is a normal purchase.
       val billingParams = offerToken?.let {
           billingFlowParamsBuilder(
               productDetails = productDetails,
               offerToken = it
           )
       }

       if (billingParams != null) {
           billingClient.launchBillingFlow(
               activity,
               billingParams.build()
           )
       }
   } else if (!currentPurchases.isNullOrEmpty() &&
       currentPurchases.size > MAX_CURRENT_PURCHASES_ALLOWED
   ) {
       // The developer has allowed users  to have more than 1 purchase, so they need to
       /// implement a logic to find which one to use.
       Log.d(TAG, "User has more than 1 current purchase.")
   }
}

d726a27add092140.png

  1. 결제 연결 종료하기

마지막으로 BillingClientWrapperterminateBillingConnection 메서드는 ViewModel의 onCleared()에서 호출됩니다.

이는 관련 활동이 소멸될 때 현재 결제 연결을 종료하기 위한 것입니다.

7. UI

이제 UI에서 빌드한 모든 것을 사용할 차례입니다. 이를 위해 컴포저블 및 MainActivity 클래스를 사용합니다.

Composables.kt

컴포저블 클래스가 완전히 제공되며, UI를 렌더링하는 데 사용되는 모든 Compose 함수와 그 간의 탐색 메커니즘을 정의합니다.

Subscriptions 함수는 Basic SubscriptionPremium Subscription의 두 버튼을 표시합니다.

Basic SubscriptionPremium Subscription는 각각 월간, 연간, 선불의 세 가지 기본 요금제를 보여주는 새로운 Compose 메서드를 로드합니다.

그러면 사용자가 보유한 특정 구독에 대해 각각 재생 가능한 기본, 재생 가능한 프리미엄, 선불 기본 또는 선불 프리미엄 프로필이라는 세 가지 프로필 작성 기능이 있습니다.

  • 사용자가 Basic을 구독하는 경우 기본 프로필을 사용하여 월간, 연간 또는 선불 프리미엄 구독으로 업그레이드할 수 있습니다.
  • 반대로 프리미엄 구독을 이용하는 사용자는 월간, 연간 또는 선불 기본 구독으로 다운그레이드할 수 있습니다.
  • 선불 정기 결제를 이용하는 사용자는 충전 버튼으로 정기 결제를 충전하거나 선불 정기 결제를 해당하는 자동 갱신 기본 요금제로 전환할 수 있습니다.

마지막으로 Google Play에 연결할 때와 사용자 프로필이 렌더링될 때 사용되는 로딩 화면 기능이 있습니다.

MainActivity.kt

  1. MainActivity가 생성되면 viewModel이 인스턴스화되고 MainNavHost라는 Compose 함수가 로드됩니다.
  2. MainNavHost는 viewModel의 billingConnectionSate Livedata에서 생성된 isBillingConnected 변수로 시작하고 vieModel이 인스턴스화될 때 billingConnectionSateBillingClientWrapper의 startBillingConnection 메서드에 전달하기 때문에 변경사항이 관찰됩니다.

isBillingConnected는 연결이 설정되면 true로, 연결이 설정되면 false로 설정됩니다.

false이면 LoadingScreen() Compose 함수가 로드되고 true인 경우 Subscription 또는 프로필 함수가 로드됩니다.

val isBillingConnected by viewModel.billingConnectionState.observeAsState()
  1. 결제 연결이 설정된 경우:

Compose navController가 인스턴스화됨

val navController = rememberNavController()

그런 다음 MainViewModel의 흐름이 수집됩니다.

val productsForSale by viewModel.productsForSaleFlows.collectAsState(
   initial = MainState()
)

val currentPurchases by viewModel.currentPurchasesFlow.collectAsState(
   initial = listOf()
)

마지막으로 viewModel의 destinationScreen LiveData 변수가 관찰됩니다.

사용자의 현재 정기 결제 상태에 따라 상응하는 Compose 함수가 렌더링됩니다.

val screen by viewModel.destinationScreen.observeAsState()
when (screen) {
   // User has a Basic Prepaid subscription
   // the corresponding profile is loaded.
   MainViewModel.DestinationScreen.BASIC_PREPAID_PROFILE_SCREEN -> {
       UserProfile(
           buttonModels =
           listOf(
               ButtonModel(R.string.topup_message) {
                   productsForSale.basicProductDetails?.let {
                       viewModel.buy(
                           productDetails = it,
                           currentPurchases = null,
                           tag = PREPAID_BASIC_PLANS_TAG,
                           activity = activity
                       )
                   }
               },
               ButtonModel(R.string.convert_to_basic_monthly_message) {
                   productsForSale.basicProductDetails?.let {
                       viewModel.buy(
                           productDetails = it,
                           currentPurchases = currentPurchases,
                           tag = MONTHLY_BASIC_PLANS_TAG,
                           activity = activity
                       )
                   }
               },
               ButtonModel(R.string.convert_to_basic_yearly_message) {
                   productsForSale.basicProductDetails?.let {
                       viewModel.buy(
                           productDetails = it,
                           currentPurchases = currentPurchases,
                           tag = YEARLY_BASIC_PLANS_TAG,
                           activity = activity
                       )
                   }
               },
           ),
           tag = PREPAID_BASIC_PLANS_TAG,
           profileTextStringResource = null
       )
   }
   // User has a renewable basic subscription
   // the corresponding profile is loaded.
   MainViewModel.DestinationScreen.BASIC_RENEWABLE_PROFILE -> {
       UserProfile(
           buttonModels =
           listOf(
               ButtonModel(R.string.monthly_premium_upgrade_message) {
                   productsForSale.premiumProductDetails?.let {
                       viewModel.buy(
                           productDetails = it,
                           currentPurchases = currentPurchases,
                           tag = MONTHLY_PREMIUM_PLANS_TAG,
                           activity = activity
                       )
                   }
               },
               ButtonModel(R.string.yearly_premium_upgrade_message) {
                   productsForSale.premiumProductDetails?.let {
                       viewModel.buy(
                           productDetails = it,
                           currentPurchases = currentPurchases,
                           tag = YEARLY_PREMIUM_PLANS_TAG,
                           activity = activity
                       )
                   }
               },
               ButtonModel(R.string.prepaid_premium_upgrade_message) {
                   productsForSale.premiumProductDetails?.let {
                       viewModel.buy(
                           productDetails = it,
                           currentPurchases = currentPurchases,
                           tag = PREPAID_PREMIUM_PLANS_TAG,
                           activity = activity
                       )
                   }
               }
           ),
           tag = null,
           profileTextStringResource = R.string.basic_sub_message
       )
   }
   // User has a prepaid Premium subscription
   // the corresponding profile is loaded.
   MainViewModel.DestinationScreen.PREMIUM_PREPAID_PROFILE_SCREEN -> {
       UserProfile(
           buttonModels =
           listOf(
               ButtonModel(R.string.topup_message) {
                   productsForSale.premiumProductDetails?.let {
                       viewModel.buy(
                           productDetails = it,
                           currentPurchases = null,
                           tag = PREPAID_PREMIUM_PLANS_TAG,
                           activity = activity
                       )
                   }
               },
               ButtonModel(R.string.convert_to_premium_monthly_message) {
                   productsForSale.premiumProductDetails?.let {
                       viewModel.buy(
                           productDetails = it,
                           currentPurchases = currentPurchases,
                           tag = MONTHLY_PREMIUM_PLANS_TAG,
                           activity = activity
                       )
                   }
               },
               ButtonModel(R.string.convert_to_premium_yearly_message) {
                   productsForSale.premiumProductDetails?.let {
                       viewModel.buy(
                           productDetails = it,
                           currentPurchases = currentPurchases,
                           tag = YEARLY_PREMIUM_PLANS_TAG,
                           activity = activity
                       )
                   }
               },
           ),
           tag = PREPAID_PREMIUM_PLANS_TAG,
           profileTextStringResource = null
       )
   }
   // User has a renewable Premium subscription
   // the corresponding profile is loaded.
   MainViewModel.DestinationScreen.PREMIUM_RENEWABLE_PROFILE -> {
       UserProfile(
           listOf(
               ButtonModel(R.string.monthly_basic_downgrade_message) {
                   productsForSale.basicProductDetails?.let {
                       viewModel.buy(
                           productDetails = it,
                           currentPurchases = currentPurchases,
                           tag = MONTHLY_BASIC_PLANS_TAG,
                           activity = activity
                       )
                   }
               },
               ButtonModel(R.string.yearly_basic_downgrade_message) {
                   productsForSale.basicProductDetails?.let {
                       viewModel.buy(
                           productDetails = it,
                           currentPurchases = currentPurchases,
                           tag = YEARLY_BASIC_PLANS_TAG,
                           activity = activity
                       )
                   }
               },
               ButtonModel(R.string.prepaid_basic_downgrade_message) {
                   productsForSale.basicProductDetails?.let {
                       viewModel.buy(
                           productDetails = it,
                           currentPurchases = currentPurchases,
                           tag = PREPAID_BASIC_PLANS_TAG,
                           activity = activity
                       )
                   }
               }
           ),
           tag = null,
           profileTextStringResource = R.string.premium_sub_message
       )
   }
   // User has no current subscription - the subscription composable
   // is loaded.
   MainViewModel.DestinationScreen.SUBSCRIPTIONS_OPTIONS_SCREEN -> {
       SubscriptionNavigationComponent(
           productsForSale = productsForSale,
           navController = navController,
           viewModel = viewModel
       )
   }
}

8. 솔루션 코드

전체 솔루션 코드는 솔루션 모듈에서 확인할 수 있습니다.

9. 축하합니다

축하합니다. Google Play 결제 라이브러리 5.0.0 정기 결제 제품을 매우 간단한 앱에 통합했습니다.

안전한 서버 설정이 적용된 정교한 앱의 문서화된 버전은 공식 샘플을 참고하세요.

추가 자료