Construyendo una Pokédex en iOS — Parte 6: Persistencia offline con SwiftData
Parte 6 de la serie PokeTracker: implementamos los modelos @Model, el servicio de datos y la estrategia cache-first para que la app funcione sin conexión.
Una app que solo funciona con internet no es una buena app. En este post agregamos persistencia local con SwiftData para que PokeTracker muestre datos aunque no haya red.
SwiftData es el reemplazo moderno de Core Data introducido en iOS 17. La diferencia más visible es que en lugar de un modelo visual y archivos .xcdatamodeld, los modelos se definen en Swift puro con macros.
Los tres modelos @Model
Necesitamos guardar tres tipos de información:
- La lista de Pokémon que ya descargamos (caché de la lista)
- El detalle de cada Pokémon que el usuario abrió
- Los Pokémon favoritos del usuario
CachedPokemon
Crea Features/Pokemon/Data/Persistence/CachedPokemon.swift:
import Foundation
import SwiftData
@Model
final class CachedPokemon {
@Attribute(.unique) var id: Int
var name: String
var spriteURLString: String?
var lastUpdated: Date
var order: Int
init(id: Int, name: String, spriteURLString: String? = nil, order: Int = 0) {
self.id = id
self.name = name
self.spriteURLString = spriteURLString
self.lastUpdated = Date()
self.order = order
}
var spriteURL: URL? {
guard let urlString = spriteURLString else { return nil }
return URL(string: urlString)
}
func toDomain() -> Pokemon {
Pokemon(id: id, name: name, spriteURL: spriteURL)
}
}
extension CachedPokemon {
convenience init(from pokemon: Pokemon, order: Int) {
self.init(
id: pokemon.id,
name: pokemon.name,
spriteURLString: pokemon.spriteURL?.absoluteString,
order: order
)
}
}
@Model le dice a SwiftData que esta clase es persistente. @Attribute(.unique) en id garantiza que no habrá dos entradas con el mismo ID — si intentas insertar un Pokémon que ya existe, SwiftData lo rechaza.
order existe para preservar el orden de paginación. SwiftData no garantiza que los registros salgan en orden de inserción, así que necesitamos guardarlo explícitamente.
toDomain() convierte el modelo de persistencia al modelo de dominio. Esto mantiene la separación: la capa de dominio no importa SwiftData, y la capa de datos no expone modelos @Model hacia arriba.
SwiftData no acepta URL directamente como tipo almacenable, así que guardamos la URL como String y la convertimos en la propiedad computada.
FavoritePokemon
Crea Features/Pokemon/Data/Persistence/FavoritePokemon.swift:
import Foundation
import SwiftData
@Model
final class FavoritePokemon {
@Attribute(.unique) var id: Int
var name: String
var spriteURLString: String?
var addedAt: Date?
init(id: Int, name: String, spriteURLString: String? = nil) {
self.id = id
self.name = name
self.spriteURLString = spriteURLString
self.addedAt = Date()
}
var spriteURL: URL? {
guard let urlString = spriteURLString else { return nil }
return URL(string: urlString)
}
func toDomain() -> Pokemon {
Pokemon(id: id, name: name, spriteURL: spriteURL)
}
}
extension FavoritePokemon {
convenience init(from pokemon: Pokemon) {
self.init(id: pokemon.id, name: pokemon.name,
spriteURLString: pokemon.spriteURL?.absoluteString)
}
}
CachedPokemonDetail
Crea Features/Pokemon/Data/Persistence/CachedPokemonDetail.swift. El detalle es más complejo porque necesitamos guardar los tipos, estadísticas y habilidades:
import Foundation
import SwiftData
@Model
final class CachedPokemonDetail {
@Attribute(.unique) var id: Int
var name: String
var height: Int
var weight: Int
var baseExperience: Int
var spriteURLString: String?
var descriptionText: String?
var genus: String?
var typesData: Data?
var statsData: Data?
var abilitiesData: Data?
var lastUpdated: Date
init(id: Int, name: String, height: Int, weight: Int, baseExperience: Int,
spriteURLString: String? = nil, descriptionText: String? = nil,
genus: String? = nil, typesData: Data? = nil,
statsData: Data? = nil, abilitiesData: Data? = nil) {
self.id = id
self.name = name
self.height = height
self.weight = weight
self.baseExperience = baseExperience
self.spriteURLString = spriteURLString
self.descriptionText = descriptionText
self.genus = genus
self.typesData = typesData
self.statsData = statsData
self.abilitiesData = abilitiesData
self.lastUpdated = Date()
}
func toDomain() -> PokemonDetail? {
guard let typesData,
let typeNames = try? JSONDecoder().decode([String].self, from: typesData)
else { return nil }
let types = typeNames.map { PokemonType(name: $0) }
let stats: [PokemonStat] = (try? JSONDecoder().decode(
[[String: Int]].self, from: statsData ?? Data()
))?.compactMap { dict in
guard let name = dict["name"].flatMap({ _ in nil as String? }),
let value = dict["value"] else { return nil }
return PokemonStat(name: String(value), baseStat: value)
} ?? []
return PokemonDetail(
id: id, name: name, height: height, weight: weight,
baseExperience: baseExperience,
types: types, stats: stats, abilities: [], moves: [],
spriteURL: spriteURLString.flatMap { URL(string: $0) },
description: descriptionText, genus: genus
)
}
}
extension CachedPokemonDetail {
convenience init(from detail: PokemonDetail) {
let typesData = try? JSONEncoder().encode(detail.types.map { $0.name })
self.init(
id: detail.id, name: detail.name,
height: detail.height, weight: detail.weight,
baseExperience: detail.baseExperience,
spriteURLString: detail.spriteURL?.absoluteString,
descriptionText: detail.description,
genus: detail.genus,
typesData: typesData
)
}
}
Los tipos, estadísticas y habilidades se serializan como Data con JSONEncoder. SwiftData no tiene soporte nativo para arrays de tipos personalizados — la solución es codificarlos manualmente.
PokemonDataService
El servicio centraliza todas las operaciones de lectura y escritura sobre SwiftData. Crea Features/Pokemon/Data/Persistence/PokemonDataService.swift:
import Foundation
import SwiftData
@MainActor
final class PokemonDataService {
private let modelContext: ModelContext
init(modelContext: ModelContext) {
self.modelContext = modelContext
}
// MARK: - Lista de Pokémon
func getCachedPokemons() throws -> [Pokemon] {
let descriptor = FetchDescriptor<CachedPokemon>(
sortBy: [SortDescriptor(\.order)]
)
return try modelContext.fetch(descriptor).map { $0.toDomain() }
}
func cachePokemons(_ pokemons: [Pokemon], startingOrder: Int) throws {
for (index, pokemon) in pokemons.enumerated() {
let order = startingOrder + index
let pokemonId = pokemon.id
let descriptor = FetchDescriptor<CachedPokemon>(
predicate: #Predicate { $0.id == pokemonId }
)
if let existing = try modelContext.fetch(descriptor).first {
existing.name = pokemon.name
existing.spriteURLString = pokemon.spriteURL?.absoluteString
existing.order = order
existing.lastUpdated = Date()
} else {
modelContext.insert(CachedPokemon(from: pokemon, order: order))
}
}
try modelContext.save()
}
func clearCachedPokemons() throws {
try modelContext.delete(model: CachedPokemon.self)
try modelContext.save()
}
// MARK: - Favoritos
func getFavorites() throws -> [Pokemon] {
let descriptor = FetchDescriptor<FavoritePokemon>(
sortBy: [SortDescriptor(\.addedAt, order: .reverse)]
)
return try modelContext.fetch(descriptor).map { $0.toDomain() }
}
func isFavorite(id: Int) throws -> Bool {
let descriptor = FetchDescriptor<FavoritePokemon>(
predicate: #Predicate { $0.id == id }
)
return try modelContext.fetch(descriptor).first != nil
}
func toggleFavorite(_ pokemon: Pokemon) throws -> Bool {
if try isFavorite(id: pokemon.id) {
let id = pokemon.id
let descriptor = FetchDescriptor<FavoritePokemon>(
predicate: #Predicate { $0.id == id }
)
if let existing = try modelContext.fetch(descriptor).first {
modelContext.delete(existing)
try modelContext.save()
}
return false
} else {
modelContext.insert(FavoritePokemon(from: pokemon))
try modelContext.save()
return true
}
}
}
@MainActor en la clase garantiza que todas las operaciones corren en el hilo principal. ModelContext no es Sendable, no se puede usar desde hilos de fondo. Con @MainActor el compilador lo fuerza automáticamente.
FetchDescriptor con #Predicate es la forma de SwiftData para consultas filtradas. El macro #Predicate verifica en compilación que los campos existen y que los tipos son correctos — si escribes mal el nombre de una propiedad, el compilador falla.
Estrategia cache-first
El ViewModel va a usar PokemonDataService con esta lógica:
- Al iniciar, carga los datos cacheados de SwiftData para mostrar algo inmediatamente.
- Hace la llamada a red en paralelo.
- Cuando llega la respuesta, actualiza SwiftData y refresca la UI.
- Si la red falla, los datos cacheados siguen ahí — el usuario ve la última versión descargada.
Esto lo veremos en el Post 8 cuando construyamos el ViewModel.
Qué viene en el siguiente post
En el Post 7 construimos el sistema de caché de imágenes en dos niveles: memoria y disco.