بيع الاشتراكات داخل التطبيق باستخدام الإصدار 5 من Play Billing Library

1. مقدمة

نظام الفوترة في Google Play هو خدمة تتيح لك بيع المنتجات الرقمية والمحتوى الرقمي في تطبيق Android. وهو الطريقة الأكثر مباشرة لبيع منتجات داخل التطبيق لتحقيق ربح من تطبيقك. يعرض لك هذا الدرس التطبيقي حول الترميز كيفية استخدام Google Play Billing Library لبيع الاشتراكات في مشروعك بطريقة تتضمّن التفاصيل الجوهرية عند دمج عمليات الشراء مع باقي تطبيقك.

وتقدّم الأداة أيضًا مفاهيم متعلّقة بالاشتراكات، مثل الخطط الأساسية والعروض الترويجية والعلامات وخطط الدفع المُسبق. لمزيد من المعلومات حول الاشتراكات في خدمة "الفوترة في Google Play"، يمكنك الانتقال إلى مركز المساعدة.

ما الذي ستنشئه

في هذا الدرس التطبيقي حول الترميز، ستُضيف أحدث مكتبة فوترة (الإصدار 5.0.0) إلى تطبيق ملف شخصي بسيط مستند إلى الاشتراك. تم تصميم هذا التطبيق لك بالفعل، لذا ما عليك سوى إضافة جزء الفوترة. كما هو موضّح في الشكل 1، في هذا التطبيق، يشترك المستخدم في أي من الخطط الأساسية و/أو العروض المقدَّمة من خلال منتجَين متوفّرَين باشتراك قابل للتجديد (أساسي ومدفوع) أو لخطة دفع مُسبَق غير قابل للتجديد. دِي كُلّْ حَاجَة. الخطط الأساسية هي اشتراكات شهرية وسنوية على التوالي. يمكن للمستخدم ترقية اشتراك مدفوع مسبقًا أو الاشتراك في خطة أقلّ كلفة أو تحويله إلى اشتراك قابل للتجديد.

d7dba51f800a6cc4.png 2220c15b849d2ead.png

لدمج Google Play Billing Library في تطبيقك، عليك إنشاء ما يلي:

  • BillingClientWrapper- برنامج تضمين لمكتبة BillingClient. وهي تهدف إلى تغليف التفاعلات مع BillingClient في "مكتبة الفوترة في Play" ولكنها غير مطلوبة في عملية الدمج الخاصة بك.
  • SubscriptionDataRepository: مستودع فوترة لتطبيقك يحتوي على قائمة بمستودع المنتجات المتوفّرة عند الاشتراك في التطبيق (أي المنتجات المعروضة للبيع)، وقائمة بمتغيّرات ShareFlow التي تساعد في جمع حالة عمليات الشراء وتفاصيل المنتجات
  • MainViewModel - نموذج عرض يتواصل من خلاله باقي تطبيقك مع مستودع الفوترة. ويساعد على بدء مسار الفوترة في واجهة المستخدم باستخدام طرق شراء مختلفة.

عند الانتهاء، يُفترض أن تبدو بنية تطبيقك مشابهة للشكل التالي:

c83bc759f32b0a63.png

المعلومات التي ستطّلع عليها

  • كيفية دمج "مكتبة الفوترة في Play"
  • كيفية إنشاء منتجات متوفرة عند الاشتراك والخطط الأساسية والعروض والعلامات من خلال Play Console
  • كيفية استرداد الخطط الأساسية والعروض المتاحة من التطبيق
  • كيفية بدء تدفق الفوترة باستخدام المعلمات المناسبة
  • كيفية عرض المنتجات المتوفّرة عند الاشتراك بالدفع المُسبق

يركّز هذا الدرس التطبيقي حول الترميز على خدمة "الفوترة في Google Play". يتم مسح المفاهيم غير ذات الصلة وكتل الرموز ويتم توفيرها لك لنسخها ولصقها ببساطة.

المتطلبات

  • إصدار حديث من "استوديو Android" (>= Arctic Fox | 2020.3.1)
  • جهاز Android يعمل بالإصدار 8.0 من نظام التشغيل Android أو إصدار أحدث
  • رمز النموذج المتوفّر لك على GitHub (التعليمات في الأقسام اللاحقة)
  • معرفة متوسطة بتطوير تطبيقات Android في "استوديو Android"
  • المعرفة بكيفية نشر تطبيق على "متجر Google Play"
  • خبرة متوسطة في كتابة كود Kotlin
  • الإصدار 5.0.0 من "مكتبة الفوترة في Google Play"

