StoreKit 2 Interview: Abonnementverwaltung und Beleg-Validierung

Beherrschen Sie iOS-Interview-Fragen zu StoreKit 2, Abonnementverwaltung, Beleg-Validierung und In-App-Kauf-Implementierung mit praktischen Swift-Codebeispielen.

iOS StoreKit 2 Abonnement-Architektur und Beleg-Validierung

StoreKit 2 stellt einen grundlegenden Wandel in der Handhabung von In-App-Käufen unter iOS dar. Mit iOS 15 eingeführt, vereinfacht dieses moderne Framework den Code für Abonnementverwaltung und Transaktionsvalidierung drastisch. Technische iOS-Interviews behandeln dieses Thema regelmäßig, um die Expertise von Entwicklern in der App-Monetarisierung zu bewerten.

Wichtige Interview-Erkenntnis

StoreKit 2 nutzt eine native Swift-API mit async/await und macht komplexe Callbacks und clientseitige Beleg-Validierung überflüssig. Interviewer schätzen Kandidaten, die die grundlegenden Unterschiede zu StoreKit 1 klar artikulieren können.

Überblick über die StoreKit 2-Architektur

StoreKit 2 basiert auf einer modernen Architektur, die Swift Concurrency vollständig nutzt. Im Gegensatz zu StoreKit 1 sind alle Operationen asynchron und verwenden native Swift-Typen.

Verfügbare Produkte abrufen

Der erste Schritt besteht darin, die in App Store Connect konfigurierten Produkte zu laden. StoreKit 2 vereinfacht diese Operation mit einer deklarativen API.

ProductService.swiftswift
import StoreKit

actor ProductService {

    // In App Store Connect konfigurierte Produkt-Identifikatoren
    private let productIdentifiers: Set<String> = [
        "com.app.subscription.monthly",
        "com.app.subscription.yearly",
        "com.app.premium.lifetime"
    ]

    // Produkt-Cache zur Vermeidung wiederholter Netzwerkaufrufe
    private var cachedProducts: [Product] = []

    func loadProducts() async throws -> [Product] {
        // Cache zurückgeben, falls verfügbar
        guard cachedProducts.isEmpty else {
            return cachedProducts
        }

        // Asynchrone Anfrage an den App Store
        let products = try await Product.products(for: productIdentifiers)
        cachedProducts = products
        return products
    }

    func product(for identifier: String) async throws -> Product? {
        let products = try await loadProducts()
        return products.first { $0.id == identifier }
    }
}

Die Verwendung eines actor gewährleistet Thread-Sicherheit bei der Verwaltung des Produkt-Caches.

StoreKit 2-Produkttypen

StoreKit 2 unterscheidet klar zwischen verschiedenen Produkttypen. Das Verständnis dieser Unterscheidung ist für Interviews unerlässlich.

ProductType+Extensions.swiftswift
import StoreKit

extension Product.ProductType {

    var displayName: String {
        switch self {
        case .consumable:
            // Verbrauchsprodukte (Credits, Leben usw.)
            return "Consumable"
        case .nonConsumable:
            // Permanente Käufe (Funktionsfreischaltung)
            return "Non-Consumable"
        case .autoRenewable:
            // Abonnements mit automatischer Verlängerung
            return "Auto-Renewable Subscription"
        case .nonRenewable:
            // Abonnements ohne Verlängerung (temporärer Pass)
            return "Non-Renewable Subscription"
        @unknown default:
            return "Unknown"
        }
    }
}

Automatisch verlängernde Abonnements stellen das dominante Geschäftsmodell für moderne iOS-Anwendungen dar.

Kauftransaktionen verarbeiten

Der Kaufprozess in StoreKit 2 wird dank async/await linear. Schluss mit Delegates und verschachtelten Callbacks aus StoreKit 1.

Einen Kauf einleiten

PurchaseManager.swiftswift
import StoreKit

