In-App Purchase。iOSの購入処理ですが、StoreKitで素でやるとなるとpaymentQueueなんたらにいろいろ書いていくわけですがこれがなかなかめんどくさい。サンドボックス環境とプロダクション環境の分岐をロジックにあらかじめ入れておく必要がある(=リリース後にはじめて通るブロックがある)というのも厄介です。
最初は苦労しながらやってましたけど、今はこれ使っちゃってます。めっちゃ楽。
今回は自動更新型プロダクト限定です。
In-App PurchaseのCertificationやCapabilityの設定、AppStoreConnectでのプロダクト登録処理などは割愛。
実施環境
Xcode 10.1 Swift 4.2大人の事情でまだSwift5に移行できてないんです…
インストール
CocoaPodsが嫌いな自分はCarthage(カートハゲw、でなくカーセッジ)でインストールです。Cartfile
はこんな感じです。
Swift 4.2でビルドしたいのでそれに対応したバージョンを指定してます。
github "bizz84/SwiftyStoreKit" == 0.14.0
SwiftyStoreKitのビルド
# carthage update --platform iOS SwiftyStoreKit
{ProjectRoot}/Carthage/Build/iOS/SwiftyStoreKit.framework
を追加
Run Script の設定
Carthageのパスは各自のインストールパスで。
Run Script Only installing
にチェックを入れるとストアアップ時に必要なライブラリがアップされず、運が悪いとリリース後にアプリがコケる原因となるので気をつけてください(経験済)。
実装編
アプリ起動時にトランザクションの監視を開始します。(アップルさん推奨)import SwiftyStoreKit
func application(application: UIApplication, didFinishLaunchingWithOptions launchOptions: [NSObject: AnyObject]?) -> Bool {
// see notes below for the meaning of Atomic / Non-Atomic
SwiftyStoreKit.completeTransactions(atomically: true) { purchases in
for purchase in purchases {
switch purchase.transaction.transactionState {
case .purchased, .restored:
if purchase.needsFinishTransaction {
// Deliver content from server, then:
SwiftyStoreKit.finishTransaction(purchase.transaction)
}
// Unlock content
case .failed, .purchasing, .deferred:
break // do nothing
}
}
}
return true
}
completeTransactionsのatomically
がfalse
の場合、purchase.needsFinishTransaction
はtrue
になります。PaymentQueueのステータスを購入完了に更新する前に何か処理をしたい場合はatomically
にfalse
をセットします。
上記のコードの場合、atomically=true(購入完了のトランザクションは分解不可)となっていますので、if purchase.needsFinishTransaction{} のブロックは通らず、purchased、restoredの検知の直後にfinishTransaction()が実行されます。
トランザクションの種類
transactionState(SKPaymentTransactionState) | 状態 |
---|---|
purchased | 購入済み |
restored | リストア(購入復元)済み |
failed | トランザクション異常終了 |
purchasing | Appストアが処理中 |
deferred | 親垢(AppleID)の承認待ち(ペアレンタルコントロール) |
プロダクト情報取得
SwiftyStoreKit.retrieveProductsInfo(["your-product-id"]) { result in
if let product = result.retrievedProducts.first {
let priceString = product.localizedPrice!
print("Product: \(product.localizedDescription), price: \(priceString)")
}
else if let invalidProductId = result.invalidProductIDs.first {
print("Invalid product identifier: \(invalidProductId)")
}
else {
print("Error: \(result.error)")
}
}
AppStoreConnectの管理画面でプロダクトを登録しても、「契約/税金/口座情報」の設定ができてないと取得に失敗しますので注意です。
購入(プロダクトIDをパラメタに)
SwiftyStoreKit.purchaseProduct("your-product-id", quantity: 1, atomically: false) { result in
switch result {
case .success(let product):
// fetch content from your server, then:
if product.needsFinishTransaction {
SwiftyStoreKit.finishTransaction(product.transaction)
}
print("Purchase Success: \(product.productId)")
case .error(let error):
switch error.code {
case .unknown: print("Unknown error. Please contact support")
case .clientInvalid: print("Not allowed to make the payment")
case .paymentCancelled: break
case .paymentInvalid: print("The purchase identifier was invalid")
case .paymentNotAllowed: print("The device is not allowed to make the payment")
case .storeProductNotAvailable: print("The product is not available in the current storefront")
case .cloudServicePermissionDenied: print("Access to cloud service information is not allowed")
case .cloudServiceNetworkConnectionFailed: print("Could not connect to the network")
case .cloudServiceRevoked: print("User has revoked permission to use this cloud service")
default: print((error as NSError).localizedDescription)
}
}
}
atomically
がtrue
であれば、if product.needsFinishTransaction{} のブロックは不要です。finishTransaction() は購入成功のステータス検知後直ちに実行されます。
購入(SKProductオブジェクトをパラメタに)
SwiftyStoreKit.retrieveProductsInfo(["your-product-id"]) { result in
if let product = result.retrievedProducts.first {
SwiftyStoreKit.purchaseProduct(product, quantity: 1, atomically: true) { result in
// handle result (same as above)
}
}
}
プロダクト情報の取得と購入処理を1セッションで行いたい場合に使えますね。
リストア
SwiftyStoreKit.restorePurchases(atomically: false) { results in
if results.restoreFailedPurchases.count > 0 {
print("Restore Failed: \(results.restoreFailedPurchases)")
}
else if results.restoredPurchases.count > 0 {
for purchase in results.restoredPurchases {
// fetch content from your server, then:
if purchase.needsFinishTransaction {
SwiftyStoreKit.finishTransaction(purchase.transaction)
}
}
print("Restore Success: \(results.restoredPurchases)")
}
else {
print("Nothing to Restore")
}
}
複数のプロダクトがある場合はproductId
プロパティ(上のfor文中ならpurchase.productId
)を調べてそれぞれに応じた処理を書きましょう。atomicallyについては前述通りです。
ローカルレシートの取得
let receiptData = SwiftyStoreKit.localReceiptData
let receiptString = receiptData.base64EncodedString(options: [])
// do your receipt validation here
アップルからレシート取得
SwiftyStoreKit.fetchReceipt(forceRefresh: true) { result in
switch result {
case .success(let receiptData):
let encryptedReceipt = receiptData.base64EncodedString(options: [])
print("Fetch receipt success:\n\(encryptedReceipt)")
case .error(let error):
print("Fetch receipt failed: \(error)")
}
}
forceRefresh
がtrueの場合、ローカルレシートが取得結果に更新されます。
自動更新型のレシート検証と継続中か期限切れかのチェック
let appleValidator = AppleReceiptValidator(service: .production, sharedSecret: "your-shared-secret")
SwiftyStoreKit.verifyReceipt(using: appleValidator) { result in
switch result {
case .success(let receipt):
let productId = "subscription_product_identifier"
// Verify the purchase of a Subscription
let purchaseResult = SwiftyStoreKit.verifySubscription(
ofType: .autoRenewable, // or .nonRenewing (see below)
productId: productId,
inReceipt: receipt)
switch purchaseResult {
case .purchased(let expiryDate, let items):
print("\(productId) is valid until \(expiryDate)\n\(items)\n")
case .expired(let expiryDate, let items):
print("\(productId) is expired since \(expiryDate)\n\(items)\n")
case .notPurchased:
print("The user has never purchased \(productId)")
}
case .error(let error):
print("Receipt verification failed: \(error)")
}
}
引数のservice
は.production
で常時OKです。サンドボックスへの分岐はSwiftyStoreKitがやってくれます。すばらしい(^^
期限切れか否かの判定をしてくれるのが助かりますね。
残念ながらローカルレシートの検証は非サポートみたいです。
必要な場合はレシート検証 プログラミングガイドを参考にがんばるしかなさそうです。
自動更新型の購入とレシート検証を同時にやる
let productId = "your-product-id"
SwiftyStoreKit.purchaseProduct(productId, atomically: true) { result in
if case .success(let purchase) = result {
// Deliver content from server, then:
if purchase.needsFinishTransaction {
SwiftyStoreKit.finishTransaction(purchase.transaction)
}
let appleValidator = AppleReceiptValidator(service: .production, sharedSecret: "your-shared-secret")
SwiftyStoreKit.verifyReceipt(using: appleValidator) { result in
if case .success(let receipt) = result {
let purchaseResult = SwiftyStoreKit.verifySubscription(
ofType: .autoRenewable,
productId: productId,
inReceipt: receipt)
switch purchaseResult {
case .purchased(let expiryDate, let receiptItems):
print("Product is valid until \(expiryDate)")
case .expired(let expiryDate, let receiptItems):
print("Product is expired since \(expiryDate)")
case .notPurchased:
print("This product has never been purchased")
}
} else {
// receipt verification error
}
}
} else {
// purchase error
}
}
SwiftyStoreKit.verifyReceipt(using: appleValidator)
の部分、実はローカルレシートかApple持ちのレシートかを指定できます。ローカルレシートを指定する場合は、
SwiftyStoreKit.verifyReceipt(using: appleValidator,forcerefresh:false)
とします。こうすることでStoreへの接続回数を減らすことができます。