Quando um conjunto de filtros silenciosamente vira uma funcionalidade
Esse app foi renomeado depois para Deep Cut Atlas. Abaixo ele é chamado de “Discoverer” o tempo todo, porque era assim que se chamava no dia em que isso aconteceu.
O Discoverer tem um botão “Não Tenho Interesse”. Você está rolando os lançamentos novos, vê um álbum que não te interessa, descarta ele, e ele nunca mais aparece. Por trás desse botão tem o modelo de persistência mais enxuto que dá pra imaginar: uma linha SwiftData com uma chave title+artist normalizada, o id do catálogo e um timestamp. Só isso. O feed de descoberta lê o conjunto de chaves a cada atualização e subtrai elas. O modelo nunca precisou saber como o álbum se chamava, só que tinha sido descartado.
Depois eu peguei a tarefa de deixar as pessoas verem e gerenciarem o que elas tinham descartado. Uma lista. Capa, título, artista, deslizar para restaurar. E no momento em que fui reler meu próprio modelo, a lacuna ficou óbvia: eu não tinha a menor ideia de como mostrar nada daquilo.
O problema: eu joguei os dados fora de propósito
Aqui está o que cada descarte armazenava:
@Model
final class DismissedRelease {
var releaseID: String = ""
var albumKey: String = "" // "fragments\u{1F}bonobo"
var dismissedAt: Date = .now
}
A albumKey é deixada em minúsculas e sem espaços nas pontas para que o match seja estável entre a biblioteca do usuário, o catálogo da Apple Music e o conjunto de descartes - três fontes que não compartilham ids. Ótimo para o match. Inútil para exibição: não tem como transformar "fragments" de volta em "Fragments", e uma lista de strings em minúsculas grudadas não é algo que eu quero lançar.
Então eu tinha duas opções.
Re-resolver os metadados a partir do MusicKit usando o id de catálogo armazenado. Isso parece limpo até você listar o custo: uma ida e volta de rede por linha, nada para mostrar offline e, o pior, o MusicKit simplesmente não roda no simulador, então a tela inteira ficaria impossível de testar sem um dispositivo físico em mãos. Também não recupera nada para linhas cujo id não resolve mais.
Guardar os dados de exibição no momento do descarte. A chamada de descarte já recebe o objeto completo do álbum. Eu já tinha o título, o artista, a URL da capa em mãos, e jogava tudo fora uma linha depois.
Quando você escreve assim, isso nem é realmente uma decisão. Os dados são de graça no momento do descarte e caros em qualquer outro momento. Capture-os.
A solução: expandir o modelo, mantendo compatibilidade com o CloudKit
Eu adicionei os campos de exibição ao modelo. O Discoverer sincroniza essas linhas pelo CloudKit, que tem uma regra que morde constantemente: toda propriedade armazenada precisa ser opcional ou ter um valor padrão, porque o CloudKit não consegue impor restrições non-optional. Então tudo ganha um valor padrão.
var title: String = ""
var artistName: String = ""
var artworkURLString: String?
var recordingTypeRaw: String = RecordingType.album.rawValue
var releaseDate: Date?
O caminho de descarte agora captura tudo isso, e uma computed property reconstrói o tipo de álbum normal do app para a lista (e para o undo):
convenience init(album: LibraryAlbum, dismissedAt: Date = .now) {
self.init(releaseID: album.id,
albumKey: AlbumKey.normalized(title: album.title, artist: album.artistName),
dismissedAt: dismissedAt)
title = album.title
artistName = album.artistName
artworkURLString = album.artworkURL?.absoluteString
// ...
}
As duas coisas que mordem depois da parte fácil
Linhas antigas não têm metadados. Todo álbum descartado antes dessa mudança tem title e artistName vazios. Eu não vou rodar um backfill - não tem de onde puxar esses dados sem o resolve via rede que acabei de rejeitar. Em vez disso, a lista reconstrói um rótulo degradado separando a chave de volta:
// "fragments\u{1F}bonobo" -> ("fragments", "bonobo")
Em minúsculas e um pouco feio, mas visível e restaurável, que é o que importa. Descartes novos aparecem certinhos; os antigos aparecem em minúsculas. Para um app de usuário único, isso é um lugar aceitável para pousar.
A parte que eu precisei me convencer: o undo ainda funciona para essas linhas legadas? Restaurar apaga a linha de descarte cuja chave bate com normalize(title, artist). Para uma linha legada, eu alimento essa função com as strings já separadas e em minúsculas. Mas a normalização deixa tudo em minúsculas e remove espaços nas pontas - e fazer isso com uma string que já está em minúsculas e já sem espaços não muda nada. Então normalize(split(key)) == key, exatamente. O rótulo de exibição tem perda; a chave de match vai e volta perfeita. O undo é seguro.
Isolamento de actor do Swift 6, de um ângulo que eu não esperava. O projeto compila com o isolamento de actor padrão definido como MainActor, então tipos sem anotação - incluindo a struct simples para a qual eu reconstruo - ganham um inicializador implicitamente isolado no main actor. Tudo bem, exceto que a computed property que faz a reconstrução vive na classe @Model, e os membros gerados pela macro @Model não herdam esse padrão. Eles são uma pequena ilha não isolada num projeto que, fora isso, é todo main-actor. Então o build quebrou:
call to main actor-isolated initializer in a synchronous nonisolated context
A correção é uma anotação: marcar a propriedade como @MainActor, o que já é verdade de qualquer forma, já que todo chamador já roda ali. Mas é um bom lembrete de que, num projeto default-MainActor, o isolamento vem da configuração de build e de quais macros optam por sair dele, não de como o código parece. Eu já tinha sido mordido pela versão oposta disso antes (um helper “puro” que estava secretamente isolado e quebrou um teste em background); essa foi a mesma causa raiz apontando para o outro lado.
O que eu levo para a próxima funcionalidade
A lição real não é sobre SwiftData ou actors. É que “guardar o mínimo” é o instinto certo até o momento em que um modelo deixa de ser encanamento interno e vira algo que uma pessoa olha. Uma chave de descarte era perfeita como filtro. No instante em que ela precisou virar uma linha numa lista, precisou também carregar sua própria descrição - e o momento mais barato para anexar essa descrição já tinha passado uma vez, lá quando eu escrevi o caminho de descarte pela primeira vez. Eu ganhei uma segunda chance porque a chamada de descarte ainda tinha os dados. Da próxima vez vou perguntar mais cedo: essa coisa algum dia vai ser mostrada para alguém? Se talvez, capture o suficiente para mostrar, mesmo quando a funcionalidade de hoje só precisa da chave.
Leitura relacionada
O teste do SwiftData que falhou sem nenhuma mensagem de erro
Cinco testes reportando um «Crash» genérico: um helper bem-intencionado deixou o ModelContainer ser desalocado enquanto o store manteve seu context. Um context é algo emprestado.
Uma seção de tracklist, e por que levou 30 minutos
Um método de protocolo, um enum de estado reaproveitado, uma convenção de roteamento que se manteve, e um orçamento de lint que forçou uma divisão que valia a pena fazer de qualquer forma.
A playlist que já tinha o nome certo
Um artefato de renomeação, uma API sem campo de autor pra atualizar, e um teste em dispositivo real provando que o bug já tinha se corrigido sozinho, fechado como aceitar como está.
Você também pode achar útil
Proton Mail
E-mail criptografado de ponta a ponta, com arquitetura de acesso zero.
Como parceiro da Proton, ganho com compras qualificadas dos serviços de privacidade e segurança da Proton (Pass, Mail, VPN, Drive).
Saiba maiseSIM 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 maisProton Drive
Armazenamento em nuvem criptografado, da equipe por trás do Proton Mail.
Como parceiro da Proton, ganho com compras qualificadas dos serviços de privacidade e segurança da Proton (Pass, Mail, VPN, Drive).
Saiba mais