actor PurchaseManager {

    enum PurchaseError: Error {
        case productNotFound
        case purchaseCancelled
        case purchasePending
        case verificationFailed
        case unknown
    }

    func purchase(_ product: Product) async throws -> Transaction {
        // Kauf mit System-Modal einleiten
        let result = try await product.purchase()

        switch result {
        case .success(let verification):
            // Transaktionssignatur überprüfen
            let transaction = try checkVerification(verification)

            // Transaktion als abgeschlossen markieren
            await transaction.finish()

            return transaction

        case .userCancelled:
            // Benutzer hat das Kauf-Modal abgebrochen
            throw PurchaseError.purchaseCancelled

        case .pending:
            // Kauf ausstehend (Elternfreigabe usw.)
            throw PurchaseError.purchasePending

        @unknown default:
            throw PurchaseError.unknown
        }
    }

    private func checkVerification<T>(_ result: VerificationResult<T>) throws -> T {
        switch result {
        case .verified(let safe):
            // Transaktion vom App Store signiert und überprüft
            return safe
        case .unverified(_, let error):
            // Ungültige oder beschädigte Signatur
            throw PurchaseError.verificationFailed
        }
    }
}

Die Methode finish() ist entscheidend: Sie signalisiert dem App Store, dass der Inhalt an den Benutzer ausgeliefert wurde.

Interview-Warnung

Vergessen Sie niemals, transaction.finish() nach der Inhaltsauslieferung aufzurufen. Eine nicht abgeschlossene Transaktion wird beim nächsten App-Start wiederhergestellt und verursacht unerwartetes Verhalten.

Hintergrundtransaktionen abhören

StoreKit 2 führt Transaction.updates ein, einen asynchronen Stream, der Transaktionen ausgibt, die außerhalb der App stattfinden (Verlängerungen, Family-Sharing-Käufe usw.).

TransactionObserver.swiftswift
import StoreKit

actor TransactionObserver {

    private var updateTask: Task<Void, Never>?

    func startObserving() {
        // Vorherige Beobachtung abbrechen
        updateTask?.cancel()

        updateTask = Task(priority: .background) {
            // Unendlicher Stream von Transaktionsaktualisierungen
            for await result in Transaction.updates {
                do {
                    let transaction = try self.checkVerification(result)

                    // Transaktion entsprechend ihrem Typ verarbeiten
                    await self.handleTransaction(transaction)

                    // Transaktion immer abschließen
                    await transaction.finish()
                } catch {
                    // Fehler für Debugging protokollieren
                    print("Transaction verification failed: \(error)")
                }
            }
        }
    }

    func stopObserving() {
        updateTask?.cancel()
        updateTask = nil
    }

    private func handleTransaction(_ transaction: Transaction) async {
        switch transaction.productType {
        case .autoRenewable:
            // Abonnementstatus aktualisieren
            await updateSubscriptionStatus(transaction)
        case .nonConsumable:
            // Premium-Funktion freischalten
            await unlockFeature(transaction.productID)
        default:
            break
        }
    }

    private func checkVerification<T>(_ result: VerificationResult<T>) throws -> T {
        switch result {
        case .verified(let safe):
            return safe
        case .unverified:
            throw PurchaseError.verificationFailed
        }
    }

    private func updateSubscriptionStatus(_ transaction: Transaction) async {
        // Implementierung der Statusaktualisierung
    }

    private func unlockFeature(_ productID: String) async {
        // Implementierung der Funktionsfreischaltung
    }

    enum PurchaseError: Error {
        case verificationFailed
    }
}

Die Transaktionsbeobachtung sollte unmittelbar beim App-Start beginnen, um keine Aktualisierungen zu verpassen.

Häufige Interview-Fragen zu StoreKit 2-Abonnements

iOS-Interviews enthalten oft spezifische Fragen zur Abonnementverwaltung. Hier sind die häufigsten mit detaillierten Antworten.

Frage 1: Wie überprüft man den aktuellen Abonnementstatus?

StoreKit 2 bietet direkten Zugriff auf aktive Berechtigungen über Transaction.currentEntitlements.

SubscriptionStatusChecker.swiftswift
import StoreKit

