アプリ内課金
Swift
In-App-Purchase

iOSアプリ初心者がswiftでアプリ内課金の実装をやってみた

More than 3 years have passed since last update.

初心者御用達のやってみたシリーズです。

お仕事で新卒ながらobjective-cからswiftへの移行を任されました。
ちなみにobjective-cもswiftも全くの未経験です。社内にも詳しい人がいません…ナンテコッタ
最初obcを見た時は、何でこんなにたくさん配列があるの?って思いました

今回は同じようなswift初心者(swiftからiOSアプリ開発に入った人)でアプリ内課金を実装する人の助けになりそうなことをいろいろまとめていきたいと思います。
主に、実装・コーディングに関することが中心です。

Appleのドキュメント

Apple公式の 若干不親切で意味が分かりそうで分からない 日本語ドキュメントはこちらにあります。
「In App Purchase プログラミングガイド」とか「レシート検証プログラミングガイド」が、
アプリ内課金で必要なドキュメントかと思います。(PDFに直リンク貼って切れてるサイトを何回も見たので避けます)
まずは読んで、仕様を理解しましょう。
特に、プロダクトモデル(課金アイテムの種類)はちゃんと理解してください。でないと僕みたいにハマります。
理解しないままどうにかしようとした自分が悪いのですが…
既存コードの書き直しだし、どうにかなるだろうと思っていた時期が僕にもありました。

仕様と全体の流れ

仕様

  • プロダクトモデルは「非更新購読(Non-renewable subscriptions)」
  • ある一定期間だけ◯◯ができる権利を提供する様なサービス形態
  • アップルストアで課金をしてもらい、自分のサーバで有効期間を管理する

登場人物

  • アプリ
  • プロダクト(課金アイテム)
  • レシート(課金証明書)
  • StoreKit
  • AppleStore
  • マイサーバ
  • これらに頭を悩ませる新卒1名

全体の流れ

  1. アプリでユーザが課金ボタンを押す
  2. アプリはStoreKitのAPIを叩いてプロダクトを確認
  3. 再びStoreKitのAPIを叩いて、AppleStoreに課金処理の要求
  4. ユーザ操作により正常に課金処理が完了
  5. アプリが課金処理の完了通知を受け、レシートを取得、マイサーバに送信する
  6. マイサーバはAppleStoreにレシートの有効性を問い合わせ、確認できたらレシートを保存し、サービスの提供を開始する

レシートについて

こいつの理解に一番苦労させられました。
レシートは購入履歴の一覧が記載されており、課金をしたことの証明書として利用されます。

レシートの取得方法

let receiptUrl: NSURL = NSBundle.mainBundle().appStoreReceiptURL
println(receiptUrl.absoluteString!) // file:///private/var/mobile...

上記の様なコードでファイルの場所を取得することができるのですが、

let fileManager = NSFileManager.defaultManager()
println(fileManager.fileExistsAtPath(receiptUrl.absoluteString!)) // false

と書いてもfalse、fileManagerを使ってレシートを取得しようとしても取れてきません。
「What's Apple people!!!!」という声が聞こえてきます。
しかしながら最終的には、

if let receiptUrl: NSURL = NSBundle.mainBundle().appStoreReceiptURL {    
    if let receiptData: NSData = NSData(contentsOfURL: receiptUrl) {
        let receiptBase64Str: String = receiptData.base64EncodedStringWithOptions(NSDataBase64EncodingOptions.allZeros)
        plintln(receiptBase64Str)
    } else {
        // 取得できないのでエラー処理
    }
}

というコードで取得ができました。
「file:///private/var/mobile...」
となっているので、ローカルにファイルがいると思うのですが、一体どういう仕組なんでしょうか?
誰か詳しい方がいましたら、教えてください。

追記:2015/06/12
@sasho さんからコメントにて解答を頂きました。ありがとうございます。
確かに、ファイルパスとしては不正なんですよね。納得。

「if let = 」ってなんだよ!って方は「swift optional binding」でググりましょう。便利です。

レシートに関する注意点

レシートには購入履歴の一覧が記載されていると書きましたが、若干違います。
一部のプロダクトデザイン(課金形態)は購入処理が完了するとレシートから削除されてしまいます。

「In App Purchase プログラミングガイド」の p26:Appレシートを使用した持続(2015/06/10時点)に記載されているのですが、

消耗型プロダクトと非更新購読の情報は、支払いが行われるとシートに追加され、トランザクションを終了するまでレシート上に残ります。
トランザクションの終了後、この情報はレシートが次に更新されるとき、たとえばユーザが次に購入を行ったときに削除されます。

これ以外の種類のプロダクト購入の情報は、支払いが行われるとレシートに追加され、レシートに残り続けます。

ということです。また、p33:トランザクションの終了(2015/06/10時点)にある、

