tutoriales.com

Maestría en StoreKit 2: Implementando Compras In-App y Suscripciones en iOS con Swift

Guía completa y avanzada para implementar StoreKit 2 en iOS. Descubre cómo gestionar productos, procesar transacciones seguras, manejar estados de suscripción y ofrecer una experiencia de pago fluida en tus apps utilizando SwiftUI.

Intermedio12 min de lectura9 views
Reportar error

Introducción a StoreKit 2 en el Ecosistema Apple

La monetización es un pilar fundamental en el ciclo de vida de cualquier aplicación moderna. Con la llegada de StoreKit 2, Apple rediseñó por completo su framework de compras dentro de la aplicación (In-App Purchases o IAP), ofreciendo una API construida desde cero aprovechando las ventajas de Swift Concurrency (async/await) y un manejo de tipos mucho más seguro y moderno.

En este tutorial exhaustivo, exploraremos cómo configurar, implementar y gestionar un flujo completo de compras y suscripciones utilizando StoreKit 2 junto con SwiftUI. Olvídate del código boilerplate heredado de Objective-C y de la complejidad innecesaria del StoreKit clásico.

💡 Consejo: StoreKit 2 gestiona automáticamente el estado criptográfico de las transacciones utilizando JSON Web Signatures (JWS), lo que simplifica enormemente la validación de recibos de forma local y segura.

🛠️ Requisitos Previos y Configuración en App Store Connect

Antes de escribir una sola línea de código en Xcode, debemos preparar nuestro entorno de desarrollo y configurar los productos en el portal de Apple.

1. Creación de Identificadores y Certificados

Asegúrate de tener una cuenta de desarrollador de Apple activa. Los pasos iniciales requieren:

  • Un App ID configurado con la capacidad de In-App Purchase habilitada.
  • Un entorno de pruebas local configurado (Xcode StoreKit Configuration File) para no depender de la App Store real durante el desarrollo.

2. Estructura de Productos Recomendada

Para una aplicación típica de productividad o contenido, la siguiente tabla resume los tipos de productos que implementaremos:

Identificador del ProductoTipo de ProductoDescripciónPrecio Sugerido
------------
com.tuempresa.app.monthlySuscripción Auto-renovableAcceso ilimitado mensual$4.99 / mes
com.tuempresa.app.yearlySuscripción Auto-renovableAcceso ilimitado anual con descuento$39.99 / año
------------
com.tuempresa.app.lifetimeCompra Única (Non-Consumable)Acceso vitalicio sin restricciones$89.99 única vez

📦 Creación de un Archivo de Configuración Local de StoreKit

Una de las mejores características para desarrolladores en Xcode es la capacidad de simular la App Store mediante un archivo .storekit.

  1. En Xcode, ve a File > New > File... y busca StoreKit Configuration File.
  2. Nómbralo Products.storekit.
  3. Añade tus productos haciendo clic en el botón + en la esquina inferior izquierda.
  4. Asegúrate de configurar correctamente los IDs para que coincidan con los que usarás en tu código.
App SwiftUI Consulta API StoreKit 2 Framework StoreKit Config File (Desarrollo Local) Apple App Store (Producción / Sandbox) Transacciones Firmadas (Estándar JWS)

🚀 Implementación del StoreManager con Swift Concurrency

Vamos a crear un controlador centralizado que gestione la carga de productos, las compras y el seguimiento de los derechos del usuario (entitlements).

Crea un archivo llamado StoreManager.swift:

import Foundation
import StoreKit
import SwiftUI

@MainActor
class StoreManager: ObservableObject {
    @Published private(set) var products: [Product] = []
    @Published private(set) var purchasedProductIDs = Set<String>()
    @Published var subscriptionGroupStatus: RenewalState?
    
    private var updateListenerTask: Task<Void, Error>? = nil
    
    let productIds = [
        "com.tuempresa.app.monthly",
        "com.tuempresa.app.yearly",
        "com.tuempresa.app.lifetime"
    ]
    
    init() {
        updateListenerTask = listenForTransactions()
        
        Task {
            await requestProducts()
            await updateCustomerProductStatus()
        }
    }
    
    deinit {
        updateListenerTask?.cancel()
    }
    
    func requestProducts() async {
        do {
            let storeProducts = try await Product.products(for: productIds)
            self.products = storeProducts.sorted(by: { $0.price < $1.price })
        } catch {
            print("Failed to fetch products from App Store: \(error)")
        }
    }
    
