cat swift/caching-leggero-persistenza-locale.md

Lezione 5450 min

Caching leggero e persistenza locale

Cache in memoria, salvataggio JSON e scadenza dei dati: persistere non significa introdurre subito un database.

Non ogni dato richiede un database. Spesso serve una cache leggera:

  • evitare richieste ripetute;
  • supportare offline parziale;
  • velocizzare avvio;
  • ridurre uso rete;
  • mantenere snapshot temporanei.

In Swift puoi partire da:

  • actor cache in memoria;
  • file JSON locale;
  • UserDefaults per preferenze piccole;
  • database solo quando serve.

Cache in memoria

actor ProductCache {
  private var storage: [String: Product] = [:]

  func get(_ id: String) -> Product? {
    storage[id]
  }

  func set(_ product: Product, for id: String) {
    storage[id] = product
  }
}

Actor per accesso concorrente sicuro.

Cache con scadenza

struct CacheEntry<Value: Sendable>: Sendable {
  let value: Value
  let insertedAt: Date
}

actor ExpiringCache<Value: Sendable> {
  private var storage: [String: CacheEntry<Value>] = [:]
  private let ttl: TimeInterval

  init(ttl: TimeInterval) {
    self.ttl = ttl
  }

  func get(_ key: String, now: Date = Date()) -> Value? {
    guard let entry = storage[key] else {
      return nil
    }

    guard now.timeIntervalSince(entry.insertedAt) < ttl else {
      storage[key] = nil
      return nil
    }

    return entry.value
  }

  func set(_ value: Value, for key: String, now: Date = Date()) {
    storage[key] = CacheEntry(value: value, insertedAt: now)
  }
}

Il tempo è iniettabile nei metodi per testare facilmente.

Persistenza su file

Per snapshot semplici:

struct ProductStore {
  let fileURL: URL
  let encoder: JSONEncoder
  let decoder: JSONDecoder

  func save(_ products: [Product]) throws {
    let data = try encoder.encode(products)
    try data.write(to: fileURL, options: [.atomic])
  }

  func load() throws -> [Product] {
    let data = try Data(contentsOf: fileURL)
    return try decoder.decode([Product].self, from: data)
  }
}

atomic riduce il rischio di file parziali.

Gestire file mancante

func loadOrEmpty() throws -> [Product] {
  guard FileManager.default.fileExists(atPath: fileURL.path) else {
    return []
  }

  return try load()
}

File mancante non è sempre errore.

UserDefaults

Usa UserDefaults per preferenze piccole:

  • tema;
  • flag onboarding;
  • ultimo filtro;
  • piccole impostazioni.

Non usarlo per cataloghi grandi o cache strutturate.

Cache policy

Devi decidere:

  • quando leggere cache;
  • quando invalidare;
  • quando aggiornare in background;
  • cosa fare se rete fallisce;
  • cosa mostrare se cache è vecchia.

Strategia semplice:

  1. se cache valida, restituisci cache;
  2. altrimenti chiama API;
  3. salva nuovo valore;
  4. restituisci nuovo valore.

Repository con cache

struct ProductRepository {
  let api: ProductAPI
  let cache: ExpiringCache<Product>

  func product(id: String) async throws -> Product {
    if let cached = await cache.get(id) {
      return cached
    }

    let product = try await api.product(id: id)
    await cache.set(product, for: id)
    return product
  }
}

Questo incapsula la politica.

Checklist

Prima di persistere:

  • è cache o fonte di verità?
  • quanto dura il dato?
  • cosa succede se è vecchio?
  • come invalidi?
  • cosa succede offline?
  • il formato su disco può cambiare?
  • il dato contiene informazioni sensibili?
  • serve davvero un database?

Esercizio

Crea:

  • ExpiringCache<Product>;
  • ProductStore su file JSON;
  • ProductRepository che usa API + cache.

Poi scrivi test manuali:

  • cache hit;
  • cache miss;
  • cache scaduta;
  • file mancante;
  • decoding fallito.