iPhoneで Grafanaの グラフを 参照できる アプリ Grafanizer 作ってます。 詳しくは こちらへ

SwiftyStoreKitを使ってみた

Jan 28, 2017  
#swift #dev

問題発生

Grafanizer にアプリ内課金で広告の非表示機能をつけたのだが、最初のリリースではRestoreの処理がうまく動いてなかった。リストアボタンをタッチすれば、課金してなくても課金したとの判定になるというお粗末なバグ。

もうFIX済みなのだが、非消耗型(Non-consumable)のRestore機能はどう実装してもうまく実装できなかったので SwiftyStoreKit を導入してみたところこれが大正解。なんでもっと早く採用しなかったんだろうと後悔するレベルなので、今後同じように躓く人達のためにちょっと記録を残しておく。

独自実装で何が問題だったかというと、

  • リストアボタンをタッチ
  • SKPaymentQueue.default().restoreCompletedTransactions() を実行
  • その後呼ばれる paymentQueue:updatedTransactions.restored が返ってくる
  • リストア完了

という流れだと思っているのだが、課金済みであっても無課金でも同じようにRestoreの処理が進んでしまってた。

もしかしてレシートを取得してレシートのProductIDを確認すればいいのかな?なんて思って処理を実装しようと思ったら、結構ハードルが高い(簡単そうなAppStoreでの検証はあまり推奨されていないようだし)。

Webで調べてみると、 paymentQueue.updatedTransactions の他に paymentQueue.restoreCompletedTransactionsFaildWithError とか paymentQueueRestoreCompletedTransactionsFinished があったりでどういう関係でどの順番で呼ばれるのか分かりづらいし、そもそも必須なのかもよく分からない。

しまいにはRestoreの処理で購入とリストアの処理を同時に実行してるサンプルがある始末...
まぁ、気持ちは痛いほどよく分かるがこれでいいんかよ。

SKPaymentQueue.defaultQueue().addTransactionObserver(self)
SKPaymentQueue.defaultQueue().restoreCompletedTransactions()

神現れる

日本語の情報はほとんど無いながらも、おすすめとの文字と共に目に飛び込んできた SwiftyStoreKit 。もう藁にすがるつもりで SwiftyStoreKit を使ってみたら、同じ処理のイメージで期待通りの動きをしてくれる。ごめんなさいごめんなさい 藁どころか神でした 。レシートの確認なんてのもする必要なし(ソースを見てもレシートの確認はしてないようにみえる)。

内容としては、ほとんど SwiftyStoreKit のREADMEそのままだけど下記の感じ。処理が飛んでわかりづらくなるDelegeteはほとんど必要なくて、非常にすっきりと書ける。

実装サンプル

1. AppDelegate.application:didFinishLaunchingWithOptionsに以下を追加

これはAppleのマニュアルにもあるように、課金処理途中で通信が途絶えたときなどに次回起動時にリカバリーするために必要になる。ここだけはDelegeteな感じで実装が別れちゃうけど仕方ない。

内容としては見ての通りで、購入されたタイミングでUserDefaultsにデータを追加してるだけのお手軽仕様。ページによっては迂回路があるような記述もあるけど、数百円のアプリに面倒なことする人はそんなにいないでしょという判断で特に課金ユーザーの確認などはしていない。

SwiftyStoreKit.completeTransactions(atomically: true) { products in
    for product in products {
        if product.transaction.transactionState == .purchased || product.transaction.transactionState == .restored {

            let defaults = UserDefaults.standard
            defaults.set(true, forKey: "receiptData")
            defaults.synchronize()

            if product.needsFinishTransaction {

                SwiftyStoreKit.finishTransaction(product.transaction)
            }
            print("purchased: \(product)")
        }
    }
}

2. InAppPurchase.viewDidLoadに以下を追加

InAppPurcaseの機能だから課金画面が InAppPurchase.swift って人も多いと思う。だとしたら、起動時に下記の感じで画面ロード時にDescriptionやPriceを取得しておく。 もし課金済みの場合は支払い済みということがわかるよう、金額は表示せずに purchased と表示させてる。

product.localizedPrice でローカライズ済みの金額も取得できるっぽいんだけど、それまで参考にしてたコードに numberFormatter.formatterBehavior = .behavior10_4 があってうまく動いてたのでそのままにしてある。これはどういう意味なのかわかってないけど...

SwiftyStoreKit.retrieveProductsInfo([productID]) { result in
    if let product = result.retrievedProducts.first {

        self.descriptionLabel.text = product.localizedDescription

        if isPromode() {

            self.priceLabel.text = "purchased"

        } else {

            let numberFormatter = NumberFormatter()
            numberFormatter.formatterBehavior = .behavior10_4
            numberFormatter.numberStyle = .currency
            numberFormatter.locale = product.priceLocale
            self.priceLabel.text = numberFormatter.string(from: product.price)!
        }
    }
}

3. InAppPurchase.buy

Buyボタンをタッチしたときの処理は以下の感じ。DelegateではなくBlockで書けるので非常にわかりやすい。

SwiftyStoreKit.purchaseProduct(self.pro, atomically: true) { result in

    if case .success(let product) = result {
        self.priceLabel.text = "purchased"

        let defaults = UserDefaults.standard
        defaults.set(true, forKey: "receiptData")
        defaults.synchronize()

        if product.needsFinishTransaction {
            SwiftyStoreKit.finishTransaction(result as! PaymentTransaction)
        }
    }

}

4. InAppPurchase.restore

そして最後はRestoreボタンの処理。あんなに悩みながらいろいろやってうまくいかなかったのに、これだけで期待通りの動きができた。

Purchaseは一つづつ処理が進むのに対して、Restoreは複数の処理が一度に進むというところを気をつければ特に悩むところはない。

SwiftyStoreKit.restorePurchases(atomically: true) { result in
    for product in result.restoredProducts {
        if product.needsFinishTransaction {
            SwiftyStoreKit.finishTransaction(product.transaction)
        }
    }

    if result.restoredProducts.count > 0 {

        let defaults = UserDefaults.standard
        defaults.set(true, forKey: "receiptData")
        defaults.synchronize()

        self.priceLabel.text = "restored"
    }
}

テストパターン

最後に実際のテストパターンを並べておく。

  1. iTunesConnectで新しいユーザーを作成
  2. リストアボタンでリストアされないことを確認
  3. 購入ボタンで無事に購入されることを確認
  4. アプリを削除して再インストール
  5. リストアボタンでリストアされることを確認

SwiftyStoreKit の開発がOSのバージョンに追従してる間は、このくらいテストしておけば大丈夫そうだけどどうだろう?


ググってもあまり情報が無いのは使うのが容易だからだろうか。

限られたリソースしかないので、必要とはいえ課金周りの処理よりは機能追加の方に労力を掛けたい。誰もが想うそんな気持ちに答えてくれる良いライブラリを発見できて助かった。