2. بدء الإعداد

الحصول على الرمز من GitHub

لقد وضعنا كل ما تحتاجه لهذا المشروع في مستودع Git. للبدء، يجب جلب الرمز وفتحه في بيئة تطوير البرامج المفضّلة لديك. في هذا الدرس التطبيقي حول الترميز، ننصحك باستخدام "استوديو Android".

يتم تخزين الرمز للبدء في مستودع جيت هب. يمكنك استنساخ المستودع باستخدام الأمر التالي:

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

3- الأساس

ما هي نقطة البداية؟

نقطة البداية هي تطبيق أساسي لملف المستخدم تم تصميمه لهذا الدرس التطبيقي حول الترميز. تم تبسيط الرمز البرمجي لعرض المفاهيم التي نريد توضيحها، ولم يتم تصميمه للاستخدام في مرحلة الإنتاج. إذا اخترت إعادة استخدام أي جزء من هذا الرمز في تطبيق علني، احرص على اتّباع أفضل الممارسات واختبار الرمز بالكامل بالكامل.

استيراد المشروع إلى "استوديو Android"

PlayالفوترةCodelab هو التطبيق الأساسي الذي لا يتضمّن طريقة تنفيذ خدمة "الفوترة في Google Play". ابدأ تشغيل "استوديو Android" واستورِد درسًا تطبيقيًا حول ترميز الفوترة عن طريق اختيار Open > billing/PlayBillingCodelab.

يتكون المشروع من وحدتين:

  • start يتضمن التطبيق الهيكلي ولكنه يفتقر إلى التبعيات المطلوبة وجميع الطرق التي تحتاج إلى تنفيذها.
  • تم الانتهاء من إكماله ويمكن أن تكون بمثابة مرشد عندما تواجهك مشكلة.

يتكوّن التطبيق من ثمانية ملفات فئات: BillingClientWrapper وSubscriptionDataRepository وComposables وMainState وMainViewModel وMainViewModelFactory وMainActivity.

  • BillingClientWrapper هو برنامج تضمين يعزل طرق [الفوترة في Google Play] المطلوبة لإجراء عملية تنفيذ بسيطة ويُصدر ردودًا على مستودع البيانات لمعالجته.
  • يُستخدَم SubscriptionDataRepository لاستخلاص مصدر بيانات "الفوترة في Google Play" (أي مكتبة برامج الفوترة) وتحويل بيانات StateFlow المنبعثة في BillingClientWrapper إلى تدفقات.
  • ButtonModel هي فئة بيانات تُستخدَم لإنشاء أزرار في واجهة المستخدم.
  • Composables تعمل على استخراج جميع الطرق القابلة للإنشاء في واجهة المستخدم إلى فئة واحدة.
  • MainState هي فئة بيانات لإدارة الدولة.
  • يتم استخدام MainViewModel للاحتفاظ بالبيانات ذات الصلة بالفوترة والحالات المستخدَمة في واجهة المستخدِم. وهي تجمع جميع التدفقات في SubscriptionDataRepository (مستودع بيانات الاشتراك) في عنصر حالة واحد.
  • MainActivity هي فئة النشاط الرئيسية التي تحمِّل "المواد القابلة للإنشاء" لواجهة المستخدم.
  • القيم الثابتة هي الكائن الذي يحتوي على الثوابت التي تستخدمها فئات متعددة.

أداة Gradle

يجب إضافة اعتمادية على Gradle كي تتمكّن من إضافة خدمة "الفوترة في Google Play" إلى تطبيقك. افتح ملف Build.gradle لوحدة التطبيق، وأضِف ما يلي:

