Actors Swift pour une Persistance Thread-Safe
Modèles pour construire des couches de persistance de données thread-safe en utilisant les actors Swift. Combine le cache en mémoire avec le stockage fichier, en exploitant le modèle d'actors pour éliminer les conditions de concurrence à la compilation.
Quand l'activer
- Construire une couche de persistance de données en Swift 5.5+
- Nécessité d'un accès thread-safe à un état mutable partagé
- Vouloir éliminer la synchronisation manuelle (locks, DispatchQueues)
- Construire des applications offline-first avec stockage local
Modèle Principal
Repository Basé sur un Actor
Le modèle d'actor garantit un accès sérialisé — pas de conditions de concurrence, imposé par le compilateur.
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)
// Chargement synchrone pendant l'init (isolation de l'actor pas encore active)
self.cache = Self.loadSynchronously(from: fileURL)
}
// MARK: - API Publique
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: - Privé
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) })
}
}
Utilisation
Tous les appels sont automatiquement asynchrones en raison de l'isolation de l'actor :
let repository = LocalRepository<Question>()
// Lecture — recherche O(1) rapide depuis le cache en mémoire
let question = await repository.find(by: "q-001")
let allQuestions = await repository.loadAll()
// Écriture — met à jour le cache et persiste sur le fichier de manière atomique
try await repository.save(newQuestion)
try await repository.delete("q-001")
Combinaison avec un 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()
}
}
Décisions de Conception Clés
| Décision | Justification |
|---|---|
| Actor (pas classe + verrou) | Sécurité des threads imposée par le compilateur, pas de synchronisation manuelle |
| Cache en mémoire + persistance fichier | Lectures rapides depuis le cache, écritures durables sur le disque |
| Chargement synchrone dans l'init | Évite la complexité de l'initialisation asynchrone |
| Dictionnaire indexé par ID | Recherches O(1) par identifiant |
Générique sur Codable & Identifiable | Réutilisable sur n'importe quel type de modèle |
Écritures atomiques (.atomic) | Empêche les écritures partielles en cas de crash |
Bonnes Pratiques
- Utiliser des types
Sendablepour toutes les données traversant les frontières des actors - Garder l'API publique de l'actor minimale — n'exposer que les opérations métier, pas les détails de persistance
- Utiliser les écritures
.atomicpour éviter la corruption des données si l'application plante pendant l'écriture - Charger de manière synchrone dans
init— les initialisateurs asynchrones ajoutent de la complexité avec peu d'avantages pour les fichiers locaux - Combiner avec les ViewModels
@Observablepour des mises à jour réactives de l'interface utilisateur
Anti-Patterns à Éviter
- Utiliser
DispatchQueueouNSLockà la place des actors pour du nouveau code de concurrence Swift - Exposer le dictionnaire de cache interne aux appelants externes
- Rendre l'URL du fichier configurable sans validation
- Oublier que tous les appels de méthodes d'un actor sont
await— les appelants doivent gérer le contexte asynchrone - Utiliser
nonisolatedpour contourner l'isolation de l'actor (cela va à l'encontre de son but)
Quand Utiliser
- Stockage de données local dans les applications iOS/macOS (données utilisateur, paramètres, contenu en cache)
- Architectures offline-first qui se synchronisent avec un serveur ultérieurement
- Tout état mutable partagé auquel plusieurs parties de l'application accèdent simultanément
- Remplacer la sécurité des threads basée sur
DispatchQueuehéritée par la concurrence moderne de Swift