Actores Swift para Persistencia Segura para Hilos
Patrones para construir capas de persistencia de datos seguras para hilos usando actores Swift. Combina caché en memoria con almacenamiento respaldado por archivos, aprovechando el modelo de actores para eliminar condiciones de carrera en tiempo de compilación.
Cuándo Activar
- Construir una capa de persistencia de datos en Swift 5.5+
- Necesitar acceso seguro a estado mutable compartido
- Querer eliminar la sincronización manual (bloqueos, DispatchQueues)
- Construir aplicaciones offline-first con almacenamiento local
Patrón Principal
Repositorio Basado en Actores
El modelo de actores garantiza acceso serializado — sin condiciones de carrera, impuesto por el compilador.
public actor LocalRepository<T: Codable & Identifiable> where T.ID == String {
private var cache: [String: T] = [:]
private let fileURL: URL
public init(directory: URL = .documentsDirectory, filename: String = "data.json") {
self.fileURL = directory.appendingPathComponent(filename)
// Carga síncrona durante init (aislamiento del actor aún no activo)
self.cache = Self.loadSynchronously(from: fileURL)
}
// MARK: - API Pública
public func save(_ item: T) throws {
cache[item.id] = item
try persistToFile()
}
public func delete(_ id: String) throws {
cache[id] = nil
try persistToFile()
}
public func find(by id: String) -> T? {
cache[id]
}
public func loadAll() -> [T] {
Array(cache.values)
}
// MARK: - Privado
private func persistToFile() throws {
let data = try JSONEncoder().encode(Array(cache.values))
try data.write(to: fileURL, options: .atomic)
}
private static func loadSynchronously(from url: URL) -> [String: T] {
guard let data = try? Data(contentsOf: url),
let items = try? JSONDecoder().decode([T].self, from: data) else {
return [:]
}
return Dictionary(uniqueKeysWithValues: items.map { ($0.id, $0) })
}
}
Uso
Todas las llamadas son automáticamente async debido al aislamiento del actor:
let repository = LocalRepository<Question>()
// Lectura — búsqueda O(1) rápida desde el caché en memoria
let question = await repository.find(by: "q-001")
let allQuestions = await repository.loadAll()
// Escritura — actualiza el caché y persiste al archivo atómicamente
try await repository.save(newQuestion)
try await repository.delete("q-001")
Combinación con ViewModel @Observable
@Observable
final class QuestionListViewModel {
private(set) var questions: [Question] = []
private let repository: LocalRepository<Question>
init(repository: LocalRepository<Question> = LocalRepository()) {
self.repository = repository
}
func load() async {
questions = await repository.loadAll()
}
func add(_ question: Question) async throws {
try await repository.save(question)
questions = await repository.loadAll()
}
}
Decisiones de Diseño Clave
| Decisión | Razón |
|---|---|
| Actor (no clase + bloqueo) | Seguridad de hilos impuesta por el compilador, sin sincronización manual |
| Caché en memoria + persistencia en archivo | Lecturas rápidas desde caché, escrituras duraderas en disco |
| Carga síncrona en init | Evita complejidad de inicialización asíncrona |
| Diccionario indexado por ID | Búsquedas O(1) por identificador |
Genérico sobre Codable & Identifiable | Reutilizable con cualquier tipo de modelo |
Escrituras atómicas de archivo (.atomic) | Previene escrituras parciales en caso de fallo |
Mejores Prácticas
- Usa tipos
Sendablepara todos los datos que cruzan el límite del actor - Mantén la API pública del actor mínima — solo expón operaciones de dominio, no detalles de persistencia
- Usa escrituras
.atomicpara evitar corrupción de datos si la app falla durante una escritura - Carga sincrónicamente en
init— los inicializadores asíncronos añaden complejidad con beneficio mínimo para archivos locales - Combina con ViewModels
@Observablepara actualizaciones reactivas de la UI
Anti-patrones a Evitar
- Usar
DispatchQueueoNSLocken lugar de actores para código nuevo de concurrencia Swift - Exponer el diccionario interno de caché a llamadores externos
- Hacer la URL del archivo configurable sin validación
- Olvidar que todas las llamadas a métodos del actor son
await— los llamadores deben manejar el contexto async - Usar
nonisolatedpara evitar el aislamiento del actor (contradice el propósito)
Cuándo Usar
- Almacenamiento de datos local en aplicaciones iOS/macOS (datos de usuario, configuraciones, contenido en caché)
- Arquitecturas offline-first que sincronizan a un servidor después
- Cualquier estado mutable compartido que múltiples partes de la app accedan concurrentemente
- Reemplazar la seguridad de hilos basada en
DispatchQueueheredada con concurrencia Swift moderna