dependencies {
    val billing_version = "5.0.0"

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

وحدة تحكُّم Google Play

لأغراض هذا الدرس التطبيقي، عليك إنشاء العرضَين التاليَين من خلال الاشتراك في قسم الاشتراكات في Google Play Console:

  • اشتراك أساسي واحد بمعرّف المنتج up_basic_sub

يجب أن يحتوي المنتج على 3 خطط أساسية (خطتان للتجديد التلقائي وخطة واحدة مسبقة الدفع) مزوّدة بعلامات مرتبطة : اشتراك شهري أساسي واحد بالعلامة monthlybasic، واشتراك سنوي أساسي بالعلامة yearlybasic، واشتراك واحد مُسبَق الدفع مع العلامة prepaidbasic.

يمكنك إضافة عروض ترويجية إلى الخطط الأساسية. ستكتسب العروض الترويجية العلامات من الخطط الأساسية المرتبطة بها.

  • اشتراك واحد مميّز بمعرّف المنتج up_premium_sub

يجب أن يحتوي المنتج على 3 خطط أساسية(خطتان تتجدّدان تلقائيًا وخطة واحدة مسبقة الدفع) مزوّدة بعلامات مرتبطة: اشتراك شهري واحد أساسي يحمل العلامة monthlypremium واشتراك سنوي أساسي يحمل العلامة yearlypremium واشتراك واحد مُسبَق الدفع باستخدام العلامة prepaidpremium.

a9f6fd6e70e69fed.png

يمكنك إضافة عروض ترويجية إلى الخطط الأساسية. ستكتسب العروض الترويجية العلامات من الخطط الأساسية المرتبطة بها.

للحصول على معلومات أكثر تفصيلاً حول كيفية إنشاء المنتجات المتوفّرة عند الاشتراك والخطط الأساسية والعروض والعلامات، يُرجى الاطّلاع على مركز مساعدة Google Play.

4. إعداد عميل الفوترة

في هذا القسم، ستعمل في الصف BillingClientWrapper.

في النهاية، سيكون لديك كل ما تحتاج إليه لإنشاء مثيل لعميل الفوترة، بالإضافة إلى جميع الطرق ذات الصلة.

  1. إعداد BillingClient

بعد إضافة طريقة اعتماد إلى Google Play Billing Library، نحتاج إلى إعداد مثيل 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 تشمل جميع عمليات الشراء التي أجراها المستخدم من خلال التطبيق. يحتوي كل عنصر شراء على سمات معرّف المنتج و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 على "صحيح" عند معالجة الإقرار بنجاح.

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، يتم نشر الردود من "QueryPurchasesAsync" و"QueryProductDetails" على التوالي على MutableStateFlow "_purchases" و"_productWithProductDetails" اللذين يظهران خارج الصف عند إجراء عمليات شراء و"productWithProductDetails".

في شهر SubscriptionDataRepository، تتم معالجة عمليات الشراء في ثلاثة مسارات استنادًا إلى منتج عملية الشراء التي تم إرجاعها: hasRenewableBasic وhasPrepaidBasic وhasRenewablePremium وhasPremiumPrepaid.

بالإضافة إلى ذلك، تتم معالجة productWithProductDetails في مسارات basicProductDetails وpremiumProductDetails ذات الصلة.

6- نموذج MainView

انتهى الجزء الصعب. أنت الآن بصدد تعريف MainViewModel، وهي مجرّد واجهة عامة لعملائك كي لا يضطروا إلى معرفة التفاصيل الداخلية لكلّ من BillingClientWrapper وSubscriptionDataRepository.

أولاً في MainViewModel، نبدأ اتصال الفوترة عند إعداد viewModel.

MainViewModel.kt

init {
   billingClient.startBillingConnection(billingConnectionState = _billingConnectionState)
}

بعد ذلك، يتم دمج التدفقات من المستودع على التوالي في productsForSaleFlows (للمنتجات المتاحة) وuserCurrentSubscriptionFlow (للاشتراك الحالي والنشط للمستخدم) كما تمت معالجتها في فئة المستودع.

تتوفر أيضًا قائمة عمليات الشراء الحالية لواجهة المستخدم من خلال currentPurchasesFlow.

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 ويتم نشر القيمة في كائن MutableLiveData يُسمى _destinationScreen.

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. استرداد الرموز المميّزة الخاصة بالخطط الأساسية والعروض

بدءًا من الإصدار 5.0.0 من Play Billing Library، يمكن أن تتضمّن جميع المنتجات المتوفّرة عند الاشتراك عدة خطط أساسية وعروض، باستثناء الخطط الأساسية المدفوعة مسبقًا التي لا يمكن أن تتضمّن عروضًا.

تساعد هذه الطريقة في استرداد جميع العروض والخطط الأساسية التي يكون المستخدم مؤهَّلاً للاستفادة منها من خلال استخدام مفهوم العلامات الذي تم طرحه مؤخرًا والذي يتم استخدامه لتجميع العروض ذات الصلة.

على سبيل المثال، عندما يحاول المستخدم شراء اشتراك شهري أساسي، سيتم وضع علامة 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()".

تعرض الطريقة الرمز المميّز لمعرّف العرض الخاص بالعرض المحدَّد.

تؤدي طريقة التنفيذ هذه ببساطة إلى عرض العروض الأقل سعرًا من حيث مجموعة 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. طريقة الشراء

تستخدم طريقة الشراء كلاً من launchBillingFlow() وBillingFlowParams في "BillingClientWrapper" لإطلاق عمليات الشراء.

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. إنهاء ربط الفوترة

وأخيرًا، يتم استدعاء طريقة terminateBillingConnection في BillingClientWrapper على onCleared() في ViewModel.

والهدف من ذلك هو إنهاء اتصال الفوترة الحالي عند التخلص من النشاط المرتبط به.

7. واجهة المستخدم

الآن، حان الوقت لاستخدام كل شيء أنشأتَه في واجهة المستخدم. لمساعدتك في ذلك ستعمل على فئات "Composables" و"MainActivity" (الأنشطة الرئيسية).

Composables.kt

يتم تقديم فئة Composables بالكامل وتحدد جميع وظائف الإنشاء المستخدمة لعرض واجهة المستخدم وآلية التنقل بينها.

تعرض الدالة Subscriptions زرَّين: Basic Subscription وPremium Subscription.

يُحمِّل كل من Basic Subscription وPremium Subscription طرق إنشاء جديدة تعرض الخطط الأساسية الثلاث ذات الصلة، وهي: شهريًا وسنويًا وخططًا مُسبقة الدفع.

بعد ذلك، تتوفّر ثلاث وظائف محتمَلة لإنشاء الملف الشخصي لكلّ مستخدم مقابل اشتراك معيّن: اشتراك أساسي متجدّد، وآخر قابل للتجديد، وملف شخصي للدفع المُسبق مقدَّم أو مدفوع.

  • عندما يكون لدى المستخدم اشتراك في خطة أساسية، يتيح الملف الشخصي للمستخدم الترقية إلى اشتراك مدفوع شهري أو سنوي أو مُسبق.
  • بالمقابل، عندما يكون لدى المستخدم اشتراك مدفوع، يمكنه الرجوع إلى اشتراك شهري أو سنوي أو أساسي مُسبق الدفع.
  • عندما يكون لدى المستخدم اشتراك دفع مُسبَق، يمكنه إضافة رصيد إلى اشتراكه باستخدام زر إضافة الرصيد أو تحويل اشتراكه المدفوع مسبقًا إلى خطة أساسية مقابلة تتجدّد تلقائيًا.

وأخيرًا، هناك وظيفة شاشة تحميل تُستخدَم عند إجراء اتصال بمتجر Google Play وعند عرض ملف شخصي للمستخدم.

MainActivity.kt

  1. عند إنشاء MainActivity، يتم إنشاء مثيل viewModel ويتم تحميل دالة إنشاء تُسمى MainNavHost.
  2. تبدأ الدالة MainNavHost بالمتغيّر isBillingConnected الذي تم إنشاؤه من البيانات المباشرة billingConnectionSate في viewModel، ويتم ملاحظة التغييرات لأنّه عند إنشاء مثيل لـ vieModel، يتم تمرير billingConnectionSate إلى طريقة startbillingConnection في BillingClientWrapper.

يتم ضبط isBillingConnected على "صحيح" عند إنشاء الاتصال وعلى "خطأ" عندما لا يتم ذلك.

عندما تكون القيمة "خطأ"، يتم تحميل دالة الإنشاء LoadingScreen() وعندما تكون القيمة "صحيحة"، يتم تحميل دالّات الملف الشخصي أو Subscription.

val isBillingConnected by viewModel.billingConnectionState.observeAsState()
  1. عند إنشاء اتصال فوترة:

تم إنشاء مثيل navController لإنشاء الرسالة.

val navController = rememberNavController()

بعد ذلك، يتم جمع التدفقات في MainViewModel.

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

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

وأخيرًا، تتم ملاحظة متغيّر destinationScreenLiveData في viewModel.

استنادًا إلى حالة الاشتراك الحالية للمستخدم، يتم عرض دالة الإنشاء المقابلة.

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 Billing Library 5.0.0 بنجاح في تطبيق بسيط جدًا.

للحصول على نسخة موثّقة من تطبيق أكثر تطوّرًا مع إعداد خادم آمن، يُرجى الاطّلاع على النموذج الرسمي.

قراءة إضافية