Pular para o conteúdo
Development

Desbloqueio vitalício com StoreKit 2 num app SwiftUI em Swift 6: três coisas que me pegaram

Por Victor Da Luz
iosswiftstorekitswiftuidev-logdeep-cut-atlas

Este app foi renomeado depois para Deep Cut Atlas. Ele é chamado de “Discoverer” ao longo do texto abaixo, porque era assim que se chamava no dia em que isso aconteceu.

Adicionei um desbloqueio único “Pro vitalício” a um pequeno app SwiftUI essa semana. O StoreKit 2 torna a compra em si quase entediante, product.purchase(), verifica o resultado, pronto. O que realmente consumiu meu tempo foi a junção entre StoreKit, as regras de concorrência do Swift 6 e a observação do SwiftUI. Três coisas separadas me pegaram, e nenhuma delas se manifestou do jeito que eu esperava.

Aqui está a forma da funcionalidade: um produto não consumível, um serviço @Observable que guarda o estado da compra, e uma tela de Configurações que muda de “Free” para “Pro” no instante em que você compra. Tudo testável no simulador com uma configuração .storekit local, sem precisar de uma conta de desenvolvedor Apple ainda.

O caminho feliz é curto

O StoreKit 2 é genuinamente bom. Todo o fluxo de compra é async/await e os tipos são pequenos:

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

A regra que vale a pena gravar na memória: Transaction.currentEntitlements é a fonte da verdade sobre o que o usuário possui, não o resultado de qualquer chamada de compra isolada. Eu guardo um booleano em cache no UserDefaults para que a interface mostre a coisa certa instantaneamente ao abrir o app, mas sempre reconcilio com currentEntitlements depois. Esse cache é uma conveniência, nunca a autoridade.

Coisa um: o protocolo que eu ia usar teria quebrado as atualizações ao vivo

Meu app guarda seus serviços num modelo abrangendo todo o app. O padrão existente envolve cada serviço num protocolo, any MusicLibraryServiceProtocol, para que os testes possam trocar por um mock. Meu instinto foi fazer o mesmo para a loja: any StoreServiceProtocol.

Esse instinto estava errado, e o motivo é sutil. O serviço de música só é chamado através de seus métodos assíncronos. Nada na interface observa suas propriedades. Mas a flag isPro da loja é o oposto, a tela de Configurações precisa se redesenhar no instante em que ela muda. O rastreamento de mudanças @Observable do SwiftUI é o que faz esse redesenho acontecer, e ler uma propriedade através de um existential any Protocol é exatamente o caso em que esse rastreamento fica instável.

Então fiz da loja um tipo concreto:

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

Mantive a testabilidade injetando UserDefaults na loja em vez de escondê-la atrás de um protocolo. A lição que estou levando: “envolva num protocolo para testar” é um bom padrão, mas se as propriedades do tipo alimentam uma interface ao vivo, prefira o @Observable concreto. Observação através de um tipo concreto é o caminho garantido.

Coisa dois: cancelar uma Task no deinit brigou com o compilador

A loja precisa de um listener de longa duração para transações que chegam fora de uma compra direta, aprovações de Pedir para Comprar, reembolsos, restaurações de outro dispositivo. Isso é uma Task iterando Transaction.updates, e eu queria cancelá-la no deinit. Código óbvio, rejeição instantânea:

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

A classe é @MainActor, mas deinit é nonisolated, ela pode rodar em qualquer thread, então não pode tocar numa propriedade isolada no main actor. Tentei as duas coisas óbvias em seguida. Um simples nonisolated var é rejeitado porque só funciona em let. Adicionar nonisolated(unsafe) compilou mas gerou um aviso, porque a macro @Observable ainda estava envolvendo a propriedade num armazenamento rastreado.

O que realmente funcionou foi dizer à macro para deixar a propriedade em paz, e marcá-la como unsafe-nonisolated:

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

O @ObservationIgnored é a metade que sustenta tudo. Sem ele, @Observable gera acessores isolados no main actor para a propriedade, e são esses acessores que reintroduzem o erro. O handle da task é encanamento interno, não estado de interface, então não tinha motivo para ser observado desde o início. É seguro porque o handle é escrito uma vez no init e lido uma vez no deinit, sem acesso concorrente, e Task.cancel() é thread-safe por si só.

Coisa três: um alerta de erro que silenciosamente não fazia nada

Essa eu só peguei porque um revisor cutucou o caminho de falha. Minha loja define uma string lastErrorMessage e a tela de Configurações a mostra como um alerta. Na maior parte do tempo funcionava. Num caminho, o retorno antecipado quando o produto ainda não tinha carregado, tocar no botão não fazia absolutamente nada. Sem alerta, sem feedback, um botão morto.

A causa é uma regra de observação do SwiftUI que eu tinha meio esquecido: body só reavalia quando uma propriedade @Observable que foi lida durante o body muda. Meu alerta lia o erro dentro do closure get do binding e no closure message, mas esses rodam depois, chamados pela maquinaria do alerta, não enquanto body está sendo construído. Então lastErrorMessage nunca foi registrado como uma dependência.

Os caminhos que “funcionavam” só funcionavam por sorte: eles também mudavam uma flag isProcessing que o body de fato lê para um spinner, o que forçava um redesenho, durante o qual o SwiftUI reverificava o alerta. O caminho de retorno antecipado definia o erro e não tocava em mais nada. Sem leitura, sem redesenho, sem alerta.

A correção é ler o valor durante o body, e o overload presenting: faz exatamente isso:

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

Agora qualquer mudança no erro reavalia o body e o alerta aparece, não importa qual caminho definiu ele. A regra geral que anotei: se um valor @Observable alimenta uma apresentação através de um closure mas não é lido em body, o SwiftUI não vai reagir a ele. Todo valor que deveria disparar uma renderização precisa ser lido enquanto o body roda.

O que eu diria a mim mesmo antes de começar

A API de compra foi a parte fácil. A parte difícil foram três lugares onde o modelo de isolamento do Swift 6 e o modelo de observação do SwiftUI se encontram, e nos três o compilador ou o runtime se comportou diferente do modelo mental com o qual eu cheguei. Tipos concretos para estado observado, @ObservationIgnored para encanamento que você cancela no deinit, e ler-no-body para qualquer coisa que deveria redesenhar. Nenhuma dessas coisas está na documentação do StoreKit, porque nenhuma delas é realmente sobre StoreKit.

Leitura relacionada

Você também pode achar útil

AdGuard

AdGuard para iOS

Bloqueio de anúncios e rastreadores em todo o sistema no iOS, sem necessidade de um servidor DNS separado.

Como afiliado da AdGuard, ganho com compras qualificadas.

Saiba mais
Airalo

eSIM Airalo

eSIM de dados local para viagens - sem necessidade de trocar um SIM físico.

Este é meu link de indicação da Airalo. Você recebe um desconto no seu primeiro eSIM e eu ganho crédito da Airalo para o meu.

Saiba mais
Proton

Proton VPN

VPN comercial com filtragem NetShield e interruptor de desligamento automático.

Como parceiro da Proton, ganho com compras qualificadas dos serviços de privacidade e segurança da Proton (Pass, Mail, VPN, Drive).

Saiba mais