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 결제 라이브러리의 상호작용을 캡슐화하기 위한 것이지만 자체 통합에 필요하지는 않습니다.
  • 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 코드 작성 경험

2. 설정

GitHub에서 코드 가져오기

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

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

git clone https://github.com/android/play-billing-codelabs

3. 기본

시작 지점

시작점은 이 Codelab을 위해 설계된 기본 사용자 프로필 앱입니다. 이 그림은 설명을 위해 사용된 개념을 단순화한 것으로, 프로덕션용으로 설계되지 않았습니다. 프로덕션 앱에서 이 코드 일부를 재사용하려면 권장사항을 따르고 모든 코드를 완전히 테스트해야 합니다.

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

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

프로젝트에는 다음과 같은 두 개의 패키지가 있습니다.

  • codelab에는 스켈레톤 앱이 있지만 구현해야 하는 필수 종속 항목과 메서드가 없습니다.
  • 솔루션에 완료된 프로젝트가 있으며 문제가 발생할 경우 가이드 역할을 할 수 있습니다.

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

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

Gradle

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

dependencies {
    val billing_version = "5.0.0"

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

콘솔

이 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개가 있어야 합니다.

기본 요금제에 혜택을 추가할 수 있습니다. 혜택은 관련 기본 요금제의 태그를 상속받습니다.

정기 결제 제품, 기본 요금제, 혜택, 태그를 만드는 방법에 관한 자세한 내용은 여기에서 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 객체의 목록을 전달합니다. 다른 여러 필드 중에서 각 구매 객체에는 제품 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. 결제 연결 종료

마지막으로 활동이 소멸될 때 endConnection()을 호출하여 Google Play와의 연결을 종료하려고 합니다.

BillingClientWrapper.kt

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

5. SubscriptionDataRepository

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

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

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

6. 기본 ViewModel

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

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

MainViewModel.kt

init {
   billingClient.startBillingConnection(billingConnectionState = _billingConnectionState)
}

그러면 저장소 흐름이 저장소 클래스에서 처리되는 대로 각각 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에 내장된 모든 것을 사용할 차례입니다. 이를 지원하기 위해 Composables 및 MainActivity 클래스를 사용합니다.

Composables.kt

Composables 클래스는 완전히 제공되며 UI를 렌더링하는 데 사용되는 모든 Compose 함수와 이들 간의 탐색 메커니즘을 정의합니다.

Subscriptions 함수에는 Basic SubscriptionPremium Subscription라는 버튼 두 개가 표시됩니다.

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

그런 다음 사용자가 이용할 수 있는 특정 가입, 즉 갱신 가능한 기본, 갱신 프리미엄, 선불 기본 또는 선불 프리미엄 프로필별로 세 가지 프로필 작성 기능을 제공할 수 있습니다.

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

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

MainActivity.kt

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

연결이 설정되면 isBillingConnected가 true로 설정되고, 그렇지 않으면 false로 설정됩니다.

false인 경우 LoadingScreen() 작성 함수가 로드되고, 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()
)

마지막으로 viewModels의 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 정기 결제 제품을 매우 간단한 앱에 성공적으로 통합했습니다.

안전한 서버 설정을 갖춘 더 정교한 앱의 문서화된 버전은 공식 샘플을 참조하세요.

추가 자료