[Swift, Разработка под iOS] iOS in-app purchases: Инициализация и обработка покупок

Автор Сообщение
news_bot ®

Стаж: 6 лет 2 месяца
Сообщений: 27286

Создавать темы news_bot ® написал(а)
27-Июл-2020 14:31

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

Итак, наш экран будет состоять из следующих функциональных элементов:
  • Заголовок: поясняющий/продающий блоки.
  • Набор кнопок для инициализации процесса покупки. На них также будут указаны основные свойства подписок: название и цена в местной валюте (валюте магазина).
  • Кнопка восстановления прошлых покупок. Этот элемент необходим для всех приложений, в которых используются подписки либо 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) { }
}

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

Хелперы:
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()
    }

  • Проверяем, что сейчас не запущен другой процесс покупки (в теории можно реализовать поддержку параллельных процессов покупки, но как правило, в этом нет никакой нужды, а вообще говоря, добавляет больше пространства для багов)
  • Если будет попытка совершить покупку с несуществующим в нашей системе peoductId, возвращаем ошибку
  • Добавляем в SKPaymentQueue нашу покупку
  • Для восстановления покупок, также делаем запрос к 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
    }
}

  • Итерируемся по массиву транзакций, обрабатывая каждую по отдельности
  • В случае, если транзакция находится в состоянии purchased или restored, нам нужно произвести все действия, необходимые для того, чтобы пользователю стал доступен контент/подписка, после чего, закрыть транзакцию при помощи метода finishTransaction. Важно: в случае работы с consumable покупками критически важно сначала убедиться, что пользователю стал доступен контент, а только после этого закрывать транзакцию, иначе возможен кейс потери информации о покупке.
  • По различным причинам процесс покупки может завершиться ошибкой, возвращаем эту информацию.
  • Функция, которая вызывается на этапе 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 за помощь в подготовке статьи.
===========
Источник:
habr.com
===========

Похожие новости: Теги для поиска: #_swift, #_razrabotka_pod_ios (Разработка под iOS), #_ios, #_ios_development, #_ios_razrabotka (ios разработка), #_ios_platezhi (ios платежи), #_adapty, #_inapp_purchases, #_swift, #_razrabotka_pod_ios (
Разработка под iOS
)
Профиль  ЛС 
Показать сообщения:     

Вы не можете начинать темы
Вы не можете отвечать на сообщения
Вы не можете редактировать свои сообщения
Вы не можете удалять свои сообщения
Вы не можете голосовать в опросах
Вы не можете прикреплять файлы к сообщениям
Вы не можете скачивать файлы

Текущее время: 07-Май 03:55
Часовой пояс: UTC + 5