LoginSignup
74
46

More than 1 year has passed since last update.

[iOS] サブスクリプションの実装 (StoreKit2, Xcode14)

Posted at

はじめに

最初に宣伝ですが、英語など外国語の学習に使えるiOSの単語帳アプリをリリースしました。
興味がある方は触ってみてください。

このアプリにサブスクリプションを実装をしたので、本記事ではiOSのサブスクリプションの実装方法についてまとめました。

StoreKit2ノススメ

iOSではサブスクリプションなどのApp内課金は、StoreKitフレームワークを使って実装しますが、StoreKitにはバージョン1と2があり、その2つはかなり実装方法が異なります。

StoreKit2はiOS15以降でしか使えませんが、StoreKit1と比べて実装がかなり楽になるので、これからリリースするアプリならStoreKit2を使うのがお勧めです。

StoreKit2のメリットは、大きくサーバーサイドのレシート検証が不要になった点と、全体的にAPIが便利になった点で、これにより肌感覚ですが、StoreKit1の3分の1くらいの時間で課金周りの機能が作れるように感じました。

サブスクリプション対応の流れ

まずはサブスクリプション対応の全体の流れを説明します。
詳細については後述するので、ここでは概要だけに留めます。

  1. App Store Connectで、商品名や価格や期間など、サブスクリプションの情報を登録する
  2. アプリに購入画面と購入処理を実装する
  3. 購入状況の変化を監視してアプリに反映する処理を実装する
  4. App Store Connectでレビュー向けの対応を行う

また、上記の他、App Store Connectのアカウントに口座情報や納税フォームが登録されていない場合は登録する必要があります。

App Store Connectでのサブスクリプション設定

まずは、App Store ConnectにログインしてマイApp から該当のアプリを選択し、アプリの設定ページでサブスクリプションの配信に必要な設定を行います。

App Store Connectでのサブスクリプションの設定方法は、こちらの公式ドキュメントにも書かれていますので参考にしてください。

サブスクリプショングループの登録

サブスクリプション商品を登録する前に、その親となるサブスクリプショングループを登録します。

サブスクリプショングループは同じ種類のサブスクリプション商品をまとめる機能で、例えば1つのグループ内に「松プラン」「竹プラン」「梅プラン」のような複数の(1つでも良いですが)サブスクリプション商品を作ることができ、ユーザーは1つのサブスクリプショングループにつき、1つだけサブスクリプション商品に登録することができます。

左メニュー内のサブスクリプションを選択し、サブスクリプショングループのセクションでサブスクリプショングループを追加します。
グループの参照名は任意につけることができます。

スクリーンショット 2022-10-06 9.42.54.png

サブスクリプショングループを登録したら、サブスクリプショングループの画面でApp Storeのローカリゼーション からサブスクリプショングループ表示名App表示オプションを登録します。

subscription-group

ちなみにサブスクリプショングループ表示名について、ヘルプにはユーザがサブスクリプション内容を管理する際、デバイス上に表示されますと書かれているのですが、iOSの設定のサブスクリプション画面では確認ができず、いったいどこに表示されるのか謎でした。
(知ってる方がいらっしゃったら是非教えてください)

サブスクリプションの登録

作成したサブスクリプショングループをクリックして表示される画面で、サブスクリプションを登録します。

参照名はあとで変えられますが、製品IDは一度登録すると変えられず、2度と同じIDが使えなくなるのでよく考えて決めましょう。
製品IDは全世界で一意にする必要があるので、以下のようにBundle IDで始めるのがいいんじゃないかと思います。

[Bundle ID].[サブスクリプショングループ名].[サブスクリプションの商品名]

233769/b30d79f0-a3a9-f399-c303-5a032a782cdf.png

サブスクリプションを登録したら、サブスクリプション期間、サブスクリプション価格を設定し、また、App Storeのローカリゼーションの欄から表示名と説明を登録します。

価格は完全に自由ではなく、以下のように選択肢の中から選ぶ形になっています。

subscription-price

