💸

StoreKit2の変更点まとめ

2023/03/23に公開

StoreKit2についてまとめました。
従来のStoreKitからの変更点を主としており、アプリ内課金の実装方法についての説明は割愛しています。

Product

商品情報&購読処理を持つStruct
https://developer.apple.com/documentation/storekit/product

1メソッドで商品情報の取得が可能

let products: [Product] = try await Product.products(for: <# ProductIdの配列 #>)

Productには、下記のような欲しかった情報が網羅されている

  • type: Product.ProductType
    • consumable, autoRenewableなど課金タイプのenum。複数の課金タイプを持つアプリで活用できる
  • displayPrice: String
    • ローカライズ済みの価格表記(ex. ¥360, $4.99)
  • displayName: String
    • 商品名

Purchase

https://developer.apple.com/documentation/storekit/product/3791971-purchase

  • 購読処理をasync/awaitで実行できる try await product.purchase()
  • 旧StoreKit時代のようにPaymentQueueのdelegate内でfinishTransactionしなくてもよく、1メソッドで全ての処理を完結できる
  • appAccountTokenをoptionとして含めることができる(後述)
// appAccountTokenをオプションに含めて購読リクエスト
let appAccountToken = <# Generate an app account token. #>
let purchaseResult = try await product.purchase(options: [
    .appAccountToken(appAccountToken)
])

StoreKit2がレシート検証までやってくれる

  • StoreKit2ではトランザクションにJWSペイロードが含まれ、レシート検証プロセスを通過してAppStoreによって署名済みの端末かどうか確認してくれる
  • 結果はproduct.purchase()の返り値として返却される
    https://developer.apple.com/videos/play/wwdc2021/10114/?time=685

購読時のサンプル

  • product.purchase()の返り値はenumで返ってくる
    • .success(VerificationResult<Transaction>), .pending, .userCancelled
  • .success(let verificationResult) のverificationResultからレシート検証の結果を取得できる
  • throwされるエラーは Product.PurchaseError or StoreKitError
let result = try await product.purchase()
switch result {
case .success(let verificationResult):
    switch verificationResult {
    case .verified(let transaction):
        // Give the user access to purchased content.
        ...
        // Complete the transaction after providing
        // the user access to the content.
        // 購読処理の戻り値でトランザクションをfinish()できるの便利!
        await transaction.finish()
    case .unverified(let transaction, let verificationError):
        // Handle unverified transactions based 
        // on your business model.
        ...
    }
case .pending:
    // The purchase requires action from the customer. 
    // If the transaction completes, 
    // it's available through Transaction.updates.
    break
case .userCancelled:
    // The user canceled the purchase.
    break
@unknown default:
    break
}

Transaction (旧SKPaymentTransaction)

https://developer.apple.com/documentation/storekit/transaction

前提として、
ユーザーの購読ステータスの変更, アプリ外での購読(AppStoreの設定画面など), pending状態の購読処理を監視するため、アプリ起動時にTransactionをlistenする必要がある。(旧SKPaymentTransaction同様)

Transaction.updates にAsyncSequenceが流れるので、それをアプリ起動時にlistenする
Task(priority: .background)やTask.detachedでメインスレッド外で処理する

アプリ内で購読した時はTransactionが流れない
これが素晴らしい。以前はユーザーが購読した際も問答無用でSKPaymentQueueにTransactionが流れていたので考慮が必要だった
※購読時のTransactionはtry await product.purchase()メソッドの戻り値で取得できる

// アプリ起動時に、AppDelegateやSwiftUIならRootなViewなどでこのclassを保持し、
// Transactionをlistenし続ける
final class TransactionObserver {
    var updates: Task<Void, Never>? = nil
    
    init() {
        updates = newTransactionListenerTask()
    }

    deinit {
        // Cancel the update handling task when you deinitialize the class.
        updates?.cancel()
    }
    
    private func newTransactionListenerTask() -> Task<Void, Never> {
        Task(priority: .background) {
            for await verificationResult in Transaction.updates {
                self.handle(updatedTransaction: verificationResult)
            }
        }
    }
    
    private func handle(updatedTransaction verificationResult: VerificationResult<Transaction>) {
        guard case .verified(let transaction) = verificationResult else {
            // Ignore unverified transactions.
            return
        }

        if let revocationDate = transaction.revocationDate {
            // Remove access to the product identified by transaction.productID.
            // Transaction.revocationReason provides details about
            // the revoked transaction.
            <#...#>
        } else if let expirationDate = transaction.expirationDate,
            expirationDate < Date() {
            // Do nothing, this subscription is expired.
            return
        } else if transaction.isUpgraded {
            // Do nothing, there is an active transaction
            // for a higher level of service.
            return
        } else {
            // Provide access to the product identified by
            // transaction.productID.
            <#...#>
        }
    }   
}

iOS16~

transaction.environment からAppStoreの環境を判定できるようになった
Xcodeから実行した場合、サーバーのレシート検証をskip, コンバージョンを計測するコードをskipなどハンドリングが可能になっている

if transaction.environment == .xcode {
    // storekit.configを使ってシミュレータでデバッグしている場合の処理
}

Subscription status

取引履歴の他にサブスクリプションのステータスを取得できるプロパティもProductに追加されている
Transaction同様、JWSで暗号化されておりセキュア
https://developer.apple.com/videos/play/wwdc2021/10114/?time=1358

下記の情報が取得できる
https://developer.apple.com/documentation/storekit/product

  • Latest transaction: 最新の購読情報
  • Renewal state: 契約中、期限切れ、猶予期間かなど購読ステータス
  • Renewal info: ステータス情報の他に自動更新しているproduct ID, 期限切れの理由など全ての情報

Product.SubscriptionInfo.RenewalState を用いてStateに応じたコミュニケーションが可能
expiredなユーザーに向けて、割引オファーを出すなど

// Product.SubscriptionInfo.RenewalStateで判定できるステータス
public static let subscribed: Product.SubscriptionInfo.RenewalState
public static let expired: Product.SubscriptionInfo.RenewalState
public static let inBillingRetryPeriod: Product.SubscriptionInfo.RenewalState
public static let inGracePeriod: Product.SubscriptionInfo.RenewalState
public static let revoked: Product.SubscriptionInfo.RenewalState

リストア処理

restoreCompletedTransactionsのデリゲートメソッドに切り替わるリストア時の処理としてAppStore.sync()が用意されている
再認証(パスワード入力)が必要になった
https://developer.apple.com/documentation/storekit/appstore/3791906-sync

"Transactionを用いれば通常呼び出す必要がない処理"としてドキュメントに記載されている
あくまでTransaction.updatesを購読してユーザーのステータスを更新することをリストアの主とし、強制的に復元させるサブとしてユーザー手動の復元ボタンを設置しておくのがベストプラクティス。

引用)

In regular operations, there’s no need to call sync(). StoreKit automatically keeps up to date transaction information and subscription status available to your app. When users reinstall your app or download it on a new device, the app automatically has all transactions available to it upon initial launch. There’s no need for users to ask your app to restore transactions — your app can immediately get the current entitlements using currentEntitlements and transaction history using all. For more information about transactions, see Transaction.

"(Transactionの自動同期で大半のケースがカバーされるので)あまり使うことはないが、ユーザーが別のAppleIDを使用した場合などに強制的に復元させるため、ベストプラクティスとして復元ボタンを置いた方が良い"
Implement proactive in-app purchase restore

App account token

Product.purchaseのオプションとして付与した場合、Transactionで購読履歴を取得したときに購読結果に同じappAccountTokenをStoreKit側から付与してくれる
AppのアカウントごとにappAccountTokenを保存しておくとアカウントごとの購読情報を管理するのに役立つ

iOS16~変更点

AppTransaction

  • Transactionに関連するストアの情報
  • originalAppVersion: 初回購読した際のアプリのバージョン などが取得できる

WWDC22の動画では、課金アプリ→フリーミアムモデルに変更したアプリのユーザーの課金ステータスをコントロールする例としてAppTransactionが使われていた
https://developer.apple.com/videos/play/wwdc2022/10007
https://developer.apple.com/documentation/storekit/supporting_business_model_changes_by_using_the_app_transaction

StoreKitへの新しいプロパティの追加

  • Price locale
  • Server environment
  • Recent subscription start date

なんとこのプロパティはバックポートのサポートがあり、Xcode14でビルドすればiOS15, iPadOS15でも使える
ただ、iOS15のStoreKit Testingではダミーの値が返ってくるので注意(16以降のシミュレータを使えば良い)

ストア関連

SwiftUIにオファーコードの入力, ストアレビューAPIが追加になった
StoreKitメッセージAPI: 値上げの通知などアプリがフォアグラウンドになった際に表示できる

AppStore Sever APIのアップデート(WWDC22)

Get Transaction Historyのアップデート

  • 下記のソート, フィルタをかけて取得できるようになった
    • 更新日時
    • product type, productID, Subscription groupID, Purchase date range, Family sharing status, Exclude revoked transactions

AppStore Server Notification V2

2つのフィールドの追加

  • environment: (sandbox or production)
  • recentSubscriptionStartDate: 最新の購読日時

返金(Refund)

document
https://developer.apple.com/documentation/storekit/transaction/3803220-beginrefundrequest
SwiftUI
https://developer.apple.com/documentation/swiftui/view/refundrequestsheet(for:ispresented:ondismiss:)
sample
https://developer.apple.com/documentation/swiftui/food_truck_building_a_swiftui_multiplatform_app

Tips

Transactionに処理が流れてくる状態をシミュレートしたい場合、storekit.configの"Enable Ask to Buy"機能を使うのが便利
(親の許可が出るまで課金できない状態 .pending 状態をシミュレートできます)
https://developer.apple.com/videos/play/wwdc2021/10114/?time=1039

XcodeのTransaction ManagerでAsk to Buyの許可ができます

参考にした記事・動画

document
https://developer.apple.com/documentation/storekit/in-app_purchase
sample
https://developer.apple.com/documentation/storekit/in-app_purchase/implementing_a_store_in_your_app_using_the_storekit_api
revenuecatの詳しい記事
https://www.revenuecat.com/blog/engineering/ios-in-app-subscription-tutorial-with-storekit-2-and-swift/
WWDC memo
https://www.wwdcnotes.com/notes/wwdc21/10114/

WWDC21: Meet StoreKit 2
https://developer.apple.com/videos/play/wwdc2021/10114
WWDC22: What's new with in-app purchase
https://developer.apple.com/videos/play/wwdc2022/10007
WWDC22: Implement proactive in-app purchase restore
https://developer.apple.com/videos/play/wwdc2022/110404

GitHubで編集を提案

Discussion