Colloquio StoreKit 2: Gestione Abbonamenti e Validazione Ricevute

Padroneggia le domande di colloquio iOS su StoreKit 2, gestione abbonamenti, validazione ricevute e implementazione degli acquisti in-app con esempi pratici di codice Swift.

Architettura abbonamenti iOS StoreKit 2 e validazione ricevute

StoreKit 2 rappresenta un cambiamento fondamentale nel modo in cui vengono gestiti gli acquisti in-app su iOS. Introdotto con iOS 15, questo framework moderno semplifica drasticamente il codice necessario per la gestione degli abbonamenti e la validazione delle transazioni. I colloqui tecnici iOS affrontano regolarmente questo argomento per valutare l'esperienza degli sviluppatori nella monetizzazione delle app.

Punto Chiave per il Colloquio

StoreKit 2 sfrutta un'API Swift nativa con async/await, eliminando la necessità di callback complessi e validazione delle ricevute lato client. Gli intervistatori apprezzano i candidati che sanno articolare le differenze fondamentali con StoreKit 1.

Panoramica dell'Architettura StoreKit 2

StoreKit 2 è costruito su un'architettura moderna che sfrutta appieno Swift Concurrency. A differenza di StoreKit 1, tutte le operazioni sono asincrone e utilizzano tipi nativi di Swift.

Recupero dei Prodotti Disponibili

Il primo passo consiste nel caricare i prodotti configurati su App Store Connect. StoreKit 2 semplifica questa operazione con un'API dichiarativa.

ProductService.swiftswift
import StoreKit

actor ProductService {

    // Identificatori dei prodotti configurati su App Store Connect
    private let productIdentifiers: Set<String> = [
        "com.app.subscription.monthly",
        "com.app.subscription.yearly",
        "com.app.premium.lifetime"
    ]

    // Cache dei prodotti per evitare chiamate di rete ripetute
    private var cachedProducts: [Product] = []

    func loadProducts() async throws -> [Product] {
        // Restituisce la cache se disponibile
        guard cachedProducts.isEmpty else {
            return cachedProducts
        }

        // Richiesta asincrona all'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 }
    }
}

L'utilizzo di un actor garantisce la thread-safety nella gestione della cache dei prodotti.

Tipi di Prodotti in StoreKit 2

StoreKit 2 distingue chiaramente tra i diversi tipi di prodotti. Comprendere questa distinzione è essenziale per i colloqui.

ProductType+Extensions.swiftswift
import StoreKit

extension Product.ProductType {

    var displayName: String {
        switch self {
        case .consumable:
            // Prodotti consumabili (crediti, vite, ecc.)
            return "Consumable"
        case .nonConsumable:
            // Acquisti permanenti (sblocco funzionalità)
            return "Non-Consumable"
        case .autoRenewable:
            // Abbonamenti con rinnovo automatico
            return "Auto-Renewable Subscription"
        case .nonRenewable:
            // Abbonamenti senza rinnovo (pass temporaneo)
            return "Non-Renewable Subscription"
        @unknown default:
            return "Unknown"
        }
    }
}

Gli abbonamenti con rinnovo automatico rappresentano il modello di business dominante per le applicazioni iOS moderne.

Gestione delle Transazioni di Acquisto

Il processo di acquisto in StoreKit 2 diventa lineare grazie ad async/await. Niente più delegate e callback annidati di StoreKit 1.

Avvio di un Acquisto

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 {
        // Avvia l'acquisto con il modale di sistema
        let result = try await product.purchase()

        switch result {
        case .success(let verification):
            // Verifica la firma della transazione
            let transaction = try checkVerification(verification)

            // Contrassegna la transazione come completata
            await transaction.finish()

            return transaction

        case .userCancelled:
            // L'utente ha annullato il modale di acquisto
            throw PurchaseError.purchaseCancelled

        case .pending:
            // Acquisto in attesa (approvazione genitoriale, ecc.)
            throw PurchaseError.purchasePending

        @unknown default:
            throw PurchaseError.unknown
        }
    }

    private func checkVerification<T>(_ result: VerificationResult<T>) throws -> T {
        switch result {
        case .verified(let safe):
            // Transazione firmata e verificata dall'App Store
            return safe
        case .unverified(_, let error):
            // Firma non valida o corrotta
            throw PurchaseError.verificationFailed
        }
    }
}

Il metodo finish() è cruciale: segnala all'App Store che il contenuto è stato consegnato all'utente.

Avviso Colloquio

