見出し画像

iOSアプリ内課金をテストし尽くす〜第1回 導入編〜[Xcode12/iOS14]

NTTレゾナントテクノロジー アジャイルデザイン部の西添です。最近、iOSアプリのアプリ内課金(In-App Purchase)を実装するために調査と実験をしていました。アプリ内課金の実装方法の解説はインターネット上にたくさんありますが、様々な場面でStoreKitがどのように振る舞うのかを紹介したサイトは見かけません。そこで、iOSアプリ内課金をStoreKit Testing in XcodeとSandboxで実験して得られた知見を、連載形式でご紹介したいと思います。第1回は導入編ということで、本連載の実験対象と実験環境を説明します。

連載目次

第1回 導入編 ←本記事
第2回 paymentQueue関数(購入時、更新時、重複購入時)
第3回 paymentQueue関数(Interrupted Purchase、Ask to Buy)
↓今後公開予定↓
第4回 paymentQueue関数(解約後の再購入)
第5回 finishTransaction()を実行しないとどうなるか
第6回 購入に失敗するケース
第7回 レシート

iOSのアプリ内課金をテストする方法

iOSのアプリ内課金をテストする方法は大きく分けて2つあります。

1. StoreKit Testing in Xcode (以下、Xcodeテストと呼びます)
Xcode 12でStoreKit Testingが新機能として追加されました。要するに、ローカル環境に閉じてアプリ内課金がテストできるようになりました。StoreKitまわりの実装段階はこちらの方法でデバッグするとよいです。当たり前ですが、この方法で取得したレシートはverifyReceipt APIで検証できないところが欠点です。

2. Sandbox (以下、Sandboxテストと呼びます)
App Store Connectのサーバと実際に通信するテスト方法です。Sandbox Tester アカウント(擬似的なApple ID)でサインインして課金をテストするため、実際にお金は払わなくて済みます。この方法で取得したレシートはverifyReceipt APIで検証できます。

XcodeテストとSandboxテストの詳しい比較については公式ドキュメント Testing at All Stages of Development with Xcode and Sandbox を参照してください。

本連載で扱うこと・扱わないこと

この連載では自動更新サブスクリプション(Auto-Renewable Subscriptions、一般には定期購読とも)を対象に、XcodeテストとSandboxテストで様々なテストシナリオを実験した結果をまとめます。
他の種類のアプリ内課金(消耗型、非消耗型、非自動更新サブスクリプション)については扱いません。

また、iOSのアプリ内課金の実装方法やXcodeテスト・Sandboxテストのやり方は説明しません。もしアプリ内課金の実装が初めてであれば、以下の動画や記事が参考になると思います。

▼この動画はプロジェクトの作成から始まり、アプリ内課金のプログラミング、Xcodeテストまでの一連の作業を実際に操作しながら解説しています。アプリ内課金の必要最小限の実装がどんなものなのかがわかるので、初心者にオススメです。

▼上の動画で実装方法をざっくり把握した後に、StoreKitフレームワークの基礎を理解するのに最適です。

▼Xcodeテストのやり方がわかりやすく紹介されています。

実験環境

本連載では、下記の環境で実験した結果を紹介します。

Mac、Xcode、iOS端末
macOS Catalina 10.15.7
Xcode 12.4
iPhone SE (2020) / iOS 14.0.1

課金アイテム設定
・Type: Auto-Renewable Subscription (自動更新サブスクリプション)
・Subscription Duration: 1ヶ月
・Family Sharing: OFF
・Introductory Offer: None
・XcodeのTime Rate: 1 second is 1 day

実験に使用したアプリ

次の機能を持つアプリを作成しました。
・自動更新サブスクリプションを購入する
・paymentQueue(_:updatedTransactions:)関数が呼ばれたら、各トランザクションの内容をコンソールに出力する
・レシートをBase64エンコードした結果をコンソールに出力する
・レシートをデコードした結果をコンソールに出力する
・レシートを取得し直す
・完了したトランザクションを復元する
・最後に観測したトランザクションをfinishTransaction()する
・各トランザクションを自動でfinishTransaction()するかしないかを切り替えられる

画面

画像1

ソースコード

Podfile
※レシートをデコードするためにASN1Decoderライブラリを使用します。

use_frameworks!

target 'SubscriptionLab' do
 pod 'ASN1Decoder'
end

AppDelegate

import UIKit
import StoreKit

