Swift3で課金処理を行った。

  • 41
    いいね
  • 0
    コメント
この記事は最終更新日から1年以上が経過しています。

はじめに

課金処理をアプリ内で完結する場合のコードを考察しました。
・・・が、ピュアなSwiftだけだと煩雑になるのとファミリー課金やイレギュラー処理まで考えるととても複雑そうになったので挫折しました。
とりあえず、最低限のコードを記載しておきます。課金のコードはいろいろ悩むと思うので誰かの参考になれば幸いです。

はじめにプログラムの使い方を記載して、最後に使用するクラスを記載しておきます。

※誤っている点や補足などがありましたらコメントにてご連絡ください。

課金情報を表示する

課金情報の取得には時間がかかるので、タイトル画面などで事前に製品情報を取得しておきます。
引数に渡すのは製品ID(String)の配列です。

// 表示したい製品IDを事前に渡しておきます。
StoreKitAccessor.instance.cacheProducts(productIdentifiers: ["製品IDa", "製品IDb"])

その後、金額を表示する場面では以下のように行う。

if let product = StoreKitAccessor.instance.getCacheProduct(productIdentifier: "製品IDa") {
  // 「$1」などの課金価格を表示する。
  label.text = StoreKitAccessor.priceForProduct(product: product)
}

購入処理

大まかですが、購入処理は以下のような流れになります。

if !StoreKitAccessor.canMakePayments() {
    // 購入処理が無効になっている。
}

// 画面をロックする。

// キャッシュにも商品データは持ってますが、念のため再度商品情報を取得します。
StoreKitAccessor.instance.getProduct(productIdentifier: "製品ID") { [weak self] (product) in
    // 製品がない場合は処理を終了する。
    guard let product = product else {
        // 設定ミスかネットワークに接続されてない。
        return
    }

    // 購入処理
    StoreKitAccessor.instance.buy(product: product, callback: { [weak self] (productIdentifier) in
        if productIdentifier == "製品ID" {
            // 購入成功

            // 購入処理とロック解除
        } else {
            // 処理失敗(キャンセルやシミュレーターの場合)
        }
        })
}

リストア処理

リストア処理は購入処理とほとんど一緒です。
非消耗型の商品を販売する場合には必ず組み込まないとリジェクトされます。

if !StoreKitAccessor.canMakePayments() {
    // 購入処理が無効になっている。
}

// 画面をロックする。

// リストアします。
StoreKitAccessor.instance.restoreProducts { [weak self] (productIdentifier) in
    // 製品がない場合は処理を終了する。
    if productIdentifier == "製品ID" {
        // 購入成功

        // 復元処理と画面ロック解除
    } else {
        // 処理失敗
    }
}

使っているコード

上記で使っているコードは以下になります。

// Apple Storeの情報取得、課金周りの処理
class StoreKitAccessor: NSObject, SKProductsRequestDelegate, SKPaymentTransactionObserver {

    // 課金アイテムの購入可否
    static func canMakePayments() -> Bool {
        return SKPaymentQueue.canMakePayments()
    }

    // SKProduct情報から国に合わせた金額を取得して表示します。
    static func priceForProduct(product:SKProduct) -> String {
        let numberFormatter = NumberFormatter()
        numberFormatter.formatterBehavior = .behavior10_4
        numberFormatter.numberStyle = .currency
        numberFormatter.locale = product.priceLocale
        return numberFormatter.string(from: product.price)!
    }

    // シングルトンで処理をする。
    static let instance = StoreKitAccessor()

    // 一時的に保持する課金情報
    private var cacheProducts:[String: SKProduct] = [String: SKProduct]()

    // 商品情報取得のコールバック
    private var getProductionCallback:((SKProduct?)->())?

    // 購入処理・リストア処理のコールバック
    private var buyProductionCallback:((String?) -> ())?

    /***
     * 商品情報関連の処理
     */

    // 使用する商品(課金)情報を事前に取得する。
    // アプリ内で定義していて、事前に金額を表示したい商品IDを全て渡します。
    func cacheProducts(productIdentifiers:[String]) {
        // 指定のプロダクト情報を全て取得します。
        let productsRequest = SKProductsRequest(productIdentifiers: Set(productIdentifiers))
        productsRequest.delegate = self
        productsRequest.start()
    }

    // キャッシュしてる商品情報を返却する。
    func getCacheProduct(productIdentifier:String) -> SKProduct? {
        return self.cacheProducts[productIdentifier]
    }

    // 指定した一つの商品情報を取得します。
    // 本メソッドの処理中は画面ロックされていることを想定しており、複数同時に本メソッドが呼び出されることは想定してない。
    // 購入処理時に使用してください。(事前キャッシュ情報が購入時に変更される可能性があるため)
    func getProduct(productIdentifier:String, callback:@escaping (SKProduct?)->()) {
        // コールバックを保存して商品情報を取得します。
        self.getProductionCallback = callback
        // 指定のプロダクト情報を取得します。
        let productsRequest = SKProductsRequest(productIdentifiers: Set([productIdentifier]))
        productsRequest.delegate = self
        productsRequest.start()
    }

