Saltar al contenido
Development

Cuando un conjunto de filtro se convierte en silencio en una función

Por Victor Da Luz
iosswiftswiftdatacloudkitdev-logdeep-cut-atlas

Esta app se renombró después a Deep Cut Atlas. Aquí abajo se la llama “Discoverer” en todo momento, porque así se llamaba el día que pasó esto.

Discoverer tiene un botón de “Not Interested.” Al desplazarse por los lanzamientos nuevos, aparece un álbum que no interesa, se descarta, y no vuelve a aparecer. Detrás de ese botón hay poco más que el modelo de persistencia más pequeño que se pueda imaginar: una fila de SwiftData con una clave normalizada title+artist, el id del catálogo, y una marca de tiempo. Eso es todo. El feed de descubrimiento lee el conjunto de claves en cada actualización y las resta. El modelo nunca necesitó saber cómo se llamaba el álbum, solo que había sido descartado.

Después tomé el issue para dejar que la gente viera y administrara lo que había descartado. Una lista. Portada, título, artista, deslizar para restaurar. Y en el momento en que releí mi propio modelo, el vacío fue obvio: no tenía idea de cómo mostrar nada de eso.

El problema: descarté los datos a propósito

Esto es lo que guardaba cada descarte:

@Model
final class DismissedRelease {
    var releaseID: String = ""
    var albumKey: String = ""   // "fragments\u{1F}bonobo"
    var dismissedAt: Date = .now
}

albumKey está en minúsculas y recortado para que la coincidencia sea estable entre la biblioteca del usuario, el catálogo de Apple Music, y el conjunto de descartes, tres fuentes que no comparten ids. Excelente para hacer coincidir. Inútil para mostrar: no hay forma de convertir "fragments" de vuelta en "Fragments", y una lista de cadenas en minúsculas pegadas entre sí no es algo que quiera publicar.

Así que tenía dos opciones.

Volver a resolver los metadatos desde MusicKit usando el id de catálogo guardado. Suena limpio hasta que se enumera lo que cuesta: un viaje de ida y vuelta a la red por cada fila, nada que mostrar sin conexión, y, lo peor, MusicKit no corre en el simulador de ninguna forma, así que toda la pantalla sería imposible de probar sin tener un dispositivo físico a mano. Tampoco puede recuperar nada para las filas cuyo id ya no resuelve.

Guardar los datos de visualización en el momento del descarte. La llamada de descarte ya recibe el objeto álbum completo. Yo tenía el título, el artista, la URL de la portada, y los tiraba a la basura una línea después.

Al plantearlo así, en realidad no es una decisión. Los datos son gratis en el momento del descarte y caros en cualquier otro momento. Capturarlos.

La solución: ampliar el modelo, mantenerlo seguro para CloudKit

Agregué los campos de visualización al modelo. Discoverer sincroniza estas filas mediante CloudKit, que tiene una regla que muerde constantemente: cada propiedad guardada debe ser opcional o tener un valor por defecto, porque CloudKit no puede aplicar restricciones no opcionales. Así que todo obtiene un valor por defecto.

var title: String = ""
var artistName: String = ""
var artworkURLString: String?
var recordingTypeRaw: String = RecordingType.album.rawValue
var releaseDate: Date?

Ahora el flujo de descarte los captura, y una propiedad calculada reconstruye el tipo de álbum normal de la app para la lista (y para deshacer):

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
    // ...
}

Las dos cosas que muerden después de la parte fácil

Las filas viejas no tienen metadatos. Todo álbum descartado antes de este cambio tiene title y artistName vacíos. No voy a correr un backfill, no hay de dónde sacarlo sin el resolve por red que acabo de rechazar. En su lugar, la lista reconstruye una etiqueta degradada separando la clave de nuevo:

// "fragments\u{1F}bonobo" -> ("fragments", "bonobo")

En minúsculas y un poco feo, pero visible y restaurable, que es el punto. Los descartes nuevos se ven bien; los viejos se ven en minúsculas. Para una app de un solo usuario, ese es un buen lugar donde quedarse.

La parte de la que tuve que convencerme: ¿sigue funcionando deshacer para esas filas viejas? Restaurar elimina la fila de descarte cuya clave coincide con normalize(title, artist). Para una fila vieja, le paso las cadenas en minúsculas ya separadas. Pero la normalización pone en minúsculas y recorta, y hacerle eso a una cadena que ya está en minúsculas y ya recortada no cambia nada. Así que normalize(split(key)) == key exactamente. La etiqueta de visualización es con pérdida; la clave de coincidencia va y vuelve perfecta. Deshacer es seguro.

Aislamiento de actores de Swift 6, desde un ángulo que no esperaba. El proyecto se compila con el aislamiento de actores por defecto en MainActor, así que los tipos sin anotar, incluido el struct simple al que reconstruyo, obtienen un inicializador implícitamente aislado en el actor principal. Bien, salvo que la propiedad calculada que hace la reconstrucción vive en la clase @Model, y los miembros generados por la macro @Model no heredan ese valor por defecto. Son una pequeña isla no aislada en un proyecto que por lo demás es todo main-actor. Así que la compilación falló:

call to main actor-isolated initializer in a synchronous nonisolated context

La solución es una anotación: marcar la propiedad como @MainActor, lo cual de todas formas es cierto porque cada llamador ya corre ahí. Pero es un buen recordatorio de que en un proyecto con MainActor por defecto, el aislamiento viene del ajuste de compilación y de qué macros se excluyen de él, no de cómo se ve el código. Ya me había topado antes con la versión opuesta de esto (un helper “puro” que estaba secretamente aislado y hacía fallar una prueba en segundo plano); esta fue la misma causa raíz apuntando en la otra dirección.

Lo que me llevo para la próxima función

La lección real no es sobre SwiftData ni sobre actores. Es que “guardar lo mínimo” es el instinto correcto hasta el momento en que un modelo pasa de ser plomería interna a algo que una persona mira. Una clave de descarte era perfecta como filtro. En el instante en que tuvo que convertirse en una fila de una lista, necesitó cargar su propia descripción, y el momento más barato para adjuntar esa descripción ya había pasado una vez, cuando escribí por primera vez el flujo de descarte. Tuve una segunda oportunidad porque la llamada de descarte todavía tenía los datos. La próxima vez preguntaré antes: ¿esto se le va a mostrar a alguien alguna vez? Si tal vez, capturar lo suficiente para mostrarlo, incluso cuando la función de hoy solo necesite la clave.

Lecturas relacionadas