@main
class AppDelegate: UIResponder, UIApplicationDelegate {
   func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
       SKPaymentQueue.default().add(IAPManager.shared)
       return true
   }

   func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration {
       return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role)
   }

   func applicationWillTerminate(_ application: UIApplication) {
       SKPaymentQueue.default().remove(IAPManager.shared)
   }
}

ViewController

import UIKit
import StoreKit

class ViewController: UIViewController {
   @IBAction func onClickPurchase(_ sender: Any) {
       IAPManager.shared.fetchProductInformation()
   }
   
   @IBAction func onClickPrintReceipt(_ sender: Any) {
       IAPManager.shared.printReceipt()
   }
   
   @IBAction func onClickPrintReceiptDetails(_ sender: Any) {
       IAPManager.shared.printReceiptDetails()
   }
   @IBAction func onClickRefreshReceipt(_ sender: Any) {
       IAPManager.shared.refreshReceipt()
   }
   
   @IBAction func onClickRestoreCompletedTransactions(_ sender: Any) {
       IAPManager.shared.restoreCompletedTransactions()
   }
   
   @IBAction func onClickFinishTransaction(_ sender: Any) {
       IAPManager.shared.finishLastTransaction()
   }

   @IBAction func onClickAutoFinishTransaction(_ sender: UISwitch) {
       IAPManager.shared.autoFinishTransaction = sender.isOn
   }
}

IAPManager
※SKProductsRequestDelegateとSKPaymentTransactionObserverを実装した独自クラスです。

import StoreKit
import ASN1Decoder

class IAPManager: NSObject {
   static let shared = IAPManager()
   private let productId = "XXXXXX"
   var autoFinishTransaction = true
   private var lastTransaction: SKPaymentTransaction?
   
   func fetchProductInformation() {
       let productRequest = SKProductsRequest(productIdentifiers: [productId])
       productRequest.delegate = self
       productRequest.start()
       // ここで productsRequest(_:didReceive:) が呼ばれる
   }
   
   func finishLastTransaction() {
       if let transaction = lastTransaction {
           SKPaymentQueue.default().finishTransaction(transaction)
       }
   }
   
   func printReceipt() {
       print(" printReceipt() >>>>>>>>>> [\(now())]")
       print(" Bundle.main.appStoreReceiptURL=\(Bundle.main.appStoreReceiptURL)")
       if let appStoreReceiptURL = Bundle.main.appStoreReceiptURL {
           print(" FileManager.default.fileExists=\(FileManager.default.fileExists(atPath: appStoreReceiptURL.path))")
           if FileManager.default.fileExists(atPath: appStoreReceiptURL.path) {
               do {
                   let receiptData = try Data(contentsOf: appStoreReceiptURL, options: .alwaysMapped)
                   let receiptBase64 = receiptData.base64EncodedString(options: [])
                   var receiptBase64Omission = ""
                   if receiptBase64.count > 40 {
                       receiptBase64Omission = "\(receiptBase64.prefix(20))...\(receiptBase64.suffix(20))"
                   }
                   print(" receipt base64 string = \(receiptBase64Omission) (length: \(receiptBase64.count))")
               }
               catch { print(" printReceipt()> Couldn't read receipt data with error: " + error.localizedDescription) }
           }
       }
       print(" printReceipt() <<<<<<<<<< [\(now())]")
   }
   
   func printReceiptDetails() {
       print(" printReceiptDetails() >>>>>>>>>> [\(now())]")
       if let appStoreReceiptURL = Bundle.main.appStoreReceiptURL {
           if FileManager.default.fileExists(atPath: appStoreReceiptURL.path) {
               do {
                   let receiptData = try Data(contentsOf: appStoreReceiptURL, options: .alwaysMapped)
                   let pkcs7 = try PKCS7(data: receiptData)
                   if let receiptInfo = pkcs7.receipt() {
                       print(" receiptCreationDate:\(receiptInfo.receiptCreationDate)")
                       print(" --------------------")
                       if let inAppPurchases = receiptInfo.inAppPurchases {
                           for (index, purchase) in inAppPurchases.enumerated() {
                               print(" [\(index)] transactionId: \(purchase.transactionId), originalTransactionId: \(purchase.originalTransactionId)")
                               print(" [\(index)] purchaseDate: \(purchase.purchaseDate), originalPurchaseDate: \(purchase.originalPurchaseDate)")
                               print(" [\(index)] expiresDate: \(purchase.expiresDate), cancellationDate: \(purchase.cancellationDate), isInIntroOfferPeriod: \(purchase.isInIntroOfferPeriod)")
                               print(" --------------------")
                           }
                       }
                   }                }
               catch { print(" printReceiptDetails()> Couldn't read receipt data with error: " + error.localizedDescription) }
           }
       }
       print(" printReceiptDetails() <<<<<<<<<< [\(now())]")
   }
   