    // 商品情報取得に成功した場合
    func productsRequest(_ request: SKProductsRequest, didReceive response: SKProductsResponse) {
        // キャッシュデータを更新する。
        for product in response.products {
            self.cacheProducts[product.productIdentifier] = product
            // コールバックが定義されている場合はコールバックを返す。(本処理では一件しか呼び出されない。)
            self.getProductionCallback?(product)
        }

        // 使用したコールバックを削除する。
        self.getProductionCallback = nil
    }

    // 商品情報取得に失敗した場合
    func request(_ request: SKRequest, didFailWithError error: Error) {
        // TODO Crashlyticsなどにエラーログを飛ばす。

        // コールバックが記載されていた場合はコールバックを返す。
        self.getProductionCallback?(nil)
        self.getProductionCallback = nil
    }

    // 商品情報取得終了処理
    func requestDidFinish(_ request: SKRequest) {
        // 商品情報取得終了時に行いたいことがあればこちらで対応する。
    }

    /***
     * 商品購入関連の処理
     */

    // 購入処理
    // 本メソッドの処理中は画面ロックされていることを想定しており、複数同時に本メソッドが呼び出されることは想定してない。
    // ※リストア処理と同じコールバックを使っているので注意!
    func buy(product:SKProduct, callback:@escaping (String?) -> ()) {
        self.buyProductionCallback = callback

        SKPaymentQueue.default().add(self)
        SKPaymentQueue.default().add(SKPayment(product: product))

        // 複数の購入処理を同時に行うこともできるが、使い方が想定できない。
//        for product in products {
//            SKPaymentQueue.default().add(SKPayment(product: product))
//        }
    }

    // 購入した商品をリストアする。
    // 本メソッドの処理中は画面ロックされていることを想定しており、複数同時に本メソッドが呼び出されることは想定してない。
    // ※購入処理と同じコールバックを使っているので注意!
    func restoreProducts(callback:@escaping (String?) -> ()) {
        self.buyProductionCallback = callback

        // 自分をQueueに追加して、結果を待ち構えます。
        SKPaymentQueue.default().add(self)
        SKPaymentQueue.default().restoreCompletedTransactions()
    }

    // 課金処理成功
    func paymentQueue(_ queue: SKPaymentQueue, updatedTransactions transactions: [SKPaymentTransaction]) {
        for transaction in queue.transactions {
            switch transaction.transactionState {
            case .purchased:
                // 成功処理
                self.buyProductionCallback?(transaction.payment.productIdentifier)
                self.buyProductionCallback = nil

                queue.finishTransaction(transaction)

            case .restored:
                // 購入が中断された場合
                self.paymentQueueRestoreCompletedTransactionsFinished(queue)
                queue.finishTransaction(transaction)
            case .deferred:
                // ファミリー共有待機処理
                // (本処理では購入失敗処理に遷移させる。)
                queue.finishTransaction(transaction)
                self.buyProductionCallback?(nil)
                self.buyProductionCallback = nil
            case .failed:
                // 処理失敗
                queue.finishTransaction(transaction)
                self.buyProductionCallback?(nil)
                self.buyProductionCallback = nil
            case .purchasing:
                // 処理中
                break
            }
        }
    }

    // リストア処理成功処理
    func paymentQueueRestoreCompletedTransactionsFinished(_ queue: SKPaymentQueue) {
        // リストアされた商品をチェックする
        for transaction in queue.transactions {
            // 本処理では一つの製品に対する処理しか考慮していないためコールバックを解放する。
            self.buyProductionCallback?(transaction.payment.productIdentifier)
            self.buyProductionCallback = nil
        }

        // 終了したことを通知する。
        // ※RxSwiftのOnCompleteを使ったほうがスマートに記載できるので、本体の実装が汚くなる場合はRxSwiftの導入をお勧めします。
        // 本実装では誰でも使えるようにRxSwiftは導入してません。
        self.buyProductionCallback?(nil)
        self.buyProductionCallback = nil
    }

    // リストア処理に失敗
    func paymentQueue(_ queue: SKPaymentQueue, restoreCompletedTransactionsFailedWithError error: Error) {
        self.buyProductionCallback?(nil)
        self.buyProductionCallback = nil
    }

    // 全てのトランザクションが終わった場合の処理
    func paymentQueue(_ queue: SKPaymentQueue, removedTransactions transactions: [SKPaymentTransaction]) {
        SKPaymentQueue.default().remove(self)
    }
}

まとめ

サーバーサイドも考えると、レシートを取得してサーバーに送ってAppleと照合するなどの処理がありますが個人でサーバーを使わないレベルなら上記のコードでもある程度耐えれるのではないでしょうか?

あと、冒頭にも書いてますがRxSwiftなどを使ったほうが本体のコードはすっきりします。
しかし、RxSwiftを知らない人もいるかと思ったのであえてピュアなコードのまま公開しました。
とりあえずコピペで動かしたい人は是非コピペしてみてください。

(商品の作り方などは別のサイトを参照してください。)

最後に

ちゃんと学びたい人はIn-App Purchase プログラミングガイドを読んだほうがいいです。
僕は少し古い知識で実装しているので、誤っている箇所があるかもしれません。

もちろんですが、本記事で損害を被っても一切保証致しません。