Saltar al contenido
Development

Desbloqueo de por vida con StoreKit 2 en una app SwiftUI de Swift 6: tres cosas que me mordieron

Por Victor Da Luz
iosswiftstorekitswiftuidev-logdeep-cut-atlas

Esta app se renombró más adelante a Deep Cut Atlas. Acá abajo se la llama “Discoverer” todo el tiempo, porque así se llamaba el día en que pasó esto.

Esta semana agregué un desbloqueo único de “Pro de por vida” a una pequeña app de SwiftUI. StoreKit 2 hace que la compra en sí sea casi aburrida, product.purchase(), verificar el resultado, listo. Lo que en verdad me costó tiempo fue la costura entre StoreKit, las reglas de concurrencia de Swift 6, y la observación de SwiftUI. Tres cosas separadas me hicieron tropezar, y ninguna apareció como esperaba.

Esta es la forma de la función: un producto no consumible, un servicio @Observable que es dueño del estado de la compra, y una pantalla de Settings que cambia de “Free” a “Pro” en el momento en que se compra. Todo probable en el simulador con una configuración .storekit local, sin necesitar todavía una cuenta de Apple Developer.

El camino feliz es corto

StoreKit 2 es genuinamente agradable. Todo el flujo de compra es async/await y los tipos son pequeños:

func purchase() async {
    guard let product = proProduct else { return }
    let result = try await product.purchase()
    if case .success(let verification) = result,
       case .verified(let transaction) = verification {
        await transaction.finish()
        await refreshEntitlements()
    }
}

La única regla que vale la pena grabarse en la memoria: Transaction.currentEntitlements es la fuente de verdad de lo que la persona usuaria posee, no el resultado de ninguna llamada de compra individual. Guardo en caché un booleano en UserDefaults para que la interfaz muestre lo correcto al instante al iniciar, pero siempre reconcilio contra currentEntitlements después. Ese caché es una conveniencia, nunca la autoridad.

Cosa uno: el protocolo al que recurrí habría roto las actualizaciones en vivo

Mi app guarda sus servicios en un modelo a nivel de toda la app. El patrón existente envuelve cada servicio en un protocolo, any MusicLibraryServiceProtocol, para que las pruebas puedan sustituirlo por un mock. Mi instinto fue hacer lo mismo para la tienda: any StoreServiceProtocol.

Ese instinto estaba equivocado, y la razón es sutil. El servicio de música solo se llama a través de sus métodos asíncronos. Nada en la interfaz observa sus propiedades. Pero la bandera isPro de la tienda es lo opuesto, la pantalla de Settings tiene que redibujarse en el instante en que cambia. El seguimiento de cambios de @Observable de SwiftUI es lo que hace que ese redibujado ocurra, y leer una propiedad a través de un existential any Protocol es exactamente el caso donde ese seguimiento se vuelve inestable.

@MainActor @Observable
final class AppModel {
    let musicLibrary: any MusicLibraryServiceProtocol  // observed: no
    let store: StoreService                            // observed: yes - concrete
}

Mantuve la capacidad de prueba inyectando UserDefaults en la tienda en vez de esconderla detrás de un protocolo. La lección que me llevo: “envolverlo en un protocolo para las pruebas” es un buen valor por defecto, pero si las propiedades del tipo manejan una interfaz en vivo, es mejor preferir el @Observable concreto. La observación a través de un tipo concreto es el camino garantizado.

Cosa dos: cancelar un Task en deinit peleó con el compilador

La tienda necesita un escucha de larga duración para las transacciones que llegan fuera de una compra directa, aprobaciones de Ask-to-Buy, reembolsos, restauraciones desde otro dispositivo. Eso es un Task iterando Transaction.updates, y quería cancelarlo en deinit. Código obvio, rechazo instantáneo:

private var updatesTask: Task<Void, Never>?
deinit { updatesTask?.cancel() }
// main actor-isolated property 'updatesTask' can not be
// referenced from a nonisolated context

La clase es @MainActor, pero deinit no está aislado (nonisolated), puede correr en cualquier hilo, así que no puede tocar una propiedad aislada al actor principal. Probé las dos cosas que se probarían después. Un nonisolated var simple es rechazado porque solo funciona en let. Agregar nonisolated(unsafe) compiló pero lanzó una advertencia, porque el macro @Observable seguía envolviendo la propiedad en almacenamiento rastreado.

Lo que realmente funcionó fue decirle al macro que dejara la propiedad en paz, y marcarla como unsafe-nonisolated:

@ObservationIgnored nonisolated(unsafe)
private var updatesTask: Task<Void, Never>?

El @ObservationIgnored es la mitad que sostiene todo. Sin él, @Observable genera accesores aislados al actor principal para la propiedad, y esos accesores son lo que reintroduce el error. El handle de la tarea es plomería interna, no estado de interfaz, así que no tenía por qué estar observado en primer lugar. Es seguro porque el handle se escribe una vez en init y se lee una vez en deinit, sin acceso concurrente, y Task.cancel() es thread-safe por sí mismo.

Cosa tres: una alerta de error que en silencio no hacía nada

Esta solo la atrapé porque un revisor hurgó en la ruta de falla. Mi tienda fija un string lastErrorMessage y la pantalla de Settings lo muestra como una alerta. La mayor parte del tiempo funcionaba. En una ruta, el retorno anticipado cuando el producto no había cargado, tocar el botón no hacía absolutamente nada. Sin alerta, sin feedback, un botón muerto.

La causa es una regla de observación de SwiftUI que había medio olvidado: body se reevalúa solo cuando cambia una propiedad @Observable que fue leída durante body. Mi alerta leía el error dentro del closure get del binding y el closure message, pero esos corren después, llamados por la maquinaria de la alerta, no mientras body se está construyendo. Así que lastErrorMessage nunca se registró como una dependencia.

Las rutas que “funcionaban” solo funcionaban por suerte: también cambiaban una bandera isProcessing que el body lee para un spinner, lo cual forzaba un redibujado, durante el cual SwiftUI volvía a verificar la alerta. La ruta de retorno anticipado fijaba el error y no tocaba nada más. Sin lectura, sin redibujado, sin alerta.

El arreglo es leer el valor durante body, y la sobrecarga presenting: hace exactamente eso:

.alert(
    "Something went wrong",
    isPresented: errorPresented,
    presenting: store.lastErrorMessage   // read during body -> tracked
) { _ in
    Button("OK") {}
} message: { message in
    Text(message)
}

Ahora cualquier cambio en el error reevalúa body y la alerta se muestra, sin importar qué ruta la haya fijado. La regla general que anoté: si un valor @Observable maneja la presentación a través de un closure pero no se lee en body, SwiftUI no va a reaccionar a eso. Todo valor que debería disparar un render tiene que leerse mientras body corre.

Lo que me diría a mí mismo antes de empezar

La API de compra fue la parte fácil. La parte difícil fueron tres lugares donde el modelo de aislamiento de Swift 6 y el modelo de observación de SwiftUI se encuentran, y en los tres el compilador o el runtime se comportaron distinto al modelo mental con el que llegué. Tipos concretos para el estado observado, @ObservationIgnored para la plomería que se cancela en deinit, y leerlo-en-body para todo lo que debería redibujarse. Nada de eso está en la documentación de StoreKit, porque en realidad nada de eso es sobre StoreKit.

Lecturas relacionadas