はじめに
最初に宣伝ですが、英語など外国語の学習に使えるiOSの単語帳アプリをリリースしました。
興味がある方は触ってみてください。
このアプリにサブスクリプションを実装をしたので、本記事ではiOSのサブスクリプションの実装方法についてまとめました。
StoreKit2ノススメ
iOSではサブスクリプションなどのApp内課金は、StoreKit
フレームワークを使って実装しますが、StoreKitにはバージョン1と2があり、その2つはかなり実装方法が異なります。
StoreKit2はiOS15以降でしか使えませんが、StoreKit1と比べて実装がかなり楽になるので、これからリリースするアプリならStoreKit2を使うのがお勧めです。
StoreKit2のメリットは、大きくサーバーサイドのレシート検証が不要になった点と、全体的にAPIが便利になった点で、これにより肌感覚ですが、StoreKit1の3分の1くらいの時間で課金周りの機能が作れるように感じました。
サブスクリプション対応の流れ
まずはサブスクリプション対応の全体の流れを説明します。
詳細については後述するので、ここでは概要だけに留めます。
- App Store Connectで、商品名や価格や期間など、サブスクリプションの情報を登録する
- アプリに購入画面と購入処理を実装する
- 購入状況の変化を監視してアプリに反映する処理を実装する
- App Store Connectでレビュー向けの対応を行う
また、上記の他、App Store Connectのアカウントに口座情報や納税フォームが登録されていない場合は登録する必要があります。
App Store Connectでのサブスクリプション設定
まずは、App Store ConnectにログインしてマイApp
から該当のアプリを選択し、アプリの設定ページでサブスクリプションの配信に必要な設定を行います。
App Store Connectでのサブスクリプションの設定方法は、こちらの公式ドキュメントにも書かれていますので参考にしてください。
サブスクリプショングループの登録
サブスクリプション商品を登録する前に、その親となるサブスクリプショングループ
を登録します。
サブスクリプショングループは同じ種類のサブスクリプション商品をまとめる機能で、例えば1つのグループ内に「松プラン」「竹プラン」「梅プラン」のような複数の(1つでも良いですが)サブスクリプション商品を作ることができ、ユーザーは1つのサブスクリプショングループにつき、1つだけサブスクリプション商品に登録することができます。
左メニュー内のサブスクリプション
を選択し、サブスクリプショングループ
のセクションでサブスクリプショングループを追加します。
グループの参照名は任意につけることができます。
サブスクリプショングループを登録したら、サブスクリプショングループの画面でApp Storeのローカリゼーション
からサブスクリプショングループ表示名
とApp表示オプション
を登録します。
ちなみにサブスクリプショングループ表示名について、ヘルプにはユーザがサブスクリプション内容を管理する際、デバイス上に表示されます
と書かれているのですが、iOSの設定のサブスクリプション画面では確認ができず、いったいどこに表示されるのか謎でした。
(知ってる方がいらっしゃったら是非教えてください)
サブスクリプションの登録
作成したサブスクリプショングループをクリックして表示される画面で、サブスクリプションを登録します。
参照名はあとで変えられますが、製品IDは一度登録すると変えられず、2度と同じIDが使えなくなるのでよく考えて決めましょう。
製品IDは全世界で一意にする必要があるので、以下のようにBundle IDで始めるのがいいんじゃないかと思います。
[Bundle ID].[サブスクリプショングループ名].[サブスクリプションの商品名]
サブスクリプションを登録したら、サブスクリプション期間、サブスクリプション価格を設定し、また、App Storeのローカリゼーション
の欄から表示名と説明を登録します。
価格は完全に自由ではなく、以下のように選択肢の中から選ぶ形になっています。
日本円の価格を入力すると、為替レートを元に他の通貨の価格も自動設定してくれるのですが、為替レートが正確に反映された価格になるわけではなく、選択肢の中からそれに一番近いものが選ばれます。
例えば、米ドルの選択肢は$0.49 /$0.99 / $1.49...
となっているため、現在の為替レート(USDJPY 145)では、以下のように50円も100円も同じく0.49ドルになってしまいます。
- 50円 > 0.34ドル > 0.49ドルが一番近い
- 100円 > 0.68ドル > 0.49ドルが一番近い
「審査に関する情報」の登録
サブスクリプション内には審査に関する情報
という欄があり、分かりづらいのですが、こちらのスクリーンショットは登録が必須です。
アプリの購入ダイアログなどのスクリーンショットをアップして、審査メモ
の欄にはその画面を表示する手順などの説明を記載します。
サブスクリプションをAppバージョンに追加する
登録したサブスクリプションは、別途Appバージョンに追加する必要があります。
左メニューで 1.0 提出準備中
(表示はバージョンとステータスによって変わります)をクリックし、App内課金とサブスクリプション
のセクションで、App内課金またはサブスクリプションを選択
をクリックして登録したサブスクリプションをチェックします。
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 を追加します。
Swift Concurrencyを知る
StoreKit2のAPIは全体的にSwift Concurrency
が使われているので、使ったことがない場合は予習が必要です。
ここでは詳しい説明はしませんが、最低でも以下は使うことになるので、使い方を知っておく必要があります。
- async
- await
- Task
ちなみに、Swift Concurrencyを知らないという理由でStoreKit1を選択するのはもったいないです。
なぜなら、StoreKit2はStoreKit1と比べて実装が楽になっていて、それによって浮く時間で、Swift Concurrencyの学習ができると思うからです。
Productの取得
ここから具体的なコードの実装に入ります。
購入の前に、まず製品IDから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
サブスクリプション期間の単位(日、週、月、年)
displayName
、description
、displayPrice
には、ローカライズされた値が入ります。
例えば、日本のユーザーなら、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インスタンスのpurchase関数を呼ぶだけです。
try? await product.purchase()
この関数を呼ぶと、以下のようなOSのダイアログが表示され、サブスクリプションに登録することができます。
ただ、実際は購入失敗時にメッセージを表示したり、購入成功した場合は特典を付与したり、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
}
}
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
からトランザクションが取得できることが確認できました。
- サブスクリプションの期限が来て自動更新されたとき
- 他のデバイスから同じApple IDでサブスクリプション登録されたとき
- ペアレンタルコントロールで保留されていた購入が許可されたとき
- サブスクリプションが払い戻しされたとき
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.updates
はAsyncSequence
になっており、トランザクションの更新が発生するたびに、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、商品名、価格、サブスクリプション期間などの情報を好きに設定します。
作成したStoreKit Configurationファイルを使うには、SchemeのRun
設定のOptions内にあるStoreKit Configuration
の欄に、作成したStoreKit Configurationファイルを指定します。
これで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のコンソール上部にある以下のボタンを押します。
Transaction Managerでは失効したものも含めたトランザクションの一覧が見れる他、以下のことができます。
- サブスクリプションの解約
- サブスクリプションプランの変更
- Ask to Buyの承認 or 却下
- 購入の払い戻し
Sandbox環境
App Store Connectのユーザとアクセス
内のSandboxテスター
からテストアカウントを登録し、そのアカウントを使うと実際にお金を払わずSandbox環境で購入のテストができます。
Sandboxテスターアカウントで購入するには、購入時に求められるApple IDのとパスワードのダイアログに、任意のテスターのアカウントを入力するだけです。
Sandboxアカウントは本物のApple IDとは独立して管理でき、一度ログインすると設定
アプリのApp Store
画面から、アカウントの切り替えや、サブスクリプション解約などの管理ができるようになります。
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環境でいまいちはっきりしない点もありますが、課金周りの実装の参考になれば幸いです。