[Swift, Разработка под iOS] iOS in-app purchases: Инициализация и обработка покупок
Автор
Сообщение
news_bot ®
Стаж: 6 лет 9 месяцев
Сообщений: 27286
Всем привет, меня зовут Виталий, я основатель 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
===========
Похожие новости:
- [Разработка под iOS, Разработка мобильных приложений, Swift, Машинное обучение] Новости о машинном обучении Apple в 2020 году (перевод)
- [Swift, Программирование, Разработка мобильных приложений, Разработка под iOS] MVI и SwiftUI – одно состояние
- [IT-компании, Разработка под iOS, Смартфоны, Социальные сети и сообщества] Пользователь обнаружил, что приложение Instagram в iOS 14 нештатно использует камеру. Разработчик пояснил, что это баг
- [Dart, Flutter, Разработка под Android, Разработка под iOS] Детальный разбор навигации в Flutter
- [Dart, Flutter, Программирование, Разработка мобильных приложений] Flutter под капотом: Binding
- [IT-компании, Информационная безопасность, Разработка под iOS, Смартфоны] Apple предлагает исследователям безопасности из некоторых стран специальный iPhone для работы
- [Swift, Программирование, Разработка мобильных приложений, Разработка под iOS] Swift: Копируй-изменяй
- [Разработка мобильных приложений, Разработка под iOS] Avito iOS meetup #8: CI-лайфхаки, санитайзеры, IndexStore, перформанс
- [Локализация продуктов, Разработка мобильных приложений, Разработка под iOS] Реализация наследования в файлах локализации iOS
- [C, Swift] Swift и Си: туда и обратно
Теги для поиска: #_swift, #_razrabotka_pod_ios (Разработка под iOS), #_ios, #_ios_development, #_ios_razrabotka (ios разработка), #_ios_platezhi (ios платежи), #_adapty, #_inapp_purchases, #_swift, #_razrabotka_pod_ios (
Разработка под iOS
)
Вы не можете начинать темы
Вы не можете отвечать на сообщения
Вы не можете редактировать свои сообщения
Вы не можете удалять свои сообщения
Вы не можете голосовать в опросах
Вы не можете прикреплять файлы к сообщениям
Вы не можете скачивать файлы
Текущее время: 22-Ноя 23:40
Часовой пояс: UTC + 5
Автор | Сообщение |
---|---|
news_bot ®
Стаж: 6 лет 9 месяцев |
|
Всем привет, меня зовут Виталий, я основатель Adapty. Мы продолжаем цикл статей, посвещенных встроенным покупкам в iOS приложениях. В предыдущей части мы рассмотрели процесс создания и конфигурации встроенных покупок. В данной статье мы разберем создание простейшего пейволла (платежного экрана), а также инициализацию и обработку покупок, настроенных нами на первом этапе. Создание экрана с подписками В любом приложении, которое использует встроенные покупки присутствует пейволл. Есть требования от Apple, которые определяют минимальный набор необходимых элементов и поясняющих текстов для подобных экранов. На данном этапе мы не будем максимально точно выполнять их все, но наш вариант будет очень приближен к рабочему варианту. Итак, наш экран будет состоять из следующих функциональных элементов:
Для верстки я использовал 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) { } }
Хелперы: 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() }
Для того, чтобы обрабатывать результаты покупок, нам необходимо реализовать протокол 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 } }
В данном случае мы рассмотрели не все возможные состояния транзакции. Существует также состояние 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 =========== Похожие новости:
Разработка под iOS ) |
|
Вы не можете начинать темы
Вы не можете отвечать на сообщения
Вы не можете редактировать свои сообщения
Вы не можете удалять свои сообщения
Вы не можете голосовать в опросах
Вы не можете прикреплять файлы к сообщениям
Вы не можете скачивать файлы
Вы не можете отвечать на сообщения
Вы не можете редактировать свои сообщения
Вы не можете удалять свои сообщения
Вы не можете голосовать в опросах
Вы не можете прикреплять файлы к сообщениям
Вы не можете скачивать файлы
Текущее время: 22-Ноя 23:40
Часовой пояс: UTC + 5