← Volver al blog

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:

  1. Al iniciar, carga los datos cacheados de SwiftData para mostrar algo inmediatamente.
  2. Hace la llamada a red en paralelo.
  3. Cuando llega la respuesta, actualiza SwiftData y refresca la UI.
  4. 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.