Programación en Swift y SwiftUI para iOS Developers

Cómo añadir compras integradas en una aplicación en SwiftUI

Desarrollar una aplicación increíble es solo la mitad de la batalla. La otra mitad, a menudo la más compleja para los desarrolladores independientes y pequeñas startups, es convertir ese código en ingresos sostenibles.

Durante años, integrar compras dentro de la aplicación (In-App Purchases o IAP) en iOS fue una tarea desalentadora. Implicaba lidiar con SKPaymentQueue, delegados complejos, validación de recibos con OpenSSL y servidores backend propios. Afortunadamente, esos días han quedado atrás.

Con la llegada de StoreKit 2 y su integración profunda con la concurrencia de Swift (async/await), implementar un sistema de monetización robusto en SwiftUI es ahora una experiencia fluida, segura y casi agradable.

En este tutorial de “clase magistral”, construiremos desde cero un sistema de compras para una aplicación multiplataforma (iOS, macOS, watchOS), cubriendo desde la configuración en App Store Connect hasta la interfaz de usuario de la “Paywall”.


Parte 1: Conceptos Fundamentales y Estrategia

Antes de escribir una sola línea de código, debemos entender qué estamos vendiendo. Apple clasifica las compras en cuatro tipos:

  1. Consumibles: Se usan una vez y se agotan (ej. monedas en un juego).
  2. No Consumibles: Se compran una vez y duran para siempre (ej. desbloquear “Modo Pro”, filtros de fotos). Nos centraremos en este para el tutorial.
  3. Suscripciones Renovables: Cobro recurrente (ej. Netflix, Spotify).
  4. Suscripciones No Renovables: Acceso por tiempo limitado (ej. pase de temporada).

Para este tutorial, simularemos una aplicación de productividad donde el usuario puede comprar una “Versión Premium” (Lifetime) que desbloquea funcionalidades ilimitadas.


Parte 2: Configuración del Entorno (Sin Código Aún)

El error número uno de los desarrolladores es saltar a Xcode sin configurar los metadatos.

1. App Store Connect

Para probar compras reales (incluso en modo Sandbox), necesitas definir los productos en los servidores de Apple.

  1. Entra a App Store Connect.
  2. Ve a “Mis Apps” y selecciona tu aplicación.
  3. En la barra lateral, busca Monetización > Compras dentro de la app.
  4. Haz clic en el botón + y selecciona No consumible.
  5. ID del producto: Esto es crucial. Usa la notación inversa de dominio. Ejemplo: com.tuempresa.tuapp.premium_lifetime.
  6. Rellena el nombre de referencia y el precio (ej. Tier 5 – $4.99).
  7. Importante: Añade una localización (Nombre y Descripción visibles para el usuario) y una captura de pantalla (puedes usar una imagen vacía temporalmente para pruebas), o el estado quedará en “Metadatos faltantes” y no podrás probarlo.

2. Configuración en Xcode

  1. Abre tu proyecto en Xcode.
  2. Selecciona el target de tu app -> Pestaña Signing & Capabilities.
  3. Haz clic en + Capability y busca In-App Purchase. Esto añade el framework al proyecto.

Parte 3: El Archivo de Configuración de StoreKit (El Secreto de la Productividad)

Antiguamente, para probar compras necesitabas un dispositivo físico y cuentas de Sandbox, lo cual era lento y propenso a fallos. Xcode ahora permite pruebas locales.

  1. En Xcode, File > New > File...
  2. Busca StoreKit Configuration File.
  3. Nómbralo Products.storekit y guárdalo en tu proyecto.
  4. Abre el archivo. Verás una interfaz visual.
  5. Haz clic en + abajo a la izquierda y selecciona Add Non-Consumable In-App Purchase.
  6. Introduce el mismo Product ID que creaste en App Store Connect (com.tuempresa.tuapp.premium_lifetime).
  7. Ponle un precio y una descripción para pruebas.

Paso Crítico: Para que Xcode use este archivo en lugar de conectar a los servidores de Apple:

  1. Haz clic en el esquema de tu app (arriba, junto al simulador).
  2. Edit Scheme…
  3. En la pestaña Run, ve a Options.
  4. En StoreKit Configuration, selecciona tu archivo Products.storekit.

Ahora puedes simular compras en el simulador de iOS sin conexión a internet y con respuestas instantáneas.


Parte 4: La Lógica de Negocio (StoreManager)