注意: 終了していないトランザクションをアプリケーションで追跡するための他のメカニズムを使用するため、トランザクションが実際に完了する前にfinishTransaction:メソッドを呼び出そうとしないでください。Store Kitは、このような方法で使用されるようにはデザインされていません。これを行うと、Appleによってホストされたコンテンツがアプリケーションでダウンロードできなくなり、さらに他の問題にもつながることがあります。

という注意喚起から

逆に言えばトランザクションを正常終了させるまではレシートに残る

と理解できます。
トランザクションを正常終了させるまでは繰り返し購入完了のデリゲートメソッドが呼ばれるのはそういうことなんですよね。(僕はそう理解しました)

消費型プロダクトと非更新購読はAppleStoreを利用したリストア処理ができない(必要がない)ということです。
リストア(復元)処理を実装する必要があるようなコンテンツ・サービスでは適切なプロダクトデザインを選択しましょう。間違っても消費型プロダクトを選択してはいけません。
また、非更新購読は自身のサーバでユーザ情報を管理し、ユーザが複数の端末から利用する場合も適切にコンテンツが提供されるように実装する必要があります。

レシートの保存

これは自分の理解で合っているのかが不安なところです。
今回調べててよく見かけたのが「レシートは必ずローカルに保存しろ!」というものです。保存するのは別に良いのですが、保存した後何に使うかが書かれていません。保存するだけです。
ずっとこの解を探していましたが、ドキュメントを読んで見つけました。「In App Purchase プログラミングガイド」p25:購入の持続(2015/06/10時点)です。
さらに、ここに記載されている購入の持続の手段をどれかしら取っているならば、必ずしもローカルに保存する必要はないはずです(持論)。

実装

全体の流れ1はUIイベント、4はユーザの操作、6はサーバサイドの実装となるため割愛します。

課金処理のベースの実装に関してはこちらの「アプリ内課金アイテムの情報(SKProduct)を取得する」と「アプリ内課金(Delegateモデル)」が大変参考になります。
ここにたどり着かなかったら自分は積んでいたかも。
デリゲートメソッドの勉強にもなりました。感謝です。
ちなみに上記2つの記事の内容で全体の流れ2, 3の実装が完了します。

続いて、全体の流れ5の実装となるわけですが、
レシートの取得方法は書いてしまったので、あとはサーバサイドにデータをPOSTするだけです。
レシートの取得からPOSTするまでのコードは以下のようになります。

if let receiptUrl: NSURL = NSBundle.mainBundle().appStoreReceiptURL {
    if let receiptData: NSData = NSData(contentsOfURL: receiptUrl) {
        let receiptBase64Str: String = receiptData.base64EncodedStringWithOptions(NSDataBase64EncodingOptions.allZeros)

        if let url = NSURL(string: POST_RECEIPT_URL) {
            let request: NSMutableURLRequest = NSMutableURLRequest(URL: url)
            request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField:"content-type")
            request.timeoutInterval = 5.0
            request.HTTPMethod = "POST"
            request.HTTPBody = "receipt-data=\(receiptBase64Str)"
                .dataUsingEncoding(NSUTF8StringEncoding)

            if let data: NSData = NSURLConnection.sendSynchronousRequest(request, returningResponse: nil, error: nil) {
                // result処理
            }
        }
    }
}

エラー処理は適宜行ってください。
また、今回の仕様では必要ありませんが、アプリからAppleStore(Sandbox)に検証をリクエストする場合は、以下のようになります。

if let receiptUrl: NSURL = NSBundle.mainBundle().appStoreReceiptURL {
    if let receiptData: NSData = NSData(contentsOfURL: receiptUrl) {
        let receiptBase64Str: String = receiptData.base64EncodedStringWithOptions(NSDataBase64EncodingOptions.allZeros)
        let requestContents: NSDictionary = ["receipt-data": receiptBase64Str] as NSDictionary
        let requestData: NSData = NSJSONSerialization.dataWithJSONObject(requestContents, options: NSJSONWritingOptions.allZeros, error: nil)!
        let sandboxUrl: NSURL = NSURL(string: "https://sandbox.itunes.apple.com/verifyReceipt")!
        let request: NSMutableURLRequest = NSMutableURLRequest(URL: sandboxUrl)

        request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField:"content-type")
        request.timeoutInterval = 5.0
        request.HTTPMethod = "POST"
        request.HTTPBody = requestData

        if let data: NSData = NSURLConnection.sendSynchronousRequest(request, returningResponse: nil, error: nil) {
            // result処理
        }
    }
}

一応ですが、AppleStoreにリクエストを送信すると、statusコードと購入情報がJSON形式で帰ってくるので、中を見て処理を書けば良いと思います。
ココらへんは公式ドキュメントに書いてあるので、そちらを読んでください。

終わりに

ドキュメントは必ず目を通しましょう。
特に、レシートから購入情報が消える件を知るまでは、最新のレシート一つでいいじゃん?と思ったりしていました。

これで「iOSアプリ初心者がswiftでアプリ内課金の実装をやってみた」は以上になりますが、誰かの目に止まって誰かの役に立てばと思います。