Atores Swift para Persistência Thread-Safe
Padrões para construir camadas de persistência de dados thread-safe usando atores Swift. Combina cache em memória com armazenamento em arquivo, aproveitando o modelo de ator para eliminar condições de corrida em tempo de compilação.
Quando Ativar
- Construindo uma camada de persistência de dados em Swift 5.5+
- Precisa de acesso thread-safe a estado mutável compartilhado
- Quer eliminar sincronização manual (locks, DispatchQueues)
- Construindo aplicativos offline-first com armazenamento local
Padrão Central
Repositório Baseado em Ator
O modelo de ator garante acesso serializado — sem condições de corrida, imposto pelo 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)
// Carregamento síncrono durante init (isolamento do ator ainda não ativo)
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 as chamadas são automaticamente assíncronas devido ao isolamento do ator:
let repository = LocalRepository<Question>()
// Leitura — busca O(1) rápida do cache em memória
let question = await repository.find(by: "q-001")
let allQuestions = await repository.loadAll()
// Escrita — atualiza o cache e persiste no arquivo atomicamente
try await repository.save(newQuestion)
try await repository.delete("q-001")
Combinando com 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()
}
}
Principais Decisões de Design
| Decisão | Justificativa |
|---|---|
| Ator (não class + lock) | Segurança de thread imposta pelo compilador, sem sincronização manual |
| Cache em memória + persistência em arquivo | Leituras rápidas do cache, gravações duráveis no disco |
| Carregamento síncrono no init | Evita complexidade de inicialização assíncrona |
| Dicionário indexado por ID | Buscas O(1) por identificador |
Genérico sobre Codable & Identifiable | Reutilizável em qualquer tipo de modelo |
Gravações atômicas (.atomic) | Previne gravações parciais em caso de falha |
Melhores Práticas
- Use tipos
Sendablepara todos os dados que cruzam limites do ator - Mantenha a API pública do ator mínima — exponha apenas operações de domínio, não detalhes de persistência
- Use gravações
.atomicpara evitar corrupção de dados se o aplicativo falhar no meio de uma gravação - Carregue sincronamente no
init— inicializadores assíncronos adicionam complexidade com benefício mínimo para arquivos locais - Combine com ViewModels
@Observablepara atualizações reativas da interface
Anti-Padrões a Evitar
- Usar
DispatchQueueouNSLockem vez de atores para novo código de concorrência Swift - Expor o dicionário de cache interno para chamadores externos
- Tornar a URL do arquivo configurável sem validação
- Esquecer que todas as chamadas de métodos do ator são
await— chamadores devem lidar com contexto assíncrono - Usar
nonisolatedpara contornar o isolamento do ator (derrota o propósito)
Quando Usar
- Armazenamento local de dados em aplicativos iOS/macOS (dados do usuário, configurações, conteúdo em cache)
- Arquiteturas offline-first que sincronizam com um servidor posteriormente
- Qualquer estado mutável compartilhado que múltiplas partes do aplicativo acessam concorrentemente
- Substituir segurança de thread baseada em
DispatchQueuelegada por concorrência Swift moderna