Vamos a crear una clase StoreManager que será el cerebro de nuestras operaciones. Usaremos el patrón ObservableObjectpara que nuestras vistas de SwiftUI reaccionen a los cambios.

Crea un archivo llamado StoreManager.swift:

import Foundation
import StoreKit

// Alias para simplificar
typealias Transaction = StoreKit.Transaction
typealias RenewalInfo = StoreKit.Product.SubscriptionInfo.RenewalInfo
typealias RenewalState = StoreKit.Product.SubscriptionInfo.RenewalState

public enum StoreError: Error {
    case failedVerification
}

@MainActor
class StoreManager: ObservableObject {
    
    // Los productos disponibles para comprar
    @Published private(set) var products: [Product] = []
    
    // Estado de si el usuario ha comprado la versión Pro
    @Published private(set) var hasUnlockedPro: Bool = false
    
    // Los IDs de tus productos (deben coincidir con App Store Connect y .storekit)
    private let productIds: [String] = ["com.tuempresa.tuapp.premium_lifetime"]
    
    // Tarea para escuchar actualizaciones de transacciones en segundo plano
    var updateListenerTask: Task<Void, Error>? = nil

    init() {
        // Al iniciar, empezamos a escuchar transacciones
        updateListenerTask = listenForTransactions()
        
        // Iniciamos la carga de productos y verificamos compras previas
        Task {
            await requestProducts()
            await updateCustomerProductStatus()
        }
    }
    
    deinit {
        updateListenerTask?.cancel()
    }
    
    // MARK: - 1. Obtener Productos
    func requestProducts() async {
        do {
            // Llamada asíncrona a Apple para obtener detalles (precio, moneda, etc.)
            let products = try await Product.products(for: productIds)
            self.products = products.sorted(by: { $0.price < $1.price })
        } catch {
            print("Error al obtener productos: \(error)")
        }
    }
    
    // MARK: - 2. Comprar Producto
    func purchase(_ product: Product) async throws {
        let result = try await product.purchase()
        
        switch result {
        case .success(let verification):
            // La compra fue exitosa, ahora verificamos la firma criptográfica
            let transaction = try checkVerified(verification)
            
            // Entregamos el contenido al usuario
            await updateCustomerProductStatus()
            
            // Le decimos a StoreKit que hemos terminado de procesar
            await transaction.finish()
            
        case .userCancelled, .pending:
            break
        default:
            break
        }
    }
    
    // MARK: - 3. Verificar Estado del Usuario (Restore)
    func updateCustomerProductStatus() async {
        // Iteramos sobre los derechos (entitlements) actuales del usuario
        for await result in Transaction.currentEntitlements {
            do {
                let transaction = try checkVerified(result)
                
                // Si encontramos nuestro producto ID, activamos la versión Pro
                if transaction.productID == "com.tuempresa.tuapp.premium_lifetime" {
                    hasUnlockedPro = true
                }
            } catch {
                print("Fallo en verificación")
            }
        }
    }
    
    // MARK: - 4. Escucha Activa de Transacciones
    /* Esto es vital. Si una compra se aprueba fuera de la app (ej. Control Parental, 
     o se renueva una suscripción), este listener lo capturará.
    */
    func listenForTransactions() -> Task<Void, Error> {
        return Task.detached {
            for await result in Transaction.updates {
                do {
                    let transaction = try self.checkVerified(result)
                    
                    // Actualizamos la UI en el hilo principal
                    await self.updateCustomerProductStatus()
                    
                    await transaction.finish()
                } catch {
                    print("Error en transacción verificada")
                }
            }
        }
    }
    
    // Función auxiliar para verificar la firma criptográfica de Apple
    func checkVerified<T>(_ result: VerificationResult<T>) throws -> T {
        switch result {
        case .unverified:
            throw StoreError.failedVerification
        case .verified(let safe):
            return safe
        }
    }
}

Análisis del Código

  • @MainActor: Asegura que todas las actualizaciones de @Published ocurran en el hilo principal, evitando problemas de UI.
  • Transaction.currentEntitlements: Esta es la magia de StoreKit 2. Ya no necesitas un botón explícito de “Restaurar Compras” que llame a una función compleja. Simplemente iterando sobre esta secuencia asíncrona, sabes qué posee el usuario.
  • Transaction.updates: Un loop infinito que escucha eventos del App Store. Es obligatorio implementarlo para manejar casos extremos como compras interrumpidas o aprobaciones parentales diferidas.