   func refreshReceipt(){
       print(" refreshReceipt() >>>>>>>>>> [\(now())]")
       let refresh = SKReceiptRefreshRequest()
       refresh.delegate = self
       refresh.start()
       print(" refreshReceipt() <<<<<<<<<< [\(now())]")
   }
   
   func restoreCompletedTransactions(){
       print(" restoreCompletedTransactions() >>>>>>>>>> [\(now())]")
       SKPaymentQueue.default().restoreCompletedTransactions()
       print(" restoreCompletedTransactions() <<<<<<<<<< [\(now())]")
   }
   
   private func now() -> String {
       let dateFormatter = DateFormatter()
       dateFormatter.dateStyle = .none
       dateFormatter.timeStyle = .medium
       let now = Date()
       return dateFormatter.string(from: now)
   }
}

extension IAPManager: SKProductsRequestDelegate {
   func productsRequest(_ request: SKProductsRequest, didReceive response: SKProductsResponse) {
       if !response.products.isEmpty {
           let payment = SKPayment(product: response.products[0])
           SKPaymentQueue.default().add(payment)
           // ここでAppStoreの課金ダイアログが表示される
       }
   }
}

extension IAPManager: SKPaymentTransactionObserver {
   func paymentQueue(_ queue: SKPaymentQueue, updatedTransactions transactions: [SKPaymentTransaction]) {
       print(" paymentQueue(_:updatedTransactions:) >>>>>>>>>> [\(now())]")
       for (index, transaction) in transactions.enumerated() {
           print(" ---------- transaction[\(index)] ----------")
           print(" Transaction ID = \(transaction.transactionIdentifier), State = \(transaction.transactionState.name)")
           print(" Original Transaction ID = \(transaction.original?.transactionIdentifier), State = \(transaction.original?.transactionState.name)")
           print(" transaction.error = \(transaction.error)")
           switch transaction.transactionState {
           case .purchasing:
               break
           case .purchased:
               break
           case .failed:
               if let error = transaction.error as? NSError {
                   print(" error.domain = \(error.domain), error.code = \(error.code)")
                   print(" error.userInfo = \(error.userInfo)")
                   print(" error.localizedDescription = \(error.localizedDescription)")
                   print(" error.localizedFailureReason = \(error.localizedFailureReason)")
                   print(" error.localizedRecoveryOptions = \(error.localizedRecoveryOptions)")
                   print(" error.localizedRecoverySuggestion = \(error.localizedRecoverySuggestion)")
                   if error.domain == SKErrorDomain {
                       switch error.code {
                       case SKError.unknown.rawValue:
                           print(" .failed> an unknown or unexpected error occurred.")
                       case SKError.paymentCancelled.rawValue:
                           print(" .failed> the user canceled a payment request.")
                       case SKError.paymentNotAllowed.rawValue:
                           print(" .failed> the user is not allowed to authorize payments.")
                       default:
                           break
                       }
                   }
               }
           case .restored:
               break
           case .deferred:
               break
           @unknown default:
               break
           }
           if transaction.transactionState != .purchasing && autoFinishTransaction {
               SKPaymentQueue.default().finishTransaction(transaction)
           }
           lastTransaction = transaction
       }
       print(" paymentQueue(_:updatedTransactions:) <<<<<<<<<< [\(now())]")
   }
}

extension SKPaymentTransactionState {
   var name: String {
       switch self {
       case .purchasing:
           return ".purchasing"
       case .purchased:
           return ".purchased"
       case .failed:
           return ".failed"
       case .restored:
           return ".restored"
       case .deferred:
           return ".deferred"
       default:
           return ""
       }
   }
}

宣伝

NTTレゾナントテクノロジーでは一緒に働いてくれるAndroid/iOSアプリエンジニアを募集中です。もし興味がありましたら採用ページをご覧ください。

この記事が気に入ったら、サポートをしてみませんか?
気軽にクリエイターの支援と、記事のオススメができます!
NTTレゾナント・テクノロジーは「Agile Transformation」をミッションに、「デザイン思考 + リーン + アジャイル」を駆使し、驚きのある「すごい!」サービスを世の中に作り出していきます。https://www.nttr-tech.co.jp/