日本円の価格を入力すると、為替レートを元に他の通貨の価格も自動設定してくれるのですが、為替レートが正確に反映された価格になるわけではなく、選択肢の中からそれに一番近いものが選ばれます。

例えば、米ドルの選択肢は$0.49 /$0.99 / $1.49...となっているため、現在の為替レート(USDJPY 145)では、以下のように50円も100円も同じく0.49ドルになってしまいます

  • 50円 > 0.34ドル > 0.49ドルが一番近い
  • 100円 > 0.68ドル > 0.49ドルが一番近い

「審査に関する情報」の登録

サブスクリプション内には審査に関する情報という欄があり、分かりづらいのですが、こちらのスクリーンショットは登録が必須です
アプリの購入ダイアログなどのスクリーンショットをアップして、審査メモの欄にはその画面を表示する手順などの説明を記載します。

スクリーンショット 2022-10-04 10.38.42.png

サブスクリプションをAppバージョンに追加する

登録したサブスクリプションは、別途Appバージョンに追加する必要があります。

左メニューで 1.0 提出準備中(表示はバージョンとステータスによって変わります)をクリックし、App内課金とサブスクリプションのセクションで、App内課金またはサブスクリプションを選択をクリックして登録したサブスクリプションをチェックします。

スクリーンショット 2022-10-04 17.47.50.png

App内課金とサブスクリプションのセクションは、サブスクリプションに必要な情報を全て登録して、ステータスが「送信準備完了」になっていないと表示されないのでご注意ください。

契約 / 税金 / 口座情報の設定

App内課金を提供するには、Appleアカウントで有料Appの契約に署名し、納税フォームの入力と、口座情報を入力する必要があります。
App Store Connectの「契約 / 税金 / 口座情報」のページで各種情報を入力をします。

口座情報

振込先の銀行の情報を登録するだけなのですが、若干分かりづらいところがあったので一部説明します。

Zengin Codeというのは金融機関コード-支店コードです。例えば、「みずほ銀行 新宿支店」の場合0001-240になります。

また、候補に出てくる銀行名は英語表記になっていて、一般的な日本名と少し違う場合があります。
例えば、三菱UFJ銀行はMUFG BANK LTDで登録されていました。

納税フォーム

以下の情報などを参考に入力しました。

アプリの実装

In-App Purchaseを有効にする

まず、Xcodeのプロジェクト設定のTARGETS内にあるSigning & Capabilitiesタブを選択し、左上の+CapabilityボタンからIn-App Purchase を追加します。

capability-image

Swift Concurrencyを知る

StoreKit2のAPIは全体的にSwift Concurrencyが使われているので、使ったことがない場合は予習が必要です。
ここでは詳しい説明はしませんが、最低でも以下は使うことになるので、使い方を知っておく必要があります。

  • async
  • await
  • Task

ちなみに、Swift Concurrencyを知らないという理由でStoreKit1を選択するのはもったいないです。
なぜなら、StoreKit2はStoreKit1と比べて実装が楽になっていて、それによって浮く時間で、Swift Concurrencyの学習ができると思うからです。

Productの取得

ここから具体的なコードの実装に入ります。

購入の前に、まず製品IDからProductのインスタンスを取得します。
Productインスタンスは、商品情報の表示と、購入処理のために必要になります。

Productインスタンスの取得例
let productIdList = [
    "hogehoge.subscription.matsu",
    "hogehoge.subscription.take",
    "hogehoge.subscription.ume",
]

let products = try await Product.products(for: productIdList)

上の例では製品IDをベタ書きしていますが、変更が見込まれるのであれば、外部から取得する設計にするのが良いかと思います。

製品情報の表示

Productインスタンスの以下のプロパティから、App Store Connectで登録したサブスクリプションの情報が取得できるので、これらを使って購入画面などに商品の情報を表示します。

  • displayName 商品名
  • description 商品の説明
  • displayPrice 商品の価格
  • subscription?.subscriptionPeriod.value サブスクリプション期間の数値(例えば2週なら2)
  • subscription?.subscriptionPeriod.unit サブスクリプション期間の単位(日、週、月、年)