struct SubscriptionStatusChecker {

    struct SubscriptionStatus {
        let isActive: Bool
        let expirationDate: Date?
        let willRenew: Bool
        let productID: String?
    }

    static func checkCurrentStatus() async -> SubscriptionStatus {
        // Alle aktiven Transaktionen abrufen
        var latestTransaction: Transaction?

        for await result in Transaction.currentEntitlements {
            if case .verified(let transaction) = result {
                // Nur Abonnements filtern
                if transaction.productType == .autoRenewable {
                    // Aktuellste Transaktion behalten
                    if latestTransaction == nil ||
                       transaction.purchaseDate > latestTransaction!.purchaseDate {
                        latestTransaction = transaction
                    }
                }
            }
        }

        guard let transaction = latestTransaction else {
            return SubscriptionStatus(
                isActive: false,
                expirationDate: nil,
                willRenew: false,
                productID: nil
            )
        }

        // Prüfen, ob das Abonnement noch aktiv ist
        let isActive = transaction.expirationDate ?? Date.distantPast > Date()

        // Verlängerungsinformationen abrufen
        let renewalInfo = await getRenewalInfo(for: transaction.productID)

        return SubscriptionStatus(
            isActive: isActive,
            expirationDate: transaction.expirationDate,
            willRenew: renewalInfo?.willAutoRenew ?? false,
            productID: transaction.productID
        )
    }

    private static func getRenewalInfo(for productID: String) async -> Product.SubscriptionInfo.RenewalInfo? {
        guard let product = try? await Product.products(for: [productID]).first,
              let subscription = product.subscription else {
            return nil
        }

        // Verlängerungsstatus abrufen
        let status = try? await subscription.status.first
        return status?.renewalInfo
    }
}

Dieser Ansatz liefert den genauen Abonnementstatus ohne Serveraufrufe.

Bereit für deine iOS-Interviews?

Übe mit unseren interaktiven Simulatoren, Flashcards und technischen Tests.

Frage 2: Wie verarbeitet man kostenlose Testphasen und Werbeangebote?

StoreKit 2 vereinfacht den Zugriff auf Angebotsinformationen. Interviewer testen häufig das Verständnis der verschiedenen Angebotstypen.

OfferManager.swiftswift
import StoreKit

struct OfferManager {

    enum OfferType {
        case freeTrial(duration: String)
        case payAsYouGo(periods: Int, price: Decimal)
        case payUpFront(duration: String, price: Decimal)
        case none
    }

    static func getIntroductoryOffer(for product: Product) -> OfferType {
        guard let subscription = product.subscription,
              let offer = subscription.introductoryOffer else {
            return .none
        }

        switch offer.paymentMode {
        case .freeTrial:
            // Kostenloser Testzeitraum
            return .freeTrial(duration: formatPeriod(offer.period))

        case .payAsYouGo:
            // Reduzierter Preis über mehrere Perioden
            return .payAsYouGo(
                periods: offer.periodCount,
                price: offer.price
            )

        case .payUpFront:
            // Einmalzahlung für einen Zeitraum
            return .payUpFront(
                duration: formatPeriod(offer.period),
                price: offer.price
            )

        @unknown default:
            return .none
        }
    }

    static func checkEligibility(for product: Product) async -> Bool {
        // Prüfen, ob der Benutzer vom Angebot profitieren kann
        guard let subscription = product.subscription else {
            return false
        }

        // isEligibleForIntroOffer zeigt an, ob ein neuer Abonnent vorliegt
        return await subscription.isEligibleForIntroOffer
    }

    private static func formatPeriod(_ period: Product.SubscriptionPeriod) -> String {
        let value = period.value
        switch period.unit {
        case .day:
            return "\(value) day\(value > 1 ? "s" : "")"
        case .week:
            return "\(value) week\(value > 1 ? "s" : "")"
        case .month:
            return "\(value) month\(value > 1 ? "s" : "")"
        case .year:
            return "\(value) year\(value > 1 ? "s" : "")"
        @unknown default:
            return ""
        }
    }
}