Non dimenticare mai di chiamare transaction.finish() dopo aver consegnato il contenuto. Una transazione non finalizzata verrà ripristinata al successivo avvio dell'app, causando comportamenti imprevisti.

Ascolto delle Transazioni in Background

StoreKit 2 introduce Transaction.updates, uno stream asincrono che emette le transazioni che avvengono al di fuori dell'app (rinnovi, acquisti family sharing, ecc.).

TransactionObserver.swiftswift
import StoreKit

actor TransactionObserver {

    private var updateTask: Task<Void, Never>?

    func startObserving() {
        // Annulla qualsiasi osservazione precedente
        updateTask?.cancel()

        updateTask = Task(priority: .background) {
            // Stream infinito di aggiornamenti delle transazioni
            for await result in Transaction.updates {
                do {
                    let transaction = try self.checkVerification(result)

                    // Elabora la transazione in base al suo tipo
                    await self.handleTransaction(transaction)

                    // Finalizza sempre la transazione
                    await transaction.finish()
                } catch {
                    // Registra l'errore per il debug
                    print("Transaction verification failed: \(error)")
                }
            }
        }
    }

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

    private func handleTransaction(_ transaction: Transaction) async {
        switch transaction.productType {
        case .autoRenewable:
            // Aggiorna lo stato dell'abbonamento
            await updateSubscriptionStatus(transaction)
        case .nonConsumable:
            // Sblocca la funzionalità premium
            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 {
        // Implementazione dell'aggiornamento dello stato
    }

    private func unlockFeature(_ productID: String) async {
        // Implementazione dello sblocco delle funzionalità
    }

    enum PurchaseError: Error {
        case verificationFailed
    }
}

L'osservazione delle transazioni dovrebbe iniziare non appena l'app viene avviata per evitare di perdere aggiornamenti.

Domande Comuni di Colloquio sugli Abbonamenti StoreKit 2

I colloqui iOS includono spesso domande specifiche sulla gestione degli abbonamenti. Ecco le più comuni con risposte dettagliate.

Domanda 1: Come Verificare lo Stato Attuale dell'Abbonamento?

StoreKit 2 fornisce accesso diretto agli entitlement attivi tramite 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 {
        // Recupera tutte le transazioni attive
        var latestTransaction: Transaction?

        for await result in Transaction.currentEntitlements {
            if case .verified(let transaction) = result {
                // Filtra solo gli abbonamenti
                if transaction.productType == .autoRenewable {
                    // Mantiene la transazione più recente
                    if latestTransaction == nil ||
                       transaction.purchaseDate > latestTransaction!.purchaseDate {
                        latestTransaction = transaction
                    }
                }
            }
        }

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

        // Verifica se l'abbonamento è ancora attivo
        let isActive = transaction.expirationDate ?? Date.distantPast > Date()

        // Recupera le informazioni di rinnovo
        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
        }

        // Recupera lo stato di rinnovo
        let status = try? await subscription.status.first
        return status?.renewalInfo
    }
}

Questo approccio fornisce lo stato preciso dell'abbonamento senza chiamate al server.

Pronto a superare i tuoi colloqui su iOS?

Pratica con i nostri simulatori interattivi, flashcards e test tecnici.

Domanda 2: Come Gestire Periodi di Prova Gratuiti e Offerte Promozionali?

StoreKit 2 semplifica l'accesso alle informazioni sulle offerte. Gli intervistatori spesso testano la comprensione dei diversi tipi di offerte.

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:
            // Periodo di prova gratuito
            return .freeTrial(duration: formatPeriod(offer.period))

        case .payAsYouGo:
            // Prezzo ridotto su più periodi
            return .payAsYouGo(
                periods: offer.periodCount,
                price: offer.price
            )

        case .payUpFront:
            // Pagamento unico per un periodo
            return .payUpFront(
                duration: formatPeriod(offer.period),
                price: offer.price
            )

        @unknown default:
            return .none
        }
    }

    static func checkEligibility(for product: Product) async -> Bool {
        // Verifica se l'utente può beneficiare dell'offerta
        guard let subscription = product.subscription else {
            return false
        }

        // isEligibleForIntroOffer indica se è un nuovo abbonato
        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 ""
        }
    }
}

Il metodo isEligibleForIntroOffer è essenziale per evitare di mostrare offerte non disponibili.

Domanda 3: Come Ripristinare gli Acquisti Precedenti?

