Entrevista StoreKit 2: Gerenciamento de Assinaturas e Validação de Recibos

Domine perguntas de entrevista iOS sobre StoreKit 2, gerenciamento de assinaturas, validação de recibos e implementação de compras no aplicativo com exemplos práticos em Swift.

Arquitetura de assinaturas iOS StoreKit 2 e validação de recibos

O StoreKit 2 representa uma mudança fundamental na forma como as compras no aplicativo são tratadas no iOS. Introduzido com o iOS 15, este framework moderno simplifica drasticamente o código necessário para o gerenciamento de assinaturas e validação de transações. As entrevistas técnicas iOS abordam regularmente este tema para avaliar a experiência dos desenvolvedores em monetização de aplicativos.

Ponto-Chave para a Entrevista

O StoreKit 2 aproveita uma API nativa do Swift com async/await, eliminando a necessidade de callbacks complexos e validação de recibos no lado do cliente. Os entrevistadores valorizam candidatos que conseguem articular as diferenças fundamentais em relação ao StoreKit 1.

Visão Geral da Arquitetura StoreKit 2

O StoreKit 2 é construído sobre uma arquitetura moderna que aproveita ao máximo o Swift Concurrency. Diferente do StoreKit 1, todas as operações são assíncronas e usam tipos nativos do Swift.

Buscando Produtos Disponíveis

O primeiro passo envolve carregar os produtos configurados no App Store Connect. O StoreKit 2 simplifica esta operação com uma API declarativa.

ProductService.swiftswift
import StoreKit

actor ProductService {

    // Identificadores de produtos configurados no App Store Connect
    private let productIdentifiers: Set<String> = [
        "com.app.subscription.monthly",
        "com.app.subscription.yearly",
        "com.app.premium.lifetime"
    ]

    // Cache de produtos para evitar chamadas de rede repetidas
    private var cachedProducts: [Product] = []

    func loadProducts() async throws -> [Product] {
        // Retorna o cache se disponível
        guard cachedProducts.isEmpty else {
            return cachedProducts
        }

        // Solicitação assíncrona à 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 }
    }
}

O uso de um actor garante a segurança de threads ao gerenciar o cache de produtos.

Tipos de Produtos no StoreKit 2

O StoreKit 2 distingue claramente entre os diferentes tipos de produtos. Compreender essa distinção é essencial para entrevistas.

ProductType+Extensions.swiftswift
import StoreKit

extension Product.ProductType {

    var displayName: String {
        switch self {
        case .consumable:
            // Produtos consumíveis (créditos, vidas, etc.)
            return "Consumable"
        case .nonConsumable:
            // Compras permanentes (desbloqueio de recursos)
            return "Non-Consumable"
        case .autoRenewable:
            // Assinaturas com renovação automática
            return "Auto-Renewable Subscription"
        case .nonRenewable:
            // Assinaturas sem renovação (passe temporário)
            return "Non-Renewable Subscription"
        @unknown default:
            return "Unknown"
        }
    }
}

As assinaturas com renovação automática representam o modelo de negócio dominante para aplicativos iOS modernos.

Tratando Transações de Compra

O processo de compra no StoreKit 2 torna-se linear graças ao async/await. Acabaram-se os delegates e callbacks aninhados do StoreKit 1.

Iniciando uma Compra

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 {
        // Inicia a compra com o modal do sistema
        let result = try await product.purchase()

        switch result {
        case .success(let verification):
            // Verifica a assinatura da transação
            let transaction = try checkVerification(verification)

            // Marca a transação como finalizada
            await transaction.finish()

            return transaction

        case .userCancelled:
            // Usuário cancelou o modal de compra
            throw PurchaseError.purchaseCancelled

        case .pending:
            // Compra pendente (aprovação parental, etc.)
            throw PurchaseError.purchasePending

        @unknown default:
            throw PurchaseError.unknown
        }
    }

    private func checkVerification<T>(_ result: VerificationResult<T>) throws -> T {
        switch result {
        case .verified(let safe):
            // Transação assinada e verificada pela App Store
            return safe
        case .unverified(_, let error):
            // Assinatura inválida ou corrompida
            throw PurchaseError.verificationFailed
        }
    }
}

O método finish() é crucial: sinaliza à App Store que o conteúdo foi entregue ao usuário.

Alerta de Entrevista

Nunca esqueça de chamar transaction.finish() após entregar o conteúdo. Uma transação não finalizada será restaurada na próxima inicialização do app, causando comportamentos inesperados.

Escutando Transações em Segundo Plano

O StoreKit 2 introduz Transaction.updates, um stream assíncrono que emite as transações que ocorrem fora do aplicativo (renovações, compras de family sharing, etc.).

TransactionObserver.swiftswift
import StoreKit

actor TransactionObserver {

    private var updateTask: Task<Void, Never>?

    func startObserving() {
        // Cancela qualquer observação anterior
        updateTask?.cancel()

        updateTask = Task(priority: .background) {
            // Stream infinito de atualizações de transações
            for await result in Transaction.updates {
                do {
                    let transaction = try self.checkVerification(result)

                    // Processa a transação de acordo com seu tipo
                    await self.handleTransaction(transaction)

                    // Sempre finaliza a transação
                    await transaction.finish()
                } catch {
                    // Registra o erro para depuração
                    print("Transaction verification failed: \(error)")
                }
            }
        }
    }

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

    private func handleTransaction(_ transaction: Transaction) async {
        switch transaction.productType {
        case .autoRenewable:
            // Atualiza o status da assinatura
            await updateSubscriptionStatus(transaction)
        case .nonConsumable:
            // Desbloqueia o recurso 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 {
        // Implementação da atualização de status
    }

    private func unlockFeature(_ productID: String) async {
        // Implementação do desbloqueio de recursos
    }

    enum PurchaseError: Error {
        case verificationFailed
    }
}

A observação de transações deve começar assim que o aplicativo for iniciado para evitar perder qualquer atualização.

Perguntas Comuns de Entrevista sobre Assinaturas StoreKit 2

As entrevistas iOS frequentemente incluem perguntas específicas sobre o gerenciamento de assinaturas. Aqui estão as mais comuns com respostas detalhadas.

Pergunta 1: Como Verificar o Status Atual da Assinatura?

O StoreKit 2 fornece acesso direto aos entitlements ativos via 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 todas as transações ativas
        var latestTransaction: Transaction?

        for await result in Transaction.currentEntitlements {
            if case .verified(let transaction) = result {
                // Filtra apenas assinaturas
                if transaction.productType == .autoRenewable {
                    // Mantém a transação mais 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 a assinatura ainda está ativa
        let isActive = transaction.expirationDate ?? Date.distantPast > Date()

        // Recupera as informações de renovação
        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 o status de renovação
        let status = try? await subscription.status.first
        return status?.renewalInfo
    }
}

Essa abordagem fornece o estado preciso da assinatura sem chamadas ao servidor.

Pronto para mandar bem nas entrevistas de iOS?

Pratique com nossos simuladores interativos, flashcards e testes tecnicos.

Pergunta 2: Como Tratar Trials Gratuitos e Ofertas Promocionais?

O StoreKit 2 simplifica o acesso às informações das ofertas. Os entrevistadores frequentemente testam o entendimento dos diferentes tipos de ofertas.

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:
            // Período de avaliação gratuita
            return .freeTrial(duration: formatPeriod(offer.period))

        case .payAsYouGo:
            // Preço reduzido durante vários períodos
            return .payAsYouGo(
                periods: offer.periodCount,
                price: offer.price
            )

        case .payUpFront:
            // Pagamento único por um período
            return .payUpFront(
                duration: formatPeriod(offer.period),
                price: offer.price
            )

        @unknown default:
            return .none
        }
    }

    static func checkEligibility(for product: Product) async -> Bool {
        // Verifica se o usuário pode se beneficiar da oferta
        guard let subscription = product.subscription else {
            return false
        }

        // isEligibleForIntroOffer indica se é um novo assinante
        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 ""
        }
    }
}

O método isEligibleForIntroOffer é essencial para evitar exibir ofertas indisponíveis.

Pergunta 3: Como Restaurar Compras Anteriores?

A restauração de compras é uma funcionalidade obrigatória conforme as diretrizes da 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] = []

        // Sincroniza com a App Store
        try await AppStore.sync()

        // Itera por todas as transações do usuário
        for await result in Transaction.currentEntitlements {
            guard case .verified(let transaction) = result else {
                continue
            }

            switch transaction.productType {
            case .nonConsumable:
                // Restaura compras permanentes
                restoredProducts.append(transaction.productID)

            case .autoRenewable:
                // Verifica se a assinatura está ativa
                if let expiration = transaction.expirationDate,
                   expiration > Date() {
                    activeSubscriptions.append(transaction.productID)
                }

            default:
                // Consumíveis não podem ser restaurados
                break
            }
        }

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

O AppStore.sync() força a sincronização com os servidores da Apple, útil após uma reinstalação.

Validação de Recibos no Lado do Servidor

A validação no lado do servidor permanece recomendada para aplicativos com assinaturas críticas. O StoreKit 2 introduz JWS (JSON Web Signatures) para validação moderna.

Extraindo Dados da Transação para o Servidor

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 {
        // Cria o payload para o servidor
        let payload = TransactionPayload(
            transactionID: String(transaction.id),
            originalTransactionID: String(transaction.originalID),
            productID: transaction.productID,
            purchaseDate: transaction.purchaseDate,
            expirationDate: transaction.expirationDate,
            jwsRepresentation: transaction.jwsRepresentation ?? ""
        )

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

        // Envia para o servidor
        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 a resposta do servidor
        let validationResponse = try JSONDecoder().decode(
            ValidationResponse.self,
            from: data
        )

        return validationResponse.isValid
    }
}

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

A jwsRepresentation contém a assinatura criptográfica verificável pelo servidor.

App Store Server API

A Apple agora fornece a App Store Server API para validação no lado do servidor. Esta API moderna substitui o endpoint depreciado verifyReceipt e oferece recursos avançados como notificações servidor a servidor.

Configurando Notificações do Servidor

As Server Notifications V2 permitem o recebimento de eventos em tempo real.

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

Essas notificações permitem manter o estado das assinaturas sincronizado com o backend.

Tratamento de Erros e Casos Extremos

As entrevistas frequentemente testam o entendimento dos cenários de erro e seu tratamento adequado.

Erros Comuns no 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 é um erro do StoreKit
        if let storeKitError = error as? StoreKitError {
            switch storeKitError {
            case .networkError:
                return .networkError
            case .userCancelled:
                // Não exibe erro, o usuário cancelou
                return .unknown
            case .notAvailableInStorefront:
                return .productNotAvailable
            case .notEntitled:
                return .notAuthorized
            default:
                return .unknown
            }
        }

        // Erros de compra
        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
    }
}

Um tratamento claro de erros melhora a experiência do usuário e simplifica a depuração.

Suporte ao Modo 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 os entitlements expirados
        let now = Date()
        return entitlements.filter { _, expiration in
            expiration > now
        }
    }

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

O cache local mantém o acesso aos recursos premium mesmo sem conectividade.

Melhores Práticas para Produção

Arquitetura Recomendada

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() {
        // Inicia a observação de transações
        startTransactionObserver()

        // Carrega o estado inicial
        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()
    }
}

Essa arquitetura centralizada simplifica o gerenciamento do estado das compras em todo o aplicativo.

Conclusão

O StoreKit 2 transforma o gerenciamento de compras no aplicativo em uma experiência de desenvolvimento moderna e segura. Pontos-chave para lembrar para uma entrevista:

✅ API nativa async/await: acabaram-se os delegates e callbacks complexos do StoreKit 1

✅ Verificação automática: VerificationResult gerencia a validação criptográfica das transações

Transaction.updates: stream assíncrono para transações em segundo plano

Transaction.currentEntitlements: acesso direto aos entitlements ativos do usuário

✅ Ofertas promocionais: isEligibleForIntroOffer para verificar a elegibilidade

✅ Validação no servidor: JWS e App Store Server API para maior segurança

transaction.finish(): obrigatório após a entrega do conteúdo

✅ Tratamento offline: cache local de entitlements para uma experiência fluida

Comece a praticar!

Teste seus conhecimentos com nossos simuladores de entrevista e testes tecnicos.

Tags

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

Compartilhar

Artigos relacionados