iOS in-app purchases: Инициализация и обработка покупок

от автора

Всем привет, меня зовут Виталий, я основатель Adapty. Мы продолжаем цикл статей, посвещенных встроенным покупкам в iOS приложениях. В предыдущей части мы рассмотрели процесс создания и конфигурации встроенных покупок. В данной статье мы разберем создание простейшего пейволла (платежного экрана), а также инициализацию и обработку покупок, настроенных нами на первом этапе.

Создание экрана с подписками

В любом приложении, которое использует встроенные покупки присутствует пейволл. Есть требования от Apple, которые определяют минимальный набор необходимых элементов и поясняющих текстов для подобных экранов. На данном этапе мы не будем максимально точно выполнять их все, но наш вариант будет очень приближен к рабочему варианту.

image

Итак, наш экран будет состоять из следующих функциональных элементов:

  • Заголовок: поясняющий/продающий блоки.
  • Набор кнопок для инициализации процесса покупки. На них также будут указаны основные свойства подписок: название и цена в местной валюте (валюте магазина).
  • Кнопка восстановления прошлых покупок. Этот элемент необходим для всех приложений, в которых используются подписки либо non-consumable покупки.

Для верстки я использовал Interface Builder Storyboard. В код класса ViewController, который содержит всю необходимую логику UI я вынес связи с кнопками покупок и индикатором прогресса (UIActivityIndicatorView) для того, чтобы визуализировать процесс оплаты.

Доработка кода для отображения информации о покупках

Разберем каркас нашего ViewController. Пока что тут нет логики, мы допишем ее позднее.