Il ripristino degli acquisti è una funzionalità obbligatoria secondo le linee guida dell'App Store.

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] = []

        // Sincronizza con l'App Store
        try await AppStore.sync()

        // Itera attraverso tutte le transazioni dell'utente
        for await result in Transaction.currentEntitlements {
            guard case .verified(let transaction) = result else {
                continue
            }

            switch transaction.productType {
            case .nonConsumable:
                // Ripristina gli acquisti permanenti
                restoredProducts.append(transaction.productID)

            case .autoRenewable:
                // Verifica se l'abbonamento è attivo
                if let expiration = transaction.expirationDate,
                   expiration > Date() {
                    activeSubscriptions.append(transaction.productID)
                }

            default:
                // I consumabili non sono ripristinabili
                break
            }
        }

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

AppStore.sync() forza la sincronizzazione con i server Apple, utile dopo una reinstallazione.

Validazione delle Ricevute Lato Server

La validazione lato server rimane consigliata per le applicazioni con abbonamenti critici. StoreKit 2 introduce JWS (JSON Web Signatures) per una validazione moderna.

Estrazione dei Dati di Transazione per il Server

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 {
        // Crea il payload per il server
        let payload = TransactionPayload(
            transactionID: String(transaction.id),
            originalTransactionID: String(transaction.originalID),
            productID: transaction.productID,
            purchaseDate: transaction.purchaseDate,
            expirationDate: transaction.expirationDate,
            jwsRepresentation: transaction.jwsRepresentation ?? ""
        )

        // Codifica in JSON
        let encoder = JSONEncoder()
        encoder.dateEncodingStrategy = .iso8601
        let jsonData = try encoder.encode(payload)

        // Invia al server
        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
        }

        // Decodifica la risposta del server
        let validationResponse = try JSONDecoder().decode(
            ValidationResponse.self,
            from: data
        )

        return validationResponse.isValid
    }
}

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

La jwsRepresentation contiene la firma crittografica verificabile dal server.

App Store Server API

Apple ora fornisce l'App Store Server API per la validazione lato server. Questa API moderna sostituisce l'endpoint deprecato verifyReceipt e offre funzionalità avanzate come le notifiche server-to-server.

Configurazione delle Notifiche del Server

Le Server Notifications V2 consentono la ricezione di eventi in tempo reale.

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?
    }
}

Queste notifiche consentono di mantenere lo stato degli abbonamenti sincronizzato con il backend.

Gestione degli Errori e Casi Limite

I colloqui spesso testano la comprensione degli scenari di errore e della loro corretta gestione.

Errori Comuni in StoreKit 2

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 {
        // Verifica se è un errore StoreKit
        if let storeKitError = error as? StoreKitError {
            switch storeKitError {
            case .networkError:
                return .networkError
            case .userCancelled:
                // Non mostrare errore, l'utente ha annullato
                return .unknown
            case .notAvailableInStorefront:
                return .productNotAvailable
            case .notEntitled:
                return .notAuthorized
            default:
                return .unknown
            }
        }

        // Errori di acquisto
        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
    }
}

Una gestione chiara degli errori migliora l'esperienza utente e semplifica il debug.

Supporto alla Modalità Offline

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
            }
        }

        // Salva localmente
        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 [:]
        }

        // Filtra gli entitlement scaduti
        let now = Date()
        return entitlements.filter { _, expiration in
            expiration > now
        }
    }

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

La cache locale mantiene l'accesso alle funzionalità premium anche senza connettività.

Best Practice per la Produzione

Architettura Consigliata

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() {
        // Avvia l'osservazione delle transazioni
        startTransactionObserver()

        // Carica lo stato iniziale
        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()
    }
}

Questa architettura centralizzata semplifica la gestione dello stato degli acquisti in tutta l'applicazione.

Conclusione

StoreKit 2 trasforma la gestione degli acquisti in-app in un'esperienza di sviluppo moderna e sicura. Punti chiave da ricordare per un colloquio:

✅ API nativa async/await: niente più delegate e callback complessi di StoreKit 1

✅ Verifica automatica: VerificationResult gestisce la validazione crittografica delle transazioni

Transaction.updates: stream asincrono per le transazioni in background

Transaction.currentEntitlements: accesso diretto agli entitlement attivi dell'utente

✅ Offerte promozionali: isEligibleForIntroOffer per verificare l'idoneità

✅ Validazione server: JWS e App Store Server API per maggiore sicurezza

transaction.finish(): obbligatorio dopo la consegna del contenuto

✅ Gestione offline: cache locale degli entitlement per un'esperienza fluida

Inizia a praticare!

Metti alla prova le tue conoscenze con i nostri simulatori di colloquio e test tecnici.

Tag

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

Condividi

Articoli correlati