Studyplus Engineering Blog

スタディプラスの開発者が発信するブログ

Studyplus iOS版におけるアプリ内課金時のUI制御

こんにちは、モバイルクライアントグループの明渡です。

先日のGoogle Play定期購入でプランを切り替えるモードの話でも記載の通り、スタディプラスでは今年3月に有料会員サービス Studyplus Pro をiOS/Android両OSでリリースしました。

info.studyplus.co.jp

iOS版でのアプリ内課金の実装自体は、用意されているフレームワークにてアプリ開発者が考慮しなければいけない事項がAndroidより少ないです。そして、Appleの豊富なドキュメント*1や、先人たちが多々残してくれている豊富な知見*2に助けられスムーズに実装を進められました。

そんな中で今回は、アプリ内課金実装そのものと比べれば些事ながらも結果的にリリース直前まで苦しんだ、アプリ内課金操作を行なった際のUI制御についてお話しします。

iOSのアプリ内課金実装の中でもアプリ開発者の裁量に委ねられていてアプリごとに最適解が異なりそうなテーマになりますので、ほんの一例としてご覧いただければ幸いです。

やりたかったこと

  • アプリ内課金を試行中、アプリ画面の操作はできないようにする

    • 購入操作を行うためのApp Storeアラートが表示されるまで数秒かかることがあり、何も制御しないとアプリ画面の操作ができてしまう
  • アプリ内課金に関する操作が終了後、アプリ画面を操作できるようになる

  • アプリ内課金の購入・復元完了後に完了した旨を伝える画面を表示する

実装

最終的に落ち着いた実装が以下の通りになります。

前提

  • Appleのドキュメントからダウンロードできるサンプルコード*3を基に実装したので、フレームワークはStoreKitをそのまま利用

    • App Storeのオブザーバにてストアからのコールバック処理時、画面側へデリゲートによりアプリ内課金のトランザクション処理ステータスを伝えて制御
  • UI制御が絡まない実装の記述は割愛

実行環境

  • Xcode 11.5
  • Swift 5.2.4

App Storeのオブザーバ

protocol AppStoreObserverDelegate: class {
    func storeObserverPurchaseSucceed()
    func storeObserverDidReceiveMessage(message: String)
    func storeObserverDidCancelled()
}
final class AppStoreObserver: NSObject {
    static let shared = AppStoreObserver()
    weak var delegate: AppStoreObserverDelegate?
}

extension AppStoreObserver: SKPaymentTransactionObserver {
    
    func paymentQueue(_ queue: SKPaymentQueue, updatedTransactions transactions: [SKPaymentTransaction]) {
        for transaction in transactions {
            switch transaction.transactionState {
            case .purchasing:
                break
            case .deferred:
                DispatchQueue.main.async { [weak self] in
                    self?.delegate?.storeObserverDidCancelled()
                }
            case .purchased:
                handlePurchased(transaction, success: {
                    // サーバーでレシート検証して会員ステータス更新が成功した際、購入完了時の処理を行う
                    DispatchQueue.main.async { [weak self] in
                        self?.delegate?.storeObserverPurchaseSucceed()
                    }
                }, failure: { [weak self] message in
                    DispatchQueue.main.async { [weak self] in
                        self?.delegate?.storeObserverDidReceiveMessage(message: message)
                    }
                })
            case .failed:
                if let error = transaction.error as? SKError, error.code != .paymentCancelled {
                    DispatchQueue.main.async { [weak self] in
                        self?.delegate?.storeObserverDidReceiveMessage(message: error.localizedDescription)
                    }
                } else {
                    DispatchQueue.main.async { [weak self] in
                        self?.delegate?.storeObserverDidCancelled()
                    }
                }
                handleFailed(transaction)

            case .restored:
                handleRestored(transaction, success: {
                    // サーバーでレシート検証して会員ステータス更新が成功した際、購入完了時の処理を行う
                    DispatchQueue.main.async { [weak self] in
                        self?.delegate?.storeObserverPurchaseSucceed()
                    }
                }, failure: { [weak self] message in
                    DispatchQueue.main.async { [weak self] message in
                        self?.delegate?.storeObserverDidReceiveMessage(message: error.localizedDescription)
                    }
                })
            @unknown default:
                let message = "Unknown payment transaction case"
                #if DEBUG
                fatalError(message)
                #endif
            }
        }
    }
}

画面

final class ViewController: UIViewController {
    // UIActivityIndicatorViewを含む画面全体を覆うカスタムビュー
    // 購入手続き・復元処理開始時に表示しておく
    private lazy var loadingView: LoadingMaskView = {
        return LoadingMaskView(frame: view.frame)
    }()
}

extension ViewController: AppStoreObserverDelegate {
    func storeObserverPurchaseSucceed() {
        DispatchQueue.main.async { [weak self] in
            self?.loadingView.dismiss()
            // Studyplus Pro 登録完了画面を表示
        }
    }
    
    func storeObserverDidReceiveMessage(message: String) {
        DispatchQueue.main.async { [weak self] in
            self?.loadingView.dismiss()
            // messageのアラート表示
        }
    }
    
    func storeObserverDidCancelled() {
        DispatchQueue.main.async { [weak self] in
            self?.loadingView.dismiss()
        }
    }
}

苦労したこと

ユーザーへアプリから伝えるべき情報とそうでないものの切り分け

購入・復元処理が完了したあと、App Storeのオブザーバ内で弊社サーバーへレシート検証リクエストも立て続けに行なっております。 SKPaymentTransactionによって購入〜課金ステータス更新までを1つトランザクションとして扱える恩恵にあやかるためですね。

苦しんだ要因としては、その後続処理側のエラー時の挙動も並行してきちんと整理せず実装を進めているうちに、無駄にこんがらがってしまっただけという側面が強いです...

App Storeからのレスポンスについては、以下のような対応に落ち着きました。

  • SKPaymentTransactionState
    • .purchased または .restored
      • レシート検証を進める
    • .deferred
      • Ask to Buyによる購入承認リクエストが送信されたがまだ購入が確定していないので、レシート検証は行わずアプリの利用を再開できるようにする
    • .failed のうち、ユーザーが自らキャンセル(SKError.paymentCancelled)
      • ユーザーの意志で購入が行われなかったので、レシート検証は行わずアプリの利用を再開できるようにする
    • .failed のうち、上記以外
      • ユーザーの意志に反して購入・復元完了しなかった恐れがあるため、エラーの内容を表示しつつアプリの利用を再開できるようにする

App Storeでの操作完了後、アプリ側のローディング表示をきちんと終了する

上記のソースコードなのですが、DispatchQueue.main.async のクロージャがくどく感じません?

オブザーバ・画面共にどちらかのクロージャを欠くと、ローディング表示を終了できず永遠にローディング表示のままとなる事象が一定確率で発生するため必須だったのです...

一定確率で発生するのがミソで、リリース版がFIXする直前まで見落として開発を進めてしまい、発覚した際には少々青ざめながら検証して現在の実装に落ち着きました。

自アプリ以外の要素が絡む処理はとりわけ、メインスレッドで実行したい処理を DispatchQueue.main.async のクロージャへ明記しないといけないのだなと理解しました。

さいごに

今回はテーマに関わるソースコードを抜粋しながら記事を書き起こしたのですが、元のソースコードをもう少し整理してリファクタしたい欲に駆られました...

近いうちにアプリ内課金前提の動作確認の伴う機能開発と併せて、今回の記事を見直してリファクタリングに臨もうかなと思います。