import StoreKit import UIKit  class ViewController: UIViewController {      // 1:     @IBOutlet private weak var purchaseButtonA: UIButton!     @IBOutlet private weak var purchaseButtonB: UIButton!     @IBOutlet private weak var activityIndicator: UIActivityIndicatorView!      override func viewDidLoad() {         super.viewDidLoad()         activityIndicator.hidesWhenStopped = true          // 2:         showSpinner()         Purchases.default.initialize { [weak self] result in             guard let self = self else { return }             self.hideSpinner()              switch result {             case let .success(products):                 DispatchQueue.main.async {                     self.updateInterface(products: products)                 }             default:                 break             }         }     }      // 3:     private func updateInterface(products: [SKProduct]) {         updateButton(purchaseButtonA, with: products[0])         updateButton(purchaseButtonB, with: products[1])     }      // 4:     @IBAction func purchaseAPressed(_ sender: UIButton) { }      @IBAction func purchaseBPressed(_ sender: UIButton) { }          @IBAction func restorePressed(_ sender: UIButton) { } }

  1. Поля класса-проперти для связи элементов UI и нашего кода
  2. В методе viewDidLoad запускаем асинхронный процесс инициализации модуля покупок. Вообще говоря, это лучше делать на старте всего приложения, делая данный процесс независимым от слоя UI, но для простоты и наглядности сделаем это прямо здесь. Перед началом инициализации будем показывать индикатор загрузки, а по ее окончании — убирать. Для этого я написал небольшие хелпер-функции, которые привел в следующем блоке кода.
  3. Функция, которая обновляет интерфейс, используя полученные данные о покупках, такие как продолжительность пробного периода и цены.
  4. Методы-связки кнопок инициализации и восстановления покупок.

Хелперы:

extension ViewController {     // 1:     func updateButton(_ button: UIButton, with product: SKProduct) {         let title = "\(product.title ?? product.productIdentifier) for \(product.localizedPrice)"         button.setTitle(title, for: .normal)     }      func showSpinner() {         DispatchQueue.main.async {             self.activityIndicator.startAnimating()             self.activityIndicator.isHidden = false         }     }      func hideSpinner() {         DispatchQueue.main.async {             self.activityIndicator.stopAnimating()         }     } }Spinner

Обратите внимание, как здесь (1) используются объекты SKProduct. Мы не используем их поля напрямую, но сделаем extension для более удобного извлечения необходимой нам информации:

extension SKProduct {     var localizedPrice: String {         let formatter = NumberFormatter()         formatter.numberStyle = .currency         formatter.locale = priceLocale         return formatter.string(from: price)!     }      var title: String? {         switch productIdentifier {         case "barcode_month_subscription":             return "Monthly Subscription"         case "barcode_year_subscription":             return "Annual Subscription"         default:             return nil         }     } }

Дорабатываем модуль Purchases

В прошлой части мы провели инициализацию модуля покупок. Для этого мы запросили информацию о месячной и годовой подписке у серверов Apple. Я немного доработал класс Purchases для того, чтобы результат асинхронной операции было возможно отображать в интерфейсе, а также добавил сохранение объектов SKProduct в память для дальнейшего использования.

typealias RequestProductsResult = Result<[SKProduct], Error> typealias PurchaseProductResult = Result<Bool, Error>  class Purchases: NSObject {     static let `default` = Purchases()      private let productIdentifiers = Set<String>(         arrayLiteral: "barcode_month_subscription", "barcode_year_subscription"     )      private var products: [String: SKProduct]?     private var productRequest: SKProductsRequest?      func initialize(completion: @escaping (RequestProductsResult) -> Void) {         requestProducts(completion: completion)     }      private var productsRequestCallback: ((RequestProductsResult) -> Void)?      private func requestProducts(completion: @escaping (RequestProductsResult) -> Void) {         productsRequestCallback = completion          productRequest?.cancel()          let productRequest = SKProductsRequest(productIdentifiers: productIdentifiers)         productRequest.delegate = self         productRequest.start()          self.productRequest = productRequest     } }

Delegate:

extension Purchases: SKProductsRequestDelegate {     func productsRequest(_ request: SKProductsRequest, didReceive response: SKProductsResponse) {         guard !response.products.isEmpty else {             print("Found 0 products")             productsRequestCallback?(.success(response.products))             productsRequestCallback = nil             return         }          var products = [String: SKProduct]()         for skProduct in response.products {             print("Found product: \(skProduct.productIdentifier)")             products[skProduct.productIdentifier] = skProduct         }          self.products = products          productsRequestCallback?(.success(response.products))         productsRequestCallback = nil     }      func request(_ request: SKRequest, didFailWithError error: Error) {         print("Failed to load products with error:\n \(error)")         productsRequestCallback?(.failure(error))         productsRequestCallback = nil     } }

Реализация механизма покупки

Для того, чтобы полноценно сообщать об ошибках, произошедших внутри нашего кода, создадим enum PurchaseError, который будет реализовать протокол Error (или LocalizedError):

enum PurchasesError: Error {     case purchaseInProgress     case productNotFound     case unknown }

Если же во время оплаты произойдут ошибки на уровне StoreKit, то в результате мы также получим ошибку (подробнее о типах ошибок можно почитать в документации).

Функция purchaseProduct запускает процесс покупки выбранного нами продукта, а restorePurchases — запрашивает у системы список уже совершенных пользователей покупок (автовозобновляемые подписки или non-consumable покупки):

        fileprivate var productPurchaseCallback: ((PurchaseProductResult) -> Void)?      func purchaseProduct(productId: String, completion: @escaping (PurchaseProductResult) -> Void) {         // 1:         guard productPurchaseCallback == nil else {             completion(.failure(PurchasesError.purchaseInProgress))             return         }         // 2:         guard let product = products?[productId] else {             completion(.failure(PurchasesError.productNotFound))             return         }          productPurchaseCallback = completion          // 3:         let payment = SKPayment(product: product)         SKPaymentQueue.default().add(payment)     }      public func restorePurchases(completion: @escaping (PurchaseProductResult) -> Void) {         guard productPurchaseCallback == nil else {             completion(.failure(PurchasesError.purchaseInProgress))             return         }         productPurchaseCallback = completion         // 4:         SKPaymentQueue.default().restoreCompletedTransactions()     }

  1. Проверяем, что сейчас не запущен другой процесс покупки (в теории можно реализовать поддержку параллельных процессов покупки, но как правило, в этом нет никакой нужды, а вообще говоря, добавляет больше пространства для багов)
  2. Если будет попытка совершить покупку с несуществующим в нашей системе peoductId, возвращаем ошибку
  3. Добавляем в SKPaymentQueue нашу покупку
  4. Для восстановления покупок, также делаем запрос к SKPaymentQueue

Для того, чтобы обрабатывать результаты покупок, нам необходимо реализовать протокол SKPaymentTransactionObserver:

extension Purchases: SKPaymentTransactionObserver {     func paymentQueue(_ queue: SKPaymentQueue, updatedTransactions transactions: [SKPaymentTransaction]) {         // 1:         for transaction in transactions {             switch transaction.transactionState {             // 2:             case .purchased, .restored:                 if finishTransaction(transaction) {                     SKPaymentQueue.default().finishTransaction(transaction)                     productPurchaseCallback?(.success(true))                 } else {                     productPurchaseCallback?(.failure(PurchasesError.unknown))                 }             // 3:             case .failed:                 productPurchaseCallback?(.failure(transaction.error ?? PurchasesError.unknown))                 SKPaymentQueue.default().finishTransaction(transaction)             default:                 break             }         }                  productPurchaseCallback = nil     } }  extension Purchases {     // 4:     func finishTransaction(_ transaction: SKPaymentTransaction) -> Bool {         let productId = transaction.payment.productIdentifier         print("Product \(productId) successfully purchased")         return true     } }

  1. Итерируемся по массиву транзакций, обрабатывая каждую по отдельности
  2. В случае, если транзакция находится в состоянии purchased или restored, нам нужно произвести все действия, необходимые для того, чтобы пользователю стал доступен контент/подписка, после чего, закрыть транзакцию при помощи метода finishTransaction. Важно: в случае работы с consumable покупками критически важно сначала убедиться, что пользователю стал доступен контент, а только после этого закрывать транзакцию, иначе возможен кейс потери информации о покупке.
  3. По различным причинам процесс покупки может завершиться ошибкой, возвращаем эту информацию.
  4. Функция, которая вызывается на этапе 2: как раз в ней мы предоставляем пользователю купленный контент (например, запоминаем дату истечения подписки, для того чтобы UI интерпретировал пользователя, как премиум)

В данном случае мы рассмотрели не все возможные состояния транзакции. Существует также состояние purchasing (означает, что транзакция находится в процессе обработки) и deferred — транзакция отложена на неопределенное время и будет завершена позднее (например, в ожидании подтверждения от родителей). Эти состояния при необходимости также можно показывать в UI.

Вызовы в интерфейсе

Теперь осталось только вызвать данные функции из нашего ViewController, позаботившись о том, чтобы процесс был визуализирован, а всевозможные ошибки отображены пользователю.

        @IBAction func purchaseAPressed(_ sender: UIButton) {         showSpinner()         Purchases.default.purchaseProduct(productId: "barcode_month_subscription") { [weak self] _ in             self?.hideSpinner()             // Handle result         }     }      @IBAction func purchaseBPressed(_ sender: Any) {         showSpinner()         Purchases.default.purchaseProduct(productId: "barcode_year_subscription") { [weak self] _ in             self?.hideSpinner()             // Handle result         }     }      @IBAction func restorePressed(_ sender: UIButton) {         showSpinner()         Purchases.default.restorePurchases { [weak self] _ in             self?.hideSpinner()             // Handle result         }     }

Вот и все, очередной забор за нашей спиной. В следующей части рассмотрим основные способы тестирования механизма покупок. Спасибо Алексею Гончарову x401om за помощь в подготовке статьи.

ссылка на оригинал статьи https://habr.com/ru/post/512590/