displayNamedescriptiondisplayPriceには、ローカライズされた値が入ります。
例えば、日本のユーザーなら、displayPriceは¥200のようになり、アメリカなら$1.49のようになります。

以下は、Productの情報を一覧表示するための、カスタムUITableViewCellのコード例です。

商品情報を表示するセル
class ProductCell: UITableViewCell {
    @IBOutlet weak var displayNameLabel: UILabel!
    @IBOutlet weak var descriptionLabel: UILabel!
    @IBOutlet weak var displayPriceLabel: UILabel!
    @IBOutlet weak var periodLabel: UILabel!

    // このプロパティに取得したProductインスタンスをセットする
    var product: Product? {
        didSet {
            displayNameLabel.text = product?.displayName
            descriptionLabel.text = product?.description
            displayPriceLabel.text = product?.displayPrice
            periodLabel.text = ""
            if let period = product?.subscription?.subscriptionPeriod {
                periodLabel.text = "\(period.value) \(period.unit)"
            }
        }
    }
}

セルのレイアウト

product-cell

実際の表示

product-list

購入処理

購入の実行自体はとても簡単で、Productインスタンスのpurchase関数を呼ぶだけです。

購入の実行
try? await product.purchase()

この関数を呼ぶと、以下のようなOSのダイアログが表示され、サブスクリプションに登録することができます。

IMG_C9814232B242-1.jpeg

ただ、実際は購入失敗時にメッセージを表示したり、購入成功した場合は特典を付与したり、Transactionをfinishする必要があるので、もっと複雑になります。

以下のサンプルコードでは呼び出し側でシンプルに使えるよう、購入正常完了時はTransactionを返し、それ以外はカスタムのエラーをthrowする形にしています。

購入処理をする関数の例
func purchase(product: Product) async throws -> Transaction  {
    // Product.PurchaseResultの取得
    let purchaseResult: Product.PurchaseResult
    do {
        purchaseResult = try await product.purchase()
    } catch Product.PurchaseError.productUnavailable {
        throw SubscribeError.productUnavailable
    } catch Product.PurchaseError.purchaseNotAllowed {
        throw SubscribeError.purchaseNotAllowed
    } catch {
        throw SubscribeError.otherError
    }

    // VerificationResultの取得
    let verificationResult: VerificationResult<Transaction>
    switch purchaseResult {
    case .success(let result):
        verificationResult = result
    case .userCancelled:
        throw SubscribeError.userCancelled
    case .pending:
        throw SubscribeError.pending
    @unknown default:
        throw SubscribeError.otherError
    }

    // Transactionの取得
    switch verificationResult {
    case .verified(let transaction):
        return transaction
    case .unverified:
        throw SubscribeError.failedVerification
    }
}
購入完了しなかった場合にthrowするError
enum SubscribeError: LocalizedError {
    case userCancelled // ユーザーによって購入がキャンセルされた
    case pending // クレジットカードが未設定などの理由で購入が保留された
    case productUnavailable // 指定した商品が無効
    case purchaseNotAllowed // OSの支払い機能が無効化されている
    case failedVerification // トランザクションデータの署名が不正
    case otherError // その他のエラー
}

上記の関数を使うと、購入処理は以下のようになります。

購入処理の例
do {
    let transaction = try await purchase(product: product)
    // productIdに対応した特典を有効にする
    enablePrivilege(productId: transaction.productID)
    await transaction.finish()
    // 完了メッセージを表示
    showResultMessage("購入が完了しました。")
} catch {
    // エラーメッセージを表示
    let errorMessage = getErrorMessage(error: error)
    showResultMessage(errorMessage)
}
エラーメッセージを取得する関数の例
private func getErrorMessage(error: Error) -> String {
    switch error {
    case SubscribeError.userCancelled:
        return "ユーザーによって購入がキャンセルされました"
    case SubscribeError.pending:
        return "購入が保留されています"
    case SubscribeError.productUnavailable:
        return "指定した商品が無効です"
    case SubscribeError.purchaseNotAllowed:
        return "OSの支払い機能が無効化されています"
    case SubscribeError.failedVerification:
        return "トランザクションデータの署名が不正です"
    default:
        return "不明なエラーが発生しました"
    }
}

