За годы работы разработчиком iOS, я собрал множество инструментов и полезных штук, которые облегчают процесс разработки. В этой статье, я хочу поделиться одним из таких инструментов. Это будет не большая статья. Я покажу, как пользоваться этой утилитой, продемонстрирую её в действии. Надеюсь, что статья окажется полезной для вас.

SwiftData отлично функционирует внутри View: достаточно добавить декоратор @Query к свойству, и все будет работать 'из коробки'. Однако, когда возникает желание вынести работу со SwiftData в отдельный модуль, начинают появляться сложности, особенно касаемо выполнения операций в фоновом режиме.

Как можно делать запрос без @Query:

func getMyModels() -> [MyModel] {
    let context = ModelContext(modelContainer)
    let result = try context.fetch(FetchDescriptor<MyModel>())
    return result
}

Данный код не потокобезопасен. При обращении из разных потоков к контейнеру, в лучшем случае, будет краш, в худшем, операция выполниться, но с непредсказуемым результатом.

Самым очевидным, кажется, это работать через мьютекс (NSLock, UnfairLock, DispatchSemaphore или другие)

func getMyModels() -> [MyModel] {
    // в этом примере реализация мьютекса не важна
    mutex {
      let context = ModelContext(modelContainer)
      let result = try context.fetch(FetchDescriptor<MyModel>())
      return result
    }
}

При такой реализации я переодически сталкивался с крашами в приложении. В этом случае потокобезопасность не достигалась.

Ситуацию исправляет DefaultSerialModelExecutor. Он гарантирует потокобезопасности. Для удобства я сделал Дженерик актор BackgroundSerialPersistenceActor<T: PersistentModel>

import Foundation
import SwiftData

/// ```swift
///  // It is important that this actor works as a mutex, 
///  // so you must have one instance of the Actor for one container 
//   // for it to work correctly.
///  let actor = BackgroundSerialPersistenceActor(container: modelContainer)
///
///  Task {
///      let data: [MyModel] = try? await actor.fetchData()
///  }
///  ```
@available(iOS 17, *)
public actor BackgroundSerialPersistenceActor: ModelActor {

    public let modelContainer: ModelContainer
    public let modelExecutor: any ModelExecutor
    private var context: ModelContext { modelExecutor.modelContext }

    public init(container: ModelContainer) {
        self.modelContainer = container
        let context = ModelContext(modelContainer)
        modelExecutor = DefaultSerialModelExecutor(modelContext: context)
    }

    public func fetchData<T: PersistentModel>(
        predicate: Predicate<T>? = nil,
        sortBy: [SortDescriptor<T>] = []
    ) throws -> [T] {
        let fetchDescriptor = FetchDescriptor<T>(predicate: predicate, sortBy: sortBy)
        let list: [T] = try context.fetch(fetchDescriptor)
        return list
    }

    public func fetchCount<T: PersistentModel>(
        predicate: Predicate<T>? = nil,
        sortBy: [SortDescriptor<T>] = []
    ) throws -> Int {
        let fetchDescriptor = FetchDescriptor<T>(predicate: predicate, sortBy: sortBy)
        let count = try context.fetchCount(fetchDescriptor)
        return count
    }

    public func insert<T: PersistentModel>(data: T) {
        let context = data.modelContext ?? context
        context.insert(data)
    }

    public func save() throws {
        try context.save()
    }

    public func remove<T: PersistentModel>(predicate: Predicate<T>? = nil) throws {
        try context.delete(model: T.self, where: predicate)
    }

    public func saveAndInsertIfNeeded<T: PersistentModel>(
        data: T, 
        predicate: Predicate<T>
    ) throws {
        let descriptor = FetchDescriptor<T>(predicate: predicate)
        let context = data.modelContext ?? context
        let savedCount = try context.fetchCount(descriptor)

        if savedCount == 0 {
            context.insert(data)
        }
        try context.save()
    }
}

Актор BackgroundSerialPersistenceActor<T: PersistentModel> представляет собой решение для работы с данными в фоновом режиме, обеспечивая последовательную и безопасную работу с данными.

Актор инкапсулирует в себе контейнер модели (ModelContainer) и исполнителя модели (ModelExecutor), обеспечивая изолированное пространство для работы с данными модели.

Пример использования:

let actor = BackgroundSerialPersistenceActor(container: modelContainer)

Task {
    let data: [MyModel] = try? await actor.fetchData()
}

Инициализация

Для начала работы с актором необходимо создать его экземпляр, передав в конструктор контейнер модели.

Важно, этот актор работает как мьютекс, по этому необходимо иметь один экземпляр Актора для одного контейнера для корректной работы.

Заключение

BackgroundSerialPersistenceActor ообеспечивает безопасность, гибкость и удобство в управлении данными. Однако важно помнить, что после операции fetch, когда модели (PersistentModel) передаются в другие функции и потоки, они сохраняют в себе контекст. Изменение поля у одной модели в разных потоках может привести к крашу приложения. Поэтому безопаснее всего после fetch мапить результат в другую структуру.

SwiftData — удобный инструмент, но все еще нужно знать, как правильно его готовить

Еще статьи Swift Utilities: