💸

[SwiftUI]StoreKit2でiOSアプリにサブスクリプションを実装する方法

2024/11/02に公開

はじめに

この記事では、SwiftUIとStoreKit 2を使用したiOSアプリでのサブスクリプション管理システムの実装について解説します。StoreKit 2は、App内課金やサブスクリプションを簡単に実装できるAppleの新しいフレームワークです。

宣伝

SwiftUIとStorekit2を使って爆速でサブスクリプションが実装できるボイラープレートを販売しています!これ使えばおそらく1時間で実装できると思います!

スグサブのリンク

開発環境

Xcode15
Swift5

全体のコード

Subscriptionに関するコードは全てSubscriptionServiceとして実装し、それをEnvironmentObjectとしてどこでも使用できるように実装しました。

import StoreKit

#if DEBUG
enum SubscriptionPlan: String, CaseIterable {
    case monthlyPlan = "DevStoreFlowSubscriptionMonthly"
    case annualPlan = "DevStoreFlowSubscriptionAnnualy"
}
#else
enum SubscriptionPlan: String, CaseIterable {
    case monthlyPlan = "StoreFlowSubscriptionMonthly"
    case annualPlan = "StoreFlowSubscriptionAnnualy"
}
#endif

public enum SubscriptionStatus: Equatable {
    case unknown
    case monthlyPlanSubscribed
    case annualPlanSubscribed
    case unsubscribed
}

@MainActor
class SubscriptionService: ObservableObject {
    @Published var status: SubscriptionStatus = .unknown
    @Published var products: [Product] = []
    @Published var isLoading = false
    @Published var errorMessage: String?
    
    private var transactionListener: Task<Void, Error>?
    
    init() {
        setupTransactionListener()
        setupNotificationObserver()
        Task { await checkSubscriptionStatus() }
    }
    
    func fetchProducts() async {
        isLoading = true
        defer { isLoading = false }
        
        do {
            let fetchedProducts = try await Product.products(
                for:  SubscriptionPlan.allCases.map(\.rawValue)
            )
            self.products = fetchedProducts.sorted { $0.price < $1.price }
        } catch {
       
            if let skError = error as? StoreKit.StoreKitError {
                print("❌ StoreKit error:", skError)
            }
            self.errorMessage = getErrorMessage(error)
        }
    }
    
    func purchase(product: Product) async {
        isLoading = true
        defer { isLoading = false }
        
        do {
            let result = try await product.purchase()
            switch result {
            case .success(let verification):
                if case .verified(let transaction) = verification {
                    await handleTransaction(transaction)
                    await transaction.finish()
                } else {
                    throw SubscribeError.failedVerification
                }
                
            case .userCancelled:
                throw SubscribeError.userCancelled
            case .pending:
                throw SubscribeError.pending
            @unknown default:
                throw SubscribeError.otherError
            }
        } catch {
            self.errorMessage = getErrorMessage(error)
        }
    }
    
    private func setupNotificationObserver() {
        NotificationCenter.default.addObserver(
            self,
            selector: #selector(handleWillEnterForeground),
            name: UIApplication.willEnterForegroundNotification,
            object: nil
        )
    }
    
    @objc private func handleWillEnterForeground() {
        Task { await checkSubscriptionStatus() }
    }
    
    private func setupTransactionListener() {
        transactionListener = Task.detached { [weak self] in
            for await result in Transaction.updates {
                print("result:",result)
                if case .verified(let transaction) = result {
                    await self?.handleTransaction(transaction)
                }
            }
        }
    }
    
    private func handleTransaction(_ transaction: Transaction) async {
        print("transaction:",transaction)
        guard let plan = SubscriptionPlan(rawValue: transaction.productID) else { return }
        
        if let expirationDate = transaction.expirationDate {
            let isActive = Date() < expirationDate
            
            if isActive {
                switch plan {
                case .monthlyPlan:
                    self.status = .monthlyPlanSubscribed
                case .annualPlan:
                    self.status = .annualPlanSubscribed
                }
            } else {
                self.status = .unsubscribed
            }
        } else {
            self.status = .unsubscribed
        }
    }
    
    private func checkSubscriptionStatus() async {
        var hasValidSubscription = false
        
        for await result in Transaction.currentEntitlements {
            if case .verified(let transaction) = result,
               !transaction.isUpgraded {
                hasValidSubscription = true
                await handleTransaction(transaction)
            }
        }
        
        if !hasValidSubscription {
            self.status = .unsubscribed
        }
    }
    
    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 "不明なエラーが発生しました"
        }
    }
    
    deinit {
        transactionListener?.cancel()
    }
}



enum SubscribeError: LocalizedError {
    case userCancelled // ユーザーによって購入がキャンセルされた
    case pending // クレジットカードが未設定などの理由で購入が保留された
    case productUnavailable // 指定した商品が無効
    case purchaseNotAllowed // OSの支払い機能が無効化されている
    case failedVerification // トランザクションデータの署名が不正
    case otherError // その他のエラー
}

1. サブスクリプション商品の作成

ここはめちゃくちゃわかりやすくまとまっている記事があるので引用させていただきます

2. サブスクリプションプランの定義

まず、アプリで提供するサブスクリプションプランを定義します。開発環境と本番環境で異なるプランIDを使用できるように、DEBUGフラグで分岐させています。

#if DEBUG
enum SubscriptionPlan: String, CaseIterable {
    case monthlyPlan = "DevStoreFlowSubscriptionMonthly"
    case annualPlan = "DevStoreFlowSubscriptionAnnualy"
}
#else
enum SubscriptionPlan: String, CaseIterable {
    case monthlyPlan = "StoreFlowSubscriptionMonthly"
    case annualPlan = "StoreFlowSubscriptionAnnualy"
}
#endif

3. サブスクリプションの状態管理

サブスクリプションの状態を表す列挙型を定義します。

public enum SubscriptionStatus: Equatable {
    case unknown            // 初期状態
    case monthlyPlanSubscribed  // 月額プラン購読中
    case annualPlanSubscribed   // 年額プラン購読中
    case unsubscribed      // 未購読
}

4. SubscriptionServiceクラスの主要機能

4.1 初期化と監視の設定

init() {
    setupTransactionListener()    // トランザクション監視の開始
    setupNotificationObserver()   // アプリのフォアグラウンド遷移監視
    Task { await checkSubscriptionStatus() }  // 現在の購読状態をチェック
}

4.2 商品情報の取得

App Store Connectで設定した商品情報を取得します。

func fetchProducts() async {
    isLoading = true
    defer { isLoading = false }
    
    do {
        let fetchedProducts = try await Product.products(
            for: SubscriptionPlan.allCases.map(\.rawValue)
        )
        self.products = fetchedProducts.sorted { $0.price < $1.price }
    } catch {
        self.errorMessage = getErrorMessage(error)
    }
}

4.3 購入処理

func purchase(product: Product) async {
    isLoading = true
    defer { isLoading = false }
    
    do {
        let result = try await product.purchase()
        switch result {
        case .success(let verification):
            if case .verified(let transaction) = verification {
                await handleTransaction(transaction)
                await transaction.finish()
            }
        // ... エラーハンドリング
        }
    } catch {
        self.errorMessage = getErrorMessage(error)
    }
}

4.4 トランザクションの監視

バックグラウンドでの購入状態の変更を監視します。

private func setupTransactionListener() {
    transactionListener = Task.detached { [weak self] in
        for await result in Transaction.updates {
            if case .verified(let transaction) = result {
                await self?.handleTransaction(transaction)
            }
        }
    }
}

4.5 トランザクションの処理

購入や更新が発生した際の処理を行います。

private func handleTransaction(_ transaction: Transaction) async {
    guard let plan = SubscriptionPlan(rawValue: transaction.productID) else { return }
    
    if let expirationDate = transaction.expirationDate {
        let isActive = Date() < expirationDate
        
        if isActive {
            switch plan {
            case .monthlyPlan:
                self.status = .monthlyPlanSubscribed
            case .annualPlan:
                self.status = .annualPlanSubscribed
            }
        } else {
            self.status = .unsubscribed
        }
    }
}

5. エラーハンドリング

サブスクリプション処理で発生する可能性のあるエラーを定義し、適切なメッセージを返します。

enum SubscribeError: LocalizedError {
    case userCancelled        // ユーザーによるキャンセル
    case pending             // 支払い保留
    case productUnavailable  // 商品が無効
    case purchaseNotAllowed  // 支払い機能が無効
    case failedVerification  // 検証失敗
    case otherError         // その他のエラー
}

使用方法

App Store Connectでサブスクリプション商品を設定

プロジェクトにSubscriptionServiceを追加

SwiftUIビューで以下のように使用:

@StateObject private var subscriptionService = SubscriptionService()

// 商品一覧の取得
.task {
    await subscriptionService.fetchProducts()
}

// 購入処理
Button("購入") {
    Task {
        await subscriptionService.purchase(product: product)
    }
}

Discussion