Parte 5: Creando la Interfaz de Usuario (Paywall)

Ahora que tenemos la lógica, necesitamos una vista atractiva para vender. SwiftUI hace que esto sea trivial.

Crea PaywallView.swift:

import SwiftUI
import StoreKit

struct PaywallView: View {
    @EnvironmentObject var storeManager: StoreManager
    @Environment(\.dismiss) var dismiss
    
    var body: some View {
        ScrollView {
            VStack(spacing: 20) {
                // Header
                Image(systemName: "crown.fill")
                    .resizable()
                    .scaledToFit()
                    .frame(width: 80, height: 80)
                    .foregroundColor(.yellow)
                    .padding(.top, 40)
                
                Text("Desbloquea Premium")
                    .font(.largeTitle.bold())
                
                Text("Obtén acceso ilimitado a todas las funcionalidades, sincronización en la nube y soporte prioritario.")
                    .multilineTextAlignment(.center)
                    .padding(.horizontal)
                    .foregroundColor(.secondary)
                
                // Lista de Beneficios
                VStack(alignment: .leading, spacing: 15) {
                    FeatureRow(icon: "icloud", text: "Sincronización iCloud")
                    FeatureRow(icon: "chart.bar", text: "Estadísticas Avanzadas")
                    FeatureRow(icon: "lock.open", text: "Sin Anuncios")
                }
                .padding(.vertical)
                
                // Tarjetas de Productos
                if storeManager.products.isEmpty {
                    ProgressView("Cargando precios...")
                } else {
                    ForEach(storeManager.products) { product in
                        ProductButton(product: product) {
                            Task {
                                try? await storeManager.purchase(product)
                            }
                        }
                    }
                }
                
                // Botón Restaurar (Aunque StoreKit 2 lo hace automático, Apple recomienda ponerlo)
                Button("Restaurar Compras") {
                    Task {
                        await storeManager.updateCustomerProductStatus()
                    }
                }
                .font(.footnote)
                .foregroundColor(.secondary)
                .padding(.top)
            }
            .padding()
        }
        .onChange(of: storeManager.hasUnlockedPro) { newValue in
            if newValue {
                // Cerrar la paywall si la compra es exitosa
                dismiss()
            }
        }
    }
}

// Subvista para el botón de compra
struct ProductButton: View {
    let product: Product
    let action: () -> Void
    
    var body: some View {
        Button(action: action) {
            HStack {
                VStack(alignment: .leading) {
                    Text(product.displayName)
                        .font(.headline)
                    Text(product.description)
                        .font(.caption)
                        .foregroundColor(.secondary)
                }
                
                Spacer()
                
                Text(product.displayPrice)
                    .padding(8)
                    .background(Color.blue)
                    .foregroundColor(.white)
                    .cornerRadius(8)
            }
            .padding()
            .background(Color("CardBackground")) // Define este color en tus Assets
            .cornerRadius(12)
            .overlay(
                RoundedRectangle(cornerRadius: 12)
                    .stroke(Color.blue, lineWidth: 2)
            )
        }
        .buttonStyle(.plain)
    }
}

struct FeatureRow: View {
    let icon: String
    let text: String
    
    var body: some View {
        HStack {
            Image(systemName: icon)
                .foregroundColor(.blue)
                .frame(width: 30)
            Text(text)
        }
    }
}

Parte 6: Integración en el Flujo de la App

Finalmente, debemos conectar todo en nuestro punto de entrada de la aplicación (App.swift).

import SwiftUI

@main
struct MiAppIncreible: App {
    // Inicializamos el StoreManager como StateObject para que viva toda la sesión
    @StateObject private var storeManager = StoreManager()
    
    var body: some Scene {
        WindowGroup {
            ContentView()
                .environmentObject(storeManager) // Inyectamos al entorno
        }
    }
}

Y en tu ContentView, puedes controlar el acceso a las funciones premium:

struct ContentView: View {
    @EnvironmentObject var storeManager: StoreManager
    @State private var showPaywall = false
    
    var body: some View {
        NavigationStack {
            VStack {
                if storeManager.hasUnlockedPro {
                    Text("¡Bienvenido Usuario Premium!")
                        .font(.title)
                        .foregroundColor(.green)
                    // Contenido exclusivo aquí
                } else {
                    Text("Modo Gratuito")
                    Button("Hazte Premium") {
                        showPaywall = true
                    }
                    .buttonStyle(.borderedProminent)
                }
            }
            .sheet(isPresented: $showPaywall) {
                PaywallView()
            }
        }
    }
}