Transaction.updatesを監視する

アプリ起動中に購入状況が変化した場合、Transaction.updatesから更新されたトランザクションを取得することができます。
例えば以下のようなケースで、Transaction.updatesからトランザクションが取得できることが確認できました。

  1. サブスクリプションの期限が来て自動更新されたとき
  2. 他のデバイスから同じApple IDでサブスクリプション登録されたとき
  3. ペアレンタルコントロールで保留されていた購入が許可されたとき
  4. サブスクリプションが払い戻しされたとき

2,3のケースでは特典の付与、4のケースでは特典の削除などの処理をする必要があります。
ただし、サブスクリプションを解約して有効期限が切れた場合については、アップデートが発生しなかったので、このケースは別途後述の方法で拾う必要があります。

トランザクションの更新を監視する関数例
func observeTransactionUpdates() {
    Task(priority: .background) {
        for await verificationResult in Transaction.updates {
            guard case .verified(let transaction) = verificationResult else {
                continue
            }

            if transaction.revocationDate != nil {
                // 払い戻しされてるので特典削除
                disablePrivilege()
            } else if let expirationDate = transaction.expirationDate,
                      Date() < expirationDate // 有効期限内
                      && !transaction.isUpgraded // アップグレードされていない
            {
                // 有効なサブスクリプションなのでproductIdに対応した特典を有効にする
                enablePrivilege(productId: transaction.productID)
            }

            await transaction.finish()
        }
    }
}

この関数はアプリの起動直後に一度だけ実行します。
Transaction.updatesAsyncSequenceになっており、トランザクションの更新が発生するたびに、forループ内の処理が実行されます。
このforループは終わることがなく、バックグラウンドでずっと監視を続けます。

その他の購入状況の変化を取得する

有効期限が切れたことなど、Transaction.updatesで感知できない更新については、任意のタイミングでTransaction.currentEntitlementsを見てアプリに反映してやる必要があります。

チェックするタイミングは画面遷移のときとか、定期的にとか、いくつかやり方が考えられますが、アプリがフォアグラウンドになったときにチェックしてやるのが、簡単かつ実用的ではないかと思います。

その場合、アプリがフォアグラウンドになったとき呼ばれる、SceneDelegate.sceneWillEnterForeground(_ scene: UIScene)で、以下のようなコードを実行します。

購入状況の変化を反映するコード例
func updateSubscriptionStatus() async {
    var validSubscription: Transaction?
    for await verificationResult in Transaction.currentEntitlements {
        if case .verified(let transaction) = verificationResult,
           transaction.productType == .autoRenewable && !transaction.isUpgraded {
            validSubscription = transaction
        }
    }

    if let productId = validSubscription?.productID {
        // 特典を付与
        enablePrivilege(productId: productId)
    } else {
        // 特典を削除
        disablePrivilege()
    }
}

なお、Transaction.currentEntitlements で取得できるのは、現在アクティブなサブスクリプションなので、有効期限が切れたり、アップグレードされたものは取得されなそうに思えるのですが、Sandbox環境はそこらへんの挙動が不安定で、取得される場合とされない場合がありました。

少なくとも、公式ドキュメントには払い戻し、または取消されたプロダクトは取得されないと書かれており、有効期限のチェックはいらない可能性が高そうですが、心配であれば、isUpgradedやexpirationDateのチェックなどを入れてもいいかもしれません。
(上のコード例ではisUpgradedのチェックだけ入れました)

バックエンドでのレシート検証は不要

StoreKit1でサブスクリプションを提供する場合、バックエンドにレシート検証用のAPIを作ってサブスクリプションの有効期限をチェックする必要がありましたが、StoreKit2では Transaction.currentEntitlements が有効期限切れのサブスクリプションを返さない仕様かつ、有効期限もそこから取得できるため、レシート検証APIは不要になります。

