Bonjour à tous, je m'appelle Vitaly, je suis le fondateur d'Adapty. Nous continuons la série d'articles consacrés aux achats in-app dans les applications iOS. Dans la partie précédente, nous avons couvert le processus de création et de configuration des achats intégrés. Dans cet article, nous analyserons la création d'un paywall simple (écran de paiement), ainsi que l'initialisation et le traitement des achats que nous avons configurés dans un premier temps .
Créer un écran d'abonnement
Toute application qui utilise des achats intégrés dispose d'un paywall. Il existe des exigences d'Apple qui définissent l'ensemble minimal d'éléments requis et de textes explicatifs pour ces écrans. À ce stade, nous ne les réaliserons pas tous aussi précisément que possible, mais notre version sera très proche de la version de travail.
Ainsi, notre écran sera composé des éléments fonctionnels suivants:
- Titre: blocs explicatifs / de vente.
- Un ensemble de boutons pour lancer le processus d'achat. Ils montreront également les principales propriétés des abonnements: nom et prix en devise locale (la devise du magasin).
- Bouton de restauration des achats passés. Cet élément est requis pour toutes les applications qui utilisent des abonnements ou des achats non consommables.
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>
typealias RequestProductsCompletion = (RequestProductsResult) -> Void
typealias PurchaseProductCompletion = (PurchaseProductResult) -> Void
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 RequestProductsCompletion) {
requestProducts(completion: completion)
}
private var productsRequestCallbacks = [RequestProductsCompletion]()
private func requestProducts(completion: @escaping RequestProductsCompletion) {
guard productsRequestCallbacks.isEmpty else {
productsRequestCallbacks.append(completion)
return
}
productsRequestCallbacks.append(completion)
let productRequest = SKProductsRequest(productIdentifiers: productIdentifiers)
productRequest.delegate = self
productRequest.start()
self.productRequest = productRequest
}
}
Delegate:
extension Purchases: SKProductsRequestDelegate {
guard !response.products.isEmpty else {
print("Found 0 products")
productsRequestCallbacks.forEach { $0(.success(response.products)) }
productsRequestCallbacks.removeAll()
return
}
var products = [String: SKProduct]()
for skProduct in response.products {
print("Found product: \(skProduct.productIdentifier)")
products[skProduct.productIdentifier] = skProduct
}
self.products = products
productsRequestCallbacks.forEach { $0(.success(response.products)) }
productsRequestCallbacks.removeAll()
}
func request(_ request: SKRequest, didFailWithError error: Error) {
print("Failed to load products with error:\n \(error)")
productsRequestCallbacks.forEach { $0(.failure(error)) }
productsRequestCallbacks.removeAll()
}
}
, , , enum PurchaseError, Error ( LocalizedError):
enum PurchasesError: Error {
case purchaseInProgress
case productNotFound
case unknown
}
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 .