Die Methode isEligibleForIntroOffer ist unerlässlich, um die Anzeige nicht verfügbarer Angebote zu vermeiden.

Frage 3: Wie stellt man frühere Käufe wieder her?

Die Wiederherstellung von Käufen ist gemäß den App Store-Richtlinien eine obligatorische Funktion.

RestoreManager.swiftswift
import StoreKit

actor RestoreManager {

    struct RestoreResult {
        let restoredProducts: [String]
        let activeSubscriptions: [String]
    }

    func restorePurchases() async throws -> RestoreResult {
        var restoredProducts: [String] = []
        var activeSubscriptions: [String] = []

        // Mit dem App Store synchronisieren
        try await AppStore.sync()

        // Alle Benutzertransaktionen durchlaufen
        for await result in Transaction.currentEntitlements {
            guard case .verified(let transaction) = result else {
                continue
            }

            switch transaction.productType {
            case .nonConsumable:
                // Permanente Käufe wiederherstellen
                restoredProducts.append(transaction.productID)

            case .autoRenewable:
                // Prüfen, ob Abonnement aktiv ist
                if let expiration = transaction.expirationDate,
                   expiration > Date() {
                    activeSubscriptions.append(transaction.productID)
                }

            default:
                // Verbrauchsprodukte sind nicht wiederherstellbar
                break
            }
        }

        return RestoreResult(
            restoredProducts: restoredProducts,
            activeSubscriptions: activeSubscriptions
        )
    }
}

AppStore.sync() erzwingt die Synchronisation mit Apples Servern, nützlich nach einer Neuinstallation.

Serverseitige Beleg-Validierung

Die serverseitige Validierung bleibt für Anwendungen mit kritischen Abonnements empfohlen. StoreKit 2 führt JWS (JSON Web Signatures) für moderne Validierung ein.

Transaktionsdaten für den Server extrahieren

ServerValidation.swiftswift
import StoreKit

struct TransactionPayload: Codable {
    let transactionID: String
    let originalTransactionID: String
    let productID: String
    let purchaseDate: Date
    let expirationDate: Date?
    let jwsRepresentation: String
}

actor ServerValidationService {

    private let apiEndpoint = "https://api.example.com/verify"

    func validateWithServer(_ transaction: Transaction) async throws -> Bool {
        // Payload für den Server erstellen
        let payload = TransactionPayload(
            transactionID: String(transaction.id),
            originalTransactionID: String(transaction.originalID),
            productID: transaction.productID,
            purchaseDate: transaction.purchaseDate,
            expirationDate: transaction.expirationDate,
            jwsRepresentation: transaction.jwsRepresentation ?? ""
        )

        // In JSON kodieren
        let encoder = JSONEncoder()
        encoder.dateEncodingStrategy = .iso8601
        let jsonData = try encoder.encode(payload)

        // An Server senden
        var request = URLRequest(url: URL(string: apiEndpoint)!)
        request.httpMethod = "POST"
        request.httpBody = jsonData
        request.setValue("application/json", forHTTPHeaderField: "Content-Type")

        let (data, response) = try await URLSession.shared.data(for: request)

        guard let httpResponse = response as? HTTPURLResponse,
              httpResponse.statusCode == 200 else {
            return false
        }

        // Serverantwort dekodieren
        let validationResponse = try JSONDecoder().decode(
            ValidationResponse.self,
            from: data
        )

        return validationResponse.isValid
    }
}

struct ValidationResponse: Codable {
    let isValid: Bool
    let subscriptionStatus: String?
}

Die jwsRepresentation enthält die kryptografische Signatur, die vom Server überprüfbar ist.

App Store Server API

Apple stellt jetzt die App Store Server API für serverseitige Validierung bereit. Diese moderne API ersetzt den veralteten verifyReceipt-Endpunkt und bietet erweiterte Funktionen wie Server-zu-Server-Benachrichtigungen.

Server-Benachrichtigungen konfigurieren