これは、StoreKit1からStoreKit2に変えることで、大きくコストを削減できるポイントの一つです。

サブスクリプションのテスト

テストケース

サブスクリプションは想定されるケースが多いです。
以下のようなケースそれぞれおいて、プログラムの動きを把握して、テストする必要があります。

正常系

  • 初めてサブスクリプションを購入

更新系

  • アプリがフォアグラウンドの状態でサブスクリプションが自動更新される
  • アプリがバックグラウンドまたは終了状態でサブスクリプションが自動更新される

解約系

  • サブスクリプションを解約した後、有効期限が切れる
  • サブスクリプションを解約した後、設定アプリで再登録する
  • サブスクリプションを解約した後、アプリから再登録する

購入ブロック系

  • 購入しようとするが、OSの支払い機能が無効化されている
  • 購入時にクレジットカード情報が未登録で登録が必要
  • 購入時にAppleの規約が更新されており同意が必要
  • 購入時にペアレンタルコントロールにより親の承認が必要

エラー系

  • 支払い完了後、特典の付与に失敗する
  • 購入処理の途中でアプリを終了する

復元&連携系

  • サブスクリプションを購入後、アプリ削除して再インストール
  • サブスクリプションを購入後、別の端末で同じApple IDを使ってアプリを使う

重複購入系

  • 購入済みのサブスクリプションを重複購入
  • 購入済みのサブスクリプションを別の端末で同じApple IDを使って重複購入
  • 別のサブスクリプションにアップグレード

テスト環境

App内課金のテスト環境は、ローカルでテストできるStoreKit Configurationと、AppleのSandbox環境の2種類があります。
最初はStoreKit Configurationを使った動作確認をメインに行い、仕上がってきたらより本番環境に近いSandbox環境でテストするのが、一般的な流れかと思います。

StoreKit Configuration

StoreKit Configurationは、Xcodeだけで完結するテスト用のシミュレーション環境で、Sandbox環境より簡単にテストできます。

まず、Xcodeメニューバーの"File"から、 "New > File..."を選択し、OtherカテゴリーのStoreKit Configuration Fileを選択してファイルを作成します。
Sync this file with an app in App Store Connectのチェックボックスが表示されますが、今回はStoreKit Configurationはあくまでテスト用として、チェックは付けませんでした。
また、Target Membershipも特にチェックを付けなくても問題ありませんでした。

作成したファイルを選択し、左下の+ボタンから、Add Auto-Renewable Subscriptionを選ぶとサブスクリプション商品が追加できるので、Product ID、商品名、価格、サブスクリプション期間などの情報を好きに設定します。

スクリーンショット 2022-10-06 11.54.06.png

作成したStoreKit Configurationファイルを使うには、SchemeのRun設定のOptions内にあるStoreKit Configurationの欄に、作成したStoreKit Configurationファイルを指定します。

スクリーンショット 2022-10-03 16.39.31.png

これでXcodeから指定のSchemeでアプリを起動した際に、StoreKit Configurationファイルに設定した商品が購入できるようになります。

StoreKit Configurationの環境設定

XcodeでStoreKit Configurationファイルを開いた状態で、メニューのEditorをクリックすると、購入周りの諸々の設定をすることができます。

Subscription Renewal Rate

サブスクリプション期間の時間の流れを設定できます。
最短で、月単位のサブスクリプションが30秒で更新されるようにできます。

その他の設定

他にも、このメニューからアカウントの言語を切り替えたり、購入が保留になるようにしたり、任意のエラーが発生するようにしたり、ペアレンタルこのトロールを有効にしたり色々な設定ができます。

StoreKit Configurationの制限

StoreKit Configurationを使用する場合、アプリをアンインストールすると、購入したサブスクリプションは消えてしまいます。
また、複数の端末でアカウントを共有して購入することもシミュレーションできないので、そういった状況をテストしたい場合は後述のSandbox環境を使う必要があります。

