麦芽を支える技術

麦芽(ばくが、英語:malt)とは、麦、特に大麦の種子を発芽させたもので、ビール、ウイスキー、水飴の原料となる。(Wikipediaより)

tvOSアプリのIn-App Purchaseで購入・リストア要求時にAppStore側からの応答が受け取れない問題

実際のところこの問題が発生するのは動作環境にも寄る気もするけど、tvOSアプリの実機デバッグで2台のAppleTV端末で同事象が発生したので、tvOSだと起こりやすいとかあるのかもしれない。

概要

In-App Purchaseで購入やリストアをする場合、ざっくり書くと以下のような流れでAppStoreとやりとりしながら処理する。

購入時

  1. プロダクトIDで初期化したSKProductsRequestインスタンス(要求オブジェクト)を生成
  2. その要求オブジェクトのstart()メソッドを実行し、指定したプロダクトIDが有効なプロダクトかAppStoreに問い合わせ
  3. SKProductsRequestDelegateプロトコルを実装した処理でそのAppStoreからの応答を受け取る
  4. 応答に問題なければトランザクションキュー(SKPaymentQueue)に支払い要求を追加し、AppStoreに送信
  5. SKPaymentTransactionObserverプロトコルを実装したトランザクションキューのオブザーバで応答を受け取る
  6. 応答結果に応じた購入処理を行う

リストア時

  1. SKReceiptRefreshRequestインスタンス(要求オブジェクト)を生成
  2. その要求オブジェクトのstart()メソッドを実行し、AppStoreにレシート更新要求を送信
  3. SKRequestDelegateプロトコルを実装した処理でそのAppStoreからの応答を受け取る
  4. 応答に問題なければトランザクションキュー(SKPaymentQueue)でリストアプロセスを開始するよう、AppStoreに送信
  5. SKPaymentTransactionObserverプロトコルを実装したトランザクションキューのオブザーバで応答を受け取る
  6. 応答結果に応じたリストア処理を行う

問題事象

手元の環境の場合、iOS実機デバッグでは特に問題は発生しなかったが、tvOS実機デバッグでは何故か上記「購入時」「リストア時」それぞれの「3.」に記載したAppStoreからの応答を受け取ることができない(SKProductsRequestDelegate / SKRequestDelegateプロトコルを実装した処理が呼ばれない)という事象が発生。

原因調査

上記「購入時」の「1.」〜「3.」辺りの説明として、In-App Purchase プログラミングガイドのP15に以下の記載がある。

App Storeに問い合わせるには、プロダクト要求オブジェクトを使用します。最初に、SKProductsRequestのインスタンスを作成し、プロダクトIDのリストで初期化します。要求オブジェクトへの強い参照を保持してください。そうしないと、要求が完了しないうちに、システムが割り当て解除してしまうおそれがあります

併せてサンプルコードもガイドに記載されているが、SKProductsRequestは必要がなくなるまで強参照を保持しておかないと、AppStoreへの要求が完了する前に要求オブジェクトの方が先に解放される可能性があるとのこと。

SKReceiptRefreshRequestの方は特にガイドには同様の記載はなかったが、SKProductsRequestSKReceiptRefreshRequestも共にSKRequestのサブクラスのため、同じような事象が起こっているのではないかと推測。

対応

というわけで、ガイドに記載されているサンプルコードの通り、SKProductsRequestSKReceiptRefreshRequestそれぞれの要求オブジェクトをそのクラスのインスタンス変数に保持することで、ひとまず事象としては解決。

import StoreKit

class YourPurchaseClass {
    fileprivate var productRequest: SKProductsRequest?
    fileprivate var refreshRequest: SKReceiptRefreshRequest?

    ...

    func startYourPurchaseProcess() {
        let productRequest = SKProductsRequest(productIdentifiers: ["PRODUCT_ID"])
        self.productRequest = productRequest  // 要求完了前に開放されないよう、インスタンス変数に保持
        productRequest.delegate = self
        productRequest.start()
    }

    func startYourRestoreProcess() {
        let refreshRequest = SKReceiptRefreshRequest(receiptProperties: nil)
        self.refreshRequest = refreshRequest  // 要求完了前に開放されないよう、インスタンス変数に保持
        refreshRequest.delegate = self
        refreshRequest.start()
    }

    ...

}

...

// SKProductsRequest の要求完了後に呼ばれる
extension YourPurchaseClass: SKProductsRequestDelegate {

    func productsRequest(_ request: SKProductsRequest, didReceive response: SKProductsResponse) {
        // プロダクトID要求完了後処理
        ...
    }
}
 
// SKProductsRequestDelegate の親クラス
// SKProductsRequest と SKReceiptRefreshRequest 両方から呼ばれる可能性がある
extension YourPurchaseClass: SKRequestDelegate {

    func request(_ request: SKRequest, didFailWithError error: Error) {
        if request is SKProductsRequest {
            // プロダクトID要求エラー時処理
            ...
            self.productRequest = nil
        } else if request is SKReceiptRefreshRequest {
            // リストア要求エラー時処理
            ...
            self.refreshRequest = nil
        }
    }

    func requestDidFinish(_ request: SKRequest) {
        if request is SKProductsRequest {
            // プロダクトID要求終了時処理
            ...
            self.productRequest = nil
        } else if request is SKReceiptRefreshRequest {
            // リストア要求終了時処理
            ...
            self.refreshRequest = nil
        }
    }
}

SKRequestDelegateの実装で、request(_:didFailWithError:)が呼び出されるとrequestDidFinish(_:)は呼ばれないとドキュメントに記載があるので、両方に要求オブジェクトの解放処理を入れている。

おわりに

ガイドの更新履歴を見るとこの記述は2015/10/21に追記されているようなので、それ以前に書かれたWebの記事とか本とかを参考に課金関連の処理を実装していると、この辺に触れられていなくてハマる。