Parte 7: Pruebas y Depuración (Transaction Manager)

Una de las mejores características de desarrollar con Xcode y archivos .storekit es el Transaction Manager.

  1. Ejecuta la app en el simulador.
  2. Abre la PaywallView y compra el producto.
  3. Verás que la UI se actualiza instantáneamente.
  4. Ahora, en Xcode, ve al menú Debug > StoreKit > Manage Transactions.

Aquí verás una ventana flotante con todas las compras realizadas en el simulador. Puedes:

  • Borrar transacciones (para simular un usuario nuevo).
  • Reembolsar transacciones (para probar cómo reacciona tu app si alguien devuelve el producto).
  • Simular errores de facturación.
  • Aprobar/Rechazar compras “Ask to Buy” (control parental).

Esta herramienta es invaluable para asegurarte de que tu listenForTransactions() maneja las revocaciones (refunds) correctamente, bloqueando el acceso al contenido premium si la compra desaparece.


Parte 8: Adaptación Multiplataforma (macOS y watchOS)

La belleza de SwiftUI y StoreKit 2 es que el código lógico (StoreManager.swift) es 100% reutilizable en macOS y watchOS. No necesitas cambiar ni una coma en la lógica.

Sin embargo, la UI (PaywallView) podría necesitar ajustes:

  • macOS: En lugar de .sheet, quizás prefieras una ventana modal o una vista lateral. Los botones y el espaciado deben adaptarse al puntero del ratón.
  • watchOS: La pantalla es pequeña. Simplifica la descripción. Muestra solo el botón grande de “Comprar” y quizás una sola frase de beneficio.

Ejemplo de adaptación condicional en PaywallView:

#if os(watchOS)
ScrollView {
    VStack {
        Text("Hazte Pro").font(.headline)
        // Botón simplificado
    }
}
#else
// Tu diseño complejo para iOS/macOS
#endif

Consideraciones Finales y Mejores Prácticas

1. Manejo de Errores

En el código de ejemplo, usamos try? o bloques catch simples. En una app de producción, deberías mostrar alertas al usuario si la compra falla por problemas de red o si el ID de Apple no está configurado.

2. StoreKit Views (Novedad iOS 17+)

Apple introdujo StoreView y SubscriptionStoreView en iOS 17. Estas son vistas prefabricadas que renderizan la Paywall automáticamente basándose en tus metadatos de App Store Connect. Si buscas la integración más rápida posible y solo soportas iOS 17+, puedes reemplazar toda nuestra PaywallView con:

import StoreKit

struct SimplePaywall: View {
    var body: some View {
        SubscriptionStoreView(groupID: "tu_grupo_de_suscripcion")
    }
}

Nota: Esto funciona mejor para suscripciones, pero tiene opciones para no consumibles.

3. Validación del lado del servidor

Para aplicaciones de alta seguridad (ej. juegos con monedas o apps bancarias), la validación local (checkVerified en nuestro código) es muy segura, pero no invulnerable a dispositivos con Jailbreak avanzado. Si el riesgo de fraude es alto, deberías enviar el transaction.jwsRepresentation a tu propio servidor y validarlo con la API de Apple directamente.


Conclusión

Integrar compras en SwiftUI ha pasado de ser una pesadilla de código “spaghetti” a una arquitectura limpia y moderna gracias a StoreKit 2. Al seguir este tutorial, has creado una base sólida:

  1. Un StoreManager reactivo y seguro en hilos.
  2. Un entorno de pruebas local rápido con archivos .storekit.
  3. Una UI declarativa que responde al estado de la compra.

Ahora tienes las herramientas no solo para crear software, sino para construir un negocio sostenible en el ecosistema de Apple. El siguiente paso es experimentar con Suscripciones Renovables y ofrecer periodos de prueba para maximizar tu conversión.

Si tienes cualquier duda sobre este artículo, contacta conmigo y estaré encantado de ayudarte 🙂. Puedes contactar conmigo en mi perfil de X o en mi perfil de Instagram

Leave a Reply

Your email address will not be published. Required fields are marked *

Previous Article

Mejores paquetes para aplicaciones en SwiftUI

Next Article

SwiftData vs Core Data : Qué framework deberías elegir?

Related Posts