Transaction Manager

StoreKit Configuration環境の購入で発生したトランザクションはTransaction Managerで管理することができます。
Transaction Managerを表示するには、Xcodeのコンソール上部にある以下のボタンを押します。

スクリーンショット 2022-10-06 12.19.09.png

Transaction Managerでは失効したものも含めたトランザクションの一覧が見れる他、以下のことができます。

  • サブスクリプションの解約
  • サブスクリプションプランの変更
  • Ask to Buyの承認 or 却下
  • 購入の払い戻し

スクリーンショット 2022-10-06 12.22.08.png

Sandbox環境

App Store Connectのユーザとアクセス内のSandboxテスターからテストアカウントを登録し、そのアカウントを使うと実際にお金を払わずSandbox環境で購入のテストができます。

スクリーンショット 2022-10-05 16.19.43.png

Sandboxテスターアカウントで購入するには、購入時に求められるApple IDのとパスワードのダイアログに、任意のテスターのアカウントを入力するだけです。

Sandboxアカウントは本物のApple IDとは独立して管理でき、一度ログインすると設定アプリのApp Store画面から、アカウントの切り替えや、サブスクリプション解約などの管理ができるようになります。

IMG_C99E354EC0DE-1.jpeg

App Reviewのために必要なこと

ただでさえ厳しめなAppleのレビューですが、App内課金やサブスクリプションを提供する場合はさらにリジェクトされるポイントが増えます。
以下ではサブスクリプションを提供する場合に、レビュー対策で必要なことを説明します。

App Store Reviewガイドライン

App Store Reviewガイドライン3.1.2 サブスクリプションのところにサブスクリプションに関するガイドラインがありますので、一度目を通しておくといいかと思います。

アプリの購入画面に必要な情報を追加する

サブスクリプションの購入画面などに、以下の情報を表示する必要があります。
私は最初プライバシーポリシーと利用規約がなくてリジェクトされました。

  • サブスクリプションの商品名
  • サブスクリプション内容の説明
  • サブスクリプションの期間
  • サブスクリプションの価格
  • プライバシーポリシーへのリンク
  • 利用規約へのリンク

サブスクリプションの期間や価格は最終的にiOSの購入画面にも自動で表示されますが、それとは別にアプリにも表示が必要っぽいです。

概要欄にプライバシーポリシーと利用規約のリンクを記載する

App Store Connectのアプリの概要欄にプライバシーポリシーと利用規約へのリンクがないとリジェクトされます
例えば、以下のようなリンクを概要欄に記載する必要があります。

■ サブスクリプション
サブスクリプションご利用にあたっての規約などは下記をご参照ください。
1. プライバシーポリシー: https://xxxxxxxxxx.net/privacypolicy.html
2. 利用規約: https://xxxxxxxxxx.net/eula.html

ちなみに、概要欄にサブスクリプションの商品名、期間、価格は書かなくてもレビューに通りました。

利用規約の作成は必要?

サブスクリプションを提供するアプリでは、カスタムの利用規約を作成する必要があるとの情報を見て作成したのですが、後ほど実は必要なくてApple標準の使用許諾契約 (EULA)でも問題ないということが分かりました。

Apple標準の使用許諾契約を使ってレビューに通ったという人がいましたし、Appleレビューのメッセージにも 「Apple標準の利用規約を使うなら、アプリの概要欄にそのリンクを入れて下さい」(和訳)と書かれていました。

Apple標準の使用許諾契約を使う場合は、以下のようなテキストをApp Store Connectの概要欄に追加し、アプリの購入画面などにもこの利用規約へのリンクを入れます。

利用規約: https://www.apple.com/legal/internet-services/itunes/dev/stdeula/

おしまい

iOS14のシェアもかなり落ちてきたので、今後はStoreKit2が課金処理の主流になっていくと思われます。
Sandbox環境でいまいちはっきりしない点もありますが、課金周りの実装の参考になれば幸いです。

74
46
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
74
46