Server Notifications V2 ermöglichen den Empfang von Ereignissen in Echtzeit.

ServerNotificationTypes.swiftswift
enum NotificationType: String, Codable {
    case subscribed = "SUBSCRIBED"
    case didRenew = "DID_RENEW"
    case didFailToRenew = "DID_FAIL_TO_RENEW"
    case didChangeRenewalStatus = "DID_CHANGE_RENEWAL_STATUS"
    case expired = "EXPIRED"
    case gracePeriodExpired = "GRACE_PERIOD_EXPIRED"
    case offerRedeemed = "OFFER_REDEEMED"
    case priceIncrease = "PRICE_INCREASE"
    case refund = "REFUND"
    case renewalExtended = "RENEWAL_EXTENDED"
    case revoke = "REVOKE"
}

struct ServerNotification: Codable {
    let notificationType: NotificationType
    let subtype: String?
    let data: NotificationData

    struct NotificationData: Codable {
        let bundleId: String
        let environment: String
        let signedTransactionInfo: String
        let signedRenewalInfo: String?
    }
}

Diese Benachrichtigungen ermöglichen die Synchronisation des Abonnementstatus mit dem Backend.

Fehlerbehandlung und Randfälle

Interviews testen häufig das Verständnis von Fehlerszenarien und deren ordnungsgemäße Behandlung.

Häufige StoreKit 2-Fehler

PurchaseErrorHandler.swiftswift
import StoreKit

struct PurchaseErrorHandler {

    enum UserFacingError {
        case networkError
        case paymentFailed
        case notAuthorized
        case productNotAvailable
        case unknown

        var message: String {
            switch self {
            case .networkError:
                return "Connection failed. Please check your internet connection."
            case .paymentFailed:
                return "Payment failed. Please verify your payment information."
            case .notAuthorized:
                return "In-app purchases are disabled on this device."
            case .productNotAvailable:
                return "This product is not currently available."
            case .unknown:
                return "An unexpected error occurred."
            }
        }
    }

    static func handle(_ error: Error) -> UserFacingError {
        // Prüfen, ob es ein StoreKit-Fehler ist
        if let storeKitError = error as? StoreKitError {
            switch storeKitError {
            case .networkError:
                return .networkError
            case .userCancelled:
                // Keine Fehleranzeige, Benutzer hat abgebrochen
                return .unknown
            case .notAvailableInStorefront:
                return .productNotAvailable
            case .notEntitled:
                return .notAuthorized
            default:
                return .unknown
            }
        }

        // Kauffehler
        if let purchaseError = error as? Product.PurchaseError {
            switch purchaseError {
            case .invalidQuantity:
                return .unknown
            case .productUnavailable:
                return .productNotAvailable
            case .purchaseNotAllowed:
                return .notAuthorized
            default:
                return .unknown
            }
        }

        return .unknown
    }
}

Klare Fehlerbehandlung verbessert die Benutzererfahrung und vereinfacht das Debugging.

Offline-Modus-Unterstützung

OfflineCapabilityManager.swiftswift
import StoreKit

actor OfflineCapabilityManager {

    private let userDefaults = UserDefaults.standard
    private let entitlementsKey = "cached_entitlements"

    func cacheCurrentEntitlements() async {
        var entitlements: [String: Date] = [:]

        for await result in Transaction.currentEntitlements {
            if case .verified(let transaction) = result {
                entitlements[transaction.productID] = transaction.expirationDate
            }
        }

        // Lokal speichern
        if let data = try? JSONEncoder().encode(entitlements) {
            userDefaults.set(data, forKey: entitlementsKey)
        }
    }

    func getCachedEntitlements() -> [String: Date] {
        guard let data = userDefaults.data(forKey: entitlementsKey),
              let entitlements = try? JSONDecoder().decode(
                  [String: Date].self,
                  from: data
              ) else {
            return [:]
        }

        // Abgelaufene Berechtigungen filtern
        let now = Date()
        return entitlements.filter { _, expiration in
            expiration > now
        }
    }

    func hasValidEntitlement(for productID: String) -> Bool {
        let cached = getCachedEntitlements()
        return cached[productID] != nil
    }
}

Lokales Caching erhält den Zugriff auf Premium-Funktionen auch ohne Konnektivität.

Best Practices für die Produktion

Empfohlene Architektur

SubscriptionService.swiftswift
import StoreKit
import Combine

@MainActor
final class SubscriptionService: ObservableObject {

    static let shared = SubscriptionService()

    @Published private(set) var products: [Product] = []
    @Published private(set) var purchasedProductIDs: Set<String> = []
    @Published private(set) var subscriptionStatus: SubscriptionStatus = .none

    private var transactionObserver: Task<Void, Never>?

    enum SubscriptionStatus {
        case none
        case active(expirationDate: Date)
        case expired
        case inGracePeriod
    }

    private init() {
        // Transaktionsbeobachtung starten
        startTransactionObserver()

        // Anfangszustand laden
        Task {
            await loadProducts()
            await updateSubscriptionStatus()
        }
    }

    private func startTransactionObserver() {
        transactionObserver = Task.detached(priority: .background) {
            for await result in Transaction.updates {
                if case .verified(let transaction) = result {
                    await transaction.finish()
                    await self.updateSubscriptionStatus()
                }
            }
        }
    }

    func loadProducts() async {
        do {
            let productIDs = [
                "com.app.subscription.monthly",
                "com.app.subscription.yearly"
            ]
            products = try await Product.products(for: productIDs)
        } catch {
            print("Failed to load products: \(error)")
        }
    }

    func purchase(_ product: Product) async throws {
        let result = try await product.purchase()

        switch result {
        case .success(let verification):
            if case .verified(let transaction) = verification {
                await transaction.finish()
                await updateSubscriptionStatus()
            }
        case .userCancelled:
            break
        case .pending:
            break
        @unknown default:
            break
        }
    }

    func updateSubscriptionStatus() async {
        var purchasedIDs: Set<String> = []
        var latestSubscription: Transaction?

        for await result in Transaction.currentEntitlements {
            if case .verified(let transaction) = result {
                purchasedIDs.insert(transaction.productID)

                if transaction.productType == .autoRenewable {
                    if latestSubscription == nil ||
                       transaction.purchaseDate > latestSubscription!.purchaseDate {
                        latestSubscription = transaction
                    }
                }
            }
        }

        purchasedProductIDs = purchasedIDs

        if let subscription = latestSubscription,
           let expiration = subscription.expirationDate {
            if expiration > Date() {
                subscriptionStatus = .active(expirationDate: expiration)
            } else {
                subscriptionStatus = .expired
            }
        } else {
            subscriptionStatus = .none
        }
    }

    deinit {
        transactionObserver?.cancel()
    }
}

Diese zentralisierte Architektur vereinfacht die Verwaltung des Kaufstatus in der gesamten Anwendung.

Fazit

StoreKit 2 verwandelt die Verwaltung von In-App-Käufen in eine moderne und sichere Entwicklungserfahrung. Wichtige Punkte für ein Interview:

✅ Native async/await-API: Schluss mit komplexen Delegates und Callbacks aus StoreKit 1

✅ Automatische Verifizierung: VerificationResult übernimmt die kryptografische Validierung von Transaktionen

Transaction.updates: asynchroner Stream für Hintergrundtransaktionen

Transaction.currentEntitlements: direkter Zugriff auf aktive Berechtigungen des Benutzers

✅ Werbeangebote: isEligibleForIntroOffer zur Berechtigungsprüfung

✅ Server-Validierung: JWS und App Store Server API für erhöhte Sicherheit

transaction.finish(): obligatorisch nach der Inhaltsauslieferung

✅ Offline-Handling: lokales Caching von Berechtigungen für reibungsloses Erlebnis

Fang an zu üben!

Teste dein Wissen mit unseren Interview-Simulatoren und technischen Tests.

Tags

#ios
#storekit
#in-app-purchase
#swift
#interview

Teilen

Verwandte Artikel