    func purchase(_ product: Product) async throws -> Transaction? {
        let result = try await product.purchase()
        
        switch result {
        let .success(verification):
            let transaction = try checkVerified(verification)
            await transaction.finish()
            await updateCustomerProductStatus()
            return transaction
            
        case .userCancelled:
            return nil
            
        case .pending:
            return nil
            
        @unknown default:
            return nil
        }
    }
    
    func updateCustomerProductStatus() async {
        var purchasedIDs = Set<String>()
        
        for await result in Transaction.currentEntitlements {
            do {
                let transaction = try checkVerified(result)
                
                if transaction.revocationDate == nil {
                    purchasedIDs.insert(transaction.productID)
                } else {
                    purchasedIDs.remove(transaction.productID)
                }
            } catch {
                print("Failed to verify transaction: \(error)")
            }
        }
        
        self.purchasedProductIDs = purchasedIDs
    }
    
    private func listenForTransactions() -> Task<Void, Error> {
        return Task.detached {
            for await result in Transaction.updates {
                do {
                    let transaction = try self.checkVerified(result)
                    await transaction.finish()
                    await self.updateCustomerProductStatus()
                } catch {
                    print("Transaction failed verification: \(error)")
                }
            }
        }
    }
    
    private func checkVerified<T>(_ result: VerificationResult<T>) throws -> T {
        switch result {
        case .unverified:
            throw StoreError.failedVerification
        case .verified(let safe):
            return safe
        }
    }
}

enum StoreError: Error {
    case failedVerification
}
⚠️ Advertencia: Es crucial invocar siempre `await transaction.finish()` después de procesar una transacción verificada. Si olvidas este paso, la transacción seguirá apareciendo en `Transaction.updates` en cada inicio de la aplicación.

🎨 Creación de la Interfaz de Usuario con SwiftUI

Ahora diseñaremos una vista moderna y atractiva para presentar los productos disponibles al usuario, permitiéndole comprar de forma intuitiva.

Crea una vista llamada StoreView.swift:

import SwiftUI
import StoreKit

struct StoreView: View {
    @EnvironmentObject var storeManager: StoreManager
    @State private var isPurchasing = false
    @State private var errorMessage: String?
    @State private var showingError = false
    
    var body: some View {
        NavigationStack {
            ScrollView {
                VStack(spacing: 20) {
                    headerView
                    
                    if storeManager.products.isEmpty {
                        ProgressView("Cargando ofertas...")
                            .padding(.top, 50)
                    } else {
                        ForEach(storeManager.products) { product in
                            ProductCardView(product: product, isPurchased: storeManager.purchasedProductIDs.contains(product.id)) {
                                Task {
                                    await buyProduct(product)
                                }
                            }
                        }
                    }
                    
                    restorePurchasesButton
                }
                .padding()
            }
            .navigationTitle("Tienda Premium")
            .alert("Error en la Compra", isPresented: $showingError, actions: {
                Button("OK", role: .cancel) {}
            }, message: {
                Text(errorMessage ?? "Ocurrió un error desconocido.")
            })
        }
    }
    
    private var headerView: some View {
        VStack(spacing: 8) {
            Image(systemName: "crown.fill")
                .font(.system(size: 50))
                .foregroundColor(.yellow)
            Text("Desbloquea todo el potencial")
                .font(.title2)
                .fontWeight(.bold)
            Text("Elige el plan que mejor se adapte a tus necesidades y disfruta de contenido exclusivo sin límites.")
                .font(.subheadline)
                .foregroundColor(.secondary)
                .multilineTextAlignment(.center)
        }
        .padding(.bottom, 10)
    }
    
    private var restorePurchasesButton: some View {
        Button(action: {
            Task {
                await storeManager.updateCustomerProductStatus()
            }
        }) {
            Text("Restaurar Compras")
                .font(.footnote)
                .foregroundColor(.accentColor)
        }
        .padding(.top, 20)
    }
    
    private func buyProduct(_ product: Product) async {
        isPurchasing = true
        do {
            _ = try await storeManager.purchase(product)
        } catch {
            errorMessage = error.localizedDescription
            showingError = true
        }
        isPurchasing = false
    }
}

struct ProductCardView: View {
    let product: Product
    let isPurchased: Bool
    let onPurchase: () -> Void
    
    var body: some View {
        HStack {
            VStack(alignment: .leading, spacing: 4) {
                Text(product.displayName)
                    .font(.headline)
                Text(product.description)
                    .font(.caption)
                    .foregroundColor(.secondary)
            }
            
            Spacer()
            
            if isPurchased {
                Text("Adquirido")
                    .font(.subheadline)
                    .fontWeight(.semibold)
                    .foregroundColor(.green)
                    .padding(.horizontal, 12)
                    .padding(.vertical, 6)
                    .background(Color.green.opacity(0.1))
                    .cornerRadius(8)
            } else {
                Button(action: onPurchase) {
                    Text(product.displayPrice)
                        .font(.subheadline)
                        .fontWeight(.bold)
                        .padding(.horizontal, 16)
                        .padding(.vertical, 8)
                        .background(Color.accentColor)
                        .foregroundColor(.white)
                        .cornerRadius(8)
                }
            }
        }
        .padding()
        .background(Color(.systemBackground))
        .cornerRadius(12)
        .shadow(color: Color.black.opacity(0.05), radius: 5, x: 0, y: 2)
    }
}

🔄 Gestión Avanzada de Suscripciones y Renewal States

Para aplicaciones con suscripciones auto-renovables, StoreKit 2 proporciona herramientas muy potentes para verificar el estado de renovación a través de Product.SubscriptionInfo.

Comprobación del Estado de Renovación

Añade la siguiente función a tu StoreManager para consultar el estado actual de las suscripciones y saber si están activas, en período de gracia o canceladas:

extension StoreManager {
    func checkSubscriptionStatus(for product: Product) async {
        guard let subscription = product.subscription else { return }
        
        do {
            let statuses = try await subscription.status
            for status in statuses {
                switch status.state {
                case .subscribed:
                    print("Suscripción activa y al día.")
                case .expired:
                    print("La suscripción ha expirado.")
                case .inBillingRetryPeriod:
                    print("Error de cobro, en período de reintento.")
                case .inGracePeriod:
                    print("Suscripción en período de gracia.")
                case .revoked:
                    print("Suscripción revocada por Apple o reembolso.")
                @unknown default:
                    break
                }
            }
        } catch {
            print("Error al comprobar el estado de la suscripción: \(error)")
        }
    }
}
📌 Nota: Es recomendable mostrar banners informativos en la interfaz de usuario si el estado de la suscripción se encuentra en `.inBillingRetryPeriod` para recordar al usuario actualizar su método de pago en la App Store.

🧪 Pruebas Locales y Depuración

Para probar tu implementación de StoreKit 2 sin necesidad de crear cuentas sandbox reales en App Store Connect:

  1. Selecciona tu esquema en Xcode (Product > Scheme > Edit Scheme...).
  2. En la pestaña Run, selecciona Options.
  3. En el campo StoreKit Configuration, selecciona tu archivo Products.storekit creado anteriormente.
  4. Ejecuta la aplicación en el simulador. Al realizar una compra, aparecerá la hoja de pago nativa simulada de StoreKit.
Paso 1: Crear el archivo .storekit con los identificadores de prueba.
Paso 2: Configurar el esquema en Xcode para apuntar al archivo local.
Paso 3: Implementar el StoreManager con async/await y Task.detached para Transaction.updates.
Paso 4: Validar el flujo completo de compra y restauración en el simulador.

Preguntas Frecuentes (FAQ)

¿Es necesario un servidor backend para validar las compras en StoreKit 2? No es estrictamente necesario. StoreKit 2 utiliza firmas JWS (JSON Web Signatures) que permiten verificar la autenticidad de las transacciones directamente en el dispositivo del cliente. Sin embargo, si tu app requiere sincronización de contenido en la nube o licencias multiplataforma, se recomienda implementar un servidor propio de validación.
¿Cómo manejo los reembolsos y cancelaciones de usuarios? StoreKit 2 emite notificaciones automáticas a través de `Transaction.updates` cuando una transacción es revocada o reembolsada. Tu `StoreManager` debe escuchar estos cambios continuamente para revocar el acceso de forma inmediata.

Conclusión y Próximos Pasos

Has construido con éxito un sistema de monetización moderno, robusto y escalable utilizando StoreKit 2 y Swift. Gracias al poder de async/await y las estructuras nativas de SwiftUI, tu código es ahora más limpio, fácil de mantener y preparado para las exigencias de la App Store actual.

Como siguientes pasos recomendados, puedes explorar la integración de ofertas promocionales (Promotional Offers), códigos de oferta (Offer Codes) y la implementación de notificaciones del servidor de App Store (App Store Server Notifications) para mantener tu backend sincronizado en tiempo real.

Intermedio SwiftUI StoreKit 2

Tutoriales relacionados

Comentarios (0)

Aún no hay comentarios. ¡Sé el primero!