Всем привет! Данный пост рассчитан на людей, которые имеют представление о Core Data. Если вы не один из них, прочитайте краткую информацию тут и скорее присоединяйтесь. Прежде всего, мне хотелось бы поделиться своим взглядом на некоторые подходы к организации работы с данными в IOS приложениях, и в этом вопросе мне пришелся по душе паттерн репозиторий (далее - репозиторий). Поехали!
Немного про репозиторий
Основная идея репозитория заключается в том, чтобы абстрагироваться от источников данных, скрыть детали реализации, ведь, c точки зрения доступа к данным, эта самая реализация - не играет роли. Предоставить доступ к основным операциям над данными: сохранить, загрузить или удалить - главная задача репозитория.
Таким образом, к его преимуществам можно отнести:
отсутствие зависимостей от реализации репозитория. Под капотом может быть все, что угодно: коллекция в оперативной памяти, UserDefaults, KeyChain, Core Data, Realm, URLCache, отдельный файл в tmp и т.п.;
разделение зон ответственности. Репозиторий выступает прослойкой между бизнес-логикой и способом хранения данных, отделяя одно от другого;
формирование единого, более структурированного подхода в работе с данными.
В конечном итоге, все это благоприятно сказывается на скорости разработки, возможностях масштабирования и тестируемости проектов.
Ближе к деталям
Рассмотрим самый неблагоприятный сценарий использования Core Data.
Для общего понимания будем использовать класс DataProvider (далее - DP) - его задача получать данные откуда - либо (сеть, UI) и класть их в Repository . Также, при необходимости, DP может достать данные из репозитория, выступая для него фасадом. Под данными будем подразумевать массив доменных объектов. Именно ими оперирует репозиторий.
1. Core Data как один большой репозиторий с NSManagamentObject
![Рисунок 1 Рисунок 1](https://habrastorage.org/getpro/habr/upload_files/147/bff/39d/147bff39d154c46050fb130410fa3d7b.png)
Идея оперировать NSManagedObject в качестве доменного объекта самая простая, но не самая удачная. При таком подходе перед нами встают сразу несколько проблем:
Сore Data разрастается по всему проекту, вешая ненужные зависимости и нарушая зоны ответственности. Раскрываются детали реализации. Знать на чем завязана реализация репозитория - должен только репозиторий;
Используя единый репозиторий для всех Data Provider, он будет разрастаться с появлением новых доменных объектов;
В худшем случае, логика работы с объектами начнет пересекаться между собой и это может превратиться в один большой непредсказуемый magic.
2. Core Data + DB Client
Первое, что приходит на ум, для решения проблем из предыдущего примера, это вынести логику работы с объектами в отдельный класс (назовем его DB Client), тогда наш Repository будет только сохранять и доставать объекты из хранилища, в то время, как вся логика по работе с объектами ляжет в DB Client. На выходе должно получиться что-то такое:
![Рисунок 2 Рисунок 2](https://habrastorage.org/getpro/habr/upload_files/789/aa1/516/789aa15163aed422a06a6988e7c54000.png)
Обе схемы решают проблему №1. (Core Data ограничивается DB Client и Repository), и частично могут решить проблему №2 и №3 на небольших проектах, но не исключают их полностью. Продолжая мысль дальше, возможно придти к следующей схеме:
![Рисунок 3 Рисунок 3](https://habrastorage.org/getpro/habr/upload_files/952/218/c1e/952218c1eae762a320fddf714bd76fe4.png)
Core Data можно ограничить только репозиторием. DB Client конвертирует доменные объекты в NSManagedObject и обратно;
Repository больше не единый и он не разрастается;
Логика работы с данными более структурирована и консолидирована
Здесь, стоит отметить, что вариантов композиции и декомпозиции классов, а также способов организации взаимодействия между ними - множество. Тем не менее, выше-описанная схема показывает еще одну, на мой взгляд, важную проблему: на каждый новый доменный объект, в лучшем случае, требуется заводить X2 объектов (DB Client и Repository). Поэтому, предлагаю рассмотреть еще один способ реализации. Для удобства, проект лежит тут.
Подготовка к реализации
В первую очередь, таким я вижу репозиторий:
protocol AccessableRepository {
//1
associatedtype DomainModel
//2
var actualSearchedData: Observable<[DomainModel]>? {get}
//3
func save(_ objects: [DomainModel], completion: @escaping ((Result<Void>) -> Void))
//4
func save(_ objects: [DomainModel], clearBeforeSaving: RepositorySearchRequest, completion: @escaping ((Result<Void>) -> Void))
//5
func present(by request: RepositorySearchRequest, completion: @escaping ((Result<[DomainModel]>) -> Void))
//6
func delete(by request: RepositorySearchRequest, completion: @escaping ((Result<Void>) -> Void))
//7
func eraseAllData(completion: @escaping ((Result<Void>) -> Void))
}
Доменный объект, которым оперирует репозиторий;
Возможность подписаться на отслеживание изменений в репозитории;
Сохранение объектов в репозиторий;
Сохранение объектов с возможностью очистки старых данных в рамках одного контекста;
Загрузка данных из репозитория;
Удаление объектов из репозитория;
Удаление всех данных из репозитория.
Возможно, ваш набор требований к репозиторияю будет отличаться, но концептуально ситуацию это не изменит.
К сожалению, возможность работать с репозиторием через AccessableRepository отсутствует, о чем свидетельствует ошибка на рисунке 4:
![Рисунок 4 Рисунок 4](https://habrastorage.org/getpro/habr/upload_files/af8/17f/8bf/af817f8bfc254153a186e5da53564341.png)
В таком случае, хорошо подходит Generic-реализация репозитория, которая выглядит следующим образом:
class Repository<DomainModel>: NSObject, AccessableRepository {
typealias DomainModel = DomainModel
var actualSearchedData: Observable<[DomainModel]>? {
fatalError("actualSearchedData must be overrided")
}
func save(_ objects: [DomainModel], completion: @escaping ((Result<Void>) -> Void)) {
fatalError("save(_ objects: must be overrided")
}
func save(_ objects: [DomainModel], clearBeforeSaving: RepositorySearchRequest, completion: @escaping ((Result<Void>) -> Void)) {
fatalError("save(_ objects vs clearBeforeSaving: must be overrided")
}
func present(by request: RepositorySearchRequest, completion: @escaping ((Result<[DomainModel]>) -> Void)) {
fatalError("present(by request: must be overrided")
}
func delete(by request: RepositorySearchRequest, completion: @escaping ((Result<Void>) -> Void)) {
fatalError("delete(by request: must be overrided")
}
func eraseAllData(completion: @escaping ((Result<Void>) -> Void)) {
fatalError("eraseAllData(completion: must be overrided")
}
}
NSObject нужен для взаимодействия с NSFetchResultController;
AccessableRepository - для наглядности, прозрачности и порядка;
FatalError играет роль предохранителя, чтобы всяк сюда входящий не использовал то, что не реализовано;
Данное решение позволяет не привязываться к конкретной реализации, а также обойти предыдущую проблему:
![Рисунок 5 Рисунок 5](https://habrastorage.org/getpro/habr/upload_files/f72/f21/a3e/f72f21a3e4ff4c133d7af97ef08f9b82.png)
Для работы с выборкой объектов потребуются объект с двумя свойствами:
protocol RepositorySearchRequest {
/* NSPredicate = nil, apply for all records
for deletion sortDescriptor is not Used
*/
//1
var predicate: NSPredicate? {get}
//2
var sortDescriptors: [NSSortDescriptor] {get}
}
Условие для выборки, если условие отсуствует - выборка применяется ко всему набору данных;
Условие для сортировки - может как отсуствтвоать так и присутствовать.
Поскольку, в дальнейшем может возникнуть необходимость использовать отдельный NSPersistentContainer (например для тестирования), который, в свою очередь, выступает источником контекста - нужно закрыть его протоколом:
protocol DBContextProviding {
//1
func mainQueueContext() -> NSManagedObjectContext
//2
func performBackgroundTask(_ block: @escaping (NSManagedObjectContext) -> Void)
}
Контекст, с которым работает main Queue, необходим для использования NSFetchedResultsController;
Требуется для выполнения операций с данными в фоновом потоке. Можно заменить на newBackgroundContext(). Про различия в работе этих двух методов можно прочитать тут.
Также, потребуются объекты, которые будут осуществлять конвертацию (мапинг) доменных моделей в объекты репозитория (NSManagedObject) и обратно:
class DBEntityMapper<DomainModel, Entity> {
//1
func convert(_ entity: Entity) -> DomainModel? {
fatalError("convert(_ entity: Entity: must be overrided")
}
//2
func update(_ entity: Entity, by model: DomainModel) {
fatalError("supdate(_ entity: Entity: must be overrided")
}
//3
func entityAccessorKey(_ entity: Entity) -> String {
fatalError("entityAccessorKey must be overrided")
}
//4
func entityAccessorKey(_ object: DomainModel) -> String {
fatalError("entityAccessorKey must be overrided")
}
}
Позволяет конвертировать NSManagedObject в доменную модель;
Позволяет обновить NSManagedObject с помощью доменной модели;
4. - используется для связи между собой NSManagedObject и доменным объектом.
Когда-то, я использовал доменный объект, в качестве инициализатора NSManagedObject. С одной стороны, это было удобно, с другой стороны накладывало ряд ограничений. Например, когда использовались связи между объектами и один NSManagedObject создавал несколько других NSManagedObject. Такой подход размывал зоны ответственности и негативно сказывался на общей логике работы с данными.
Во время работы с репозиторием потребуется обрабатвать ошибки, для этого достаточно enum:
enum DBRepositoryErrors: Error {
case entityTypeError
case noChangesInRepository
}
Здесь абстрактная часть репозитория подходит к концу, осталось самои интересное - реализация.
Реализация
Для данного примера, подойдет простая реализация DBContextProvider (без каких-либо дополнительных параметров):
final class DBContextProvider {
//1
private lazy var persistentContainer: NSPersistentContainer = {
let container = NSPersistentContainer(name: "DataStorageModel")
container.loadPersistentStores(completionHandler: { (_, error) in
if let error = error as NSError? {
fatalError("Unresolved error \(error),\(error.userInfo)")
}
container.viewContext.automaticallyMergesChangesFromParent = true
})
return container
}()
//2
private lazy var mainContext = persistentContainer.viewContext
}
//3
//MARK:- DBContextProviding implementation
extension DBContextProvider: DBContextProviding {
func performBackgroundTask(_ block: @escaping (NSManagedObjectContext) -> Void) {
persistentContainer.performBackgroundTask(block)
}
func mainQueueContext() -> NSManagedObjectContext {
self.mainContext
}
}
Инициализация NSPersistentContainer;
Раньше такой подход избавлял от утечек памяти;
Реализация DBContextProviding.
Основная составляющая репозитория выглядит следующим образом:
final class DBRepository<DomainModel, DBEntity>: Repository<DomainModel>, NSFetchedResultsControllerDelegate {
//1
private let associatedEntityName: String
//2
private let contextSource: DBContextProviding
//3
private var fetchedResultsController: NSFetchedResultsController<NSFetchRequestResult>?
//4
private var searchedData: Observable<[DomainModel]>?
//5
private let entityMapper: DBEntityMapper<DomainModel, DBEntity>
//6
init(contextSource: DBContextProviding, autoUpdateSearchRequest: RepositorySearchRequest?, entityMapper: DBEntityMapper<DomainModel, DBEntity>) {
self.contextSource = contextSource
self.associatedEntityName = String(describing: DBEntity.self)
self.entityMapper = entityMapper
super.init()
//7
guard let request = autoUpdateSearchRequest else { return }
self.searchedData = .init(value: [])
//self.fetchedResultsController = configureactualSearchedDataUpdating(request)
}
}
Cвойство, которое будет использовать при работе с NSFetchRequest;
DBContextProviding - для доступа к контексту, требуется для выполнения операций сохранения, загрузки, удаления;
fetchedResultsController - необходим, когда требуется отслеживать изменения в NSPersistentStore (проще говоря, изменение объектов в базе);
searchedData - зависит от fetchedResultsController и служит оберткой для fetchedResultsController, скрывая детали реализации и уведомляя подписчиков об изменениях в данных;
entityMapper - конвертирует доменные объекты в NSManagedObject и обратно;
Иницализатор;
В случае если autoUpdateSearchReques != nil, выполняем конфигурацию fetchedResultsController для отслеживания изменениы в базе ;
Чтобы не порождать однотипный код для работы с контекстом, потребуется вспомогательный метод:
private func applyChanges(context: NSManagedObjectContext, mergePolicy: Any = NSMergeByPropertyObjectTrumpMergePolicy, completion: ((Result<Void>) -> Void)? = nil) {
//1
context.mergePolicy = mergePolicy
switch context.hasChanges {
case true:
do {
//2
try context.save()
} catch {
ConsoleLog.logEvent(object: "DBRepository \(DBEntity.self)", method: "saveIn", "Error: \(error)")
completion?(Result.error(error))
}
ConsoleLog.logEvent(object: "DBRepository \(DBEntity.self)", method: "saveIn", "Saving Complete")
completion?(Result(value: ()))
case false:
//3
ConsoleLog.logEvent(object: "DBRepository \(DBEntity.self)", method: "saveIn", "No changes in context")
completion?(Result(error: DBRepositoryErrors.noChangesInRepository))
}
}
mergePolicy - отвечает за то, как решаются конфликты при работе с контекстом. В данном случае, по умолчанию используется политика, которая отдает приоритет изменённым объектам, находящимся в памяти, а не в persistent store;
Сохранение изменений в persistent store;
Если изменения объектов отсутствуют, в completion-блок передается соответствующая ошибка.
Сохранение объектов реализовано следующим образом:
private func saveIn(data: [DomainModel], clearBeforeSaving: RepositorySearchRequest?, completion: @escaping ((Result<Void>) -> Void)) {
contextSource.performBackgroundTask() { context in
//1
if let clearBeforeSaving = clearBeforeSaving {
let clearFetchRequest = NSFetchRequest<NSManagedObject>(entityName: self.associatedEntityName)
clearFetchRequest.predicate = clearBeforeSaving.predicate
clearFetchRequest.includesPropertyValues = false
(try? context.fetch(clearFetchRequest))?.forEach({ context.delete($0) })
}
//2
var existingObjects: [String: DBEntity] = [:]
let fetchRequest = NSFetchRequest<NSManagedObject>(entityName: self.associatedEntityName)
(try? context.fetch(fetchRequest) as? [DBEntity])?.forEach({
let accessor = self.entityMapper.entityAccessorKey($0)
existingObjects[accessor] = $0
})
data.forEach({
let accessor = self.entityMapper.entityAccessorKey($0)
//3
let entityForUpdate: DBEntity? = existingObjects[accessor] ?? NSEntityDescription.insertNewObject(forEntityName: self.associatedEntityName, into: context) as? DBEntity
//4
guard let entity = entityForUpdate else { return }
self.entityMapper.update(entity, by: $0)
})
//5
self.applyChanges(context: context, completion: completion)
}
}
Используется при необходимости удалить объекты, перед сохранением новых (в рамках текущего контекста);
Выполняется выгрузка объектов, которые существуют в репозитории, для их дальнейшего изменения;
Если объект с нужным entityAccessorKey отсутствует, создается новый экземпляр NSManagedObject;
Выполнение мапинга свойств из доменного объекта в NSManagedObject;
Применение выполненных изменений.
Важно: Возможно вас смутил п.2., данное решение оптимально на небольших наборах данных. Я выполнял замеры (ExampleCase3 в демо-проекте) на 10 000 записей, iPhone 6s Plus IOS 12.4.1 и получил следующие результаты:
время записи/перезаписи данных от 0,9 до 1.8 сек, cкачок потребления оперативной памяти в пике до 33 мб;
если убрать код в п2 и оставить только вставку новых объектов, то время записи/перезаписи данных +- 20 сек, cкачок потребления оперативной памяти в пике до 50 мб.
Для больших наборов данных я бы рекомендовал разделять их на части, использовать batchUpdate и batchDelete, а начиная с IOS 13 появился batchInsert.
Таким образом, реализация методов save cводится к вызову метода saveIn:
override func save(_ objects: [DomainModel], completion: @escaping ((Result<Void>) -> Void)) {
saveIn(data: objects, clearBeforeSaving: nil, completion: completion)
}
override func save(_ objects: [DomainModel], clearBeforeSaving: RepositorySearchRequest, completion: @escaping ((Result<Void>) -> Void)) {
saveIn(data: objects, clearBeforeSaving: clearBeforeSaving, completion: completion)
}
Методы present, delete, eraseAllData завязаны на работе с NSFetchRequest. В их реализации нет ничего особенного, поэтому не вижу смысла заострять на них внимание:
override func present(by request: RepositorySearchRequest, completion: @escaping ((Result<[DomainModel]>) -> Void)) {
//1
let fetchRequest = NSFetchRequest<NSManagedObject>(entityName: associatedEntityName)
fetchRequest.predicate = request.predicate
fetchRequest.sortDescriptors = request.sortDescriptors
contextSource.performBackgroundTask() { context in
do {
//2
let rawData = try context.fetch(fetchRequest)
guard rawData.isEmpty == false else {return completion(Result(value: [])) }
guard let results = rawData as? [DBEntity] else {
completion(Result(value: []))
assertionFailure(DBRepositoryErrors.entityTypeError.localizedDescription)
return
}
//3
let converted = results.compactMap({ return self.entityMapper.convert($0) })
completion(Result(value: converted))
} catch {
completion(Result(error: error))
}
}
}
override func delete(by request: RepositorySearchRequest, completion: @escaping ((Result<Void>) -> Void)) {
//1
let fetchRequest = NSFetchRequest<NSManagedObject>(entityName: associatedEntityName)
fetchRequest.predicate = request.predicate
fetchRequest.includesPropertyValues = false
contextSource.performBackgroundTask() { context in
//2
let results = try? context.fetch(fetchRequest)
results?.forEach({ context.delete($0) })
//3
self.applyChanges(context: context, completion: completion)
}
}
override func eraseAllData(completion: @escaping ((Result<Void>) -> Void)) {
//1
let fetchRequest = NSFetchRequest<NSFetchRequestResult>(entityName: associatedEntityName)
let batchDeleteRequest = NSBatchDeleteRequest(fetchRequest: fetchRequest)
batchDeleteRequest.resultType = .resultTypeObjectIDs
contextSource.performBackgroundTask({ context in
do {
//2
let result = try context.execute(batchDeleteRequest)
guard let deleteResult = result as? NSBatchDeleteResult,
let ids = deleteResult.result as? [NSManagedObjectID]
else {
completion(Result.error(DBRepositoryErrors.noChangesInRepository))
return
}
let changes = [NSDeletedObjectsKey: ids]
NSManagedObjectContext.mergeChanges(
fromRemoteContextSave: changes,
into: [self.contextSource.mainQueueContext()]
)
//3
completion(Result(value: ()))
return
} catch {
ConsoleLog.logEvent(object: "DBRepository \(DBEntity.self)", method: "eraseAllData", "Error: \(error)")
completion(Result.error(error))
}
})
Создание запроса;
Выборка объектов и их обработка;
Вовзрат результата операции.
Для реализации возможности отслеживания изменений данных в реальном времени, потребуется FetchedResultsController. Для его конфигурации используется следующий метод:
private func configureactualSearchedDataUpdating(_ request: RepositorySearchRequest) -> NSFetchedResultsController<NSFetchRequestResult> {
//1
let fetchRequest: NSFetchRequest<NSFetchRequestResult> = NSFetchRequest(entityName: associatedEntityName)
fetchRequest.predicate = request.predicate
fetchRequest.sortDescriptors = request.sortDescriptors
//2
let fetchedResultsController = NSFetchedResultsController(fetchRequest: fetchRequest,
managedObjectContext: contextSource.mainQueueContext(), sectionNameKeyPath: nil,
cacheName: nil)
//3
fetchedResultsController.delegate = self
try? fetchedResultsController.performFetch()
if let content = fetchedResultsController.fetchedObjects as? [DBEntity] {
updateObservableContent(content)
}
return fetchedResultsController
}
func updateObservableContent(_ content: [DBEntity]) {
let converted = content.compactMap({ return self.entityMapper.convert($0) })
//4
searchedData?.value = converted
}
Формирование запроса, на основании которого будут отслеживаться изменения;
Создание экземпляра класса NSFetchedResultsController;
performFetch() позволяет выполнить запрос и получить данные, не дожидаясь изменений в базе. Например, это может быть полезно при реализации Ofline First;
Изменение свойства searchedData, в свою очередь уведомляет подписчиков (если такие имеются) об изменении.
Заключение
На этом этапе реализация всех основных методов для работы с репозиторием подходит к концу. Для меня основными преимуществами данного подхода стало следующее:
логика работы репозитория с Core Data стала везде единая;
для добавления новых объектов в репозиторий, достаточно создать только EntityMapper (новое Entity требуется создать в любом случае). Вся логика по мапингу свойств также собрана в одном месте;
Data слой стал более структурированным. Теперь можно точно гарантировать, что репозиторий не выполняет 100500 запросов в методе сохранения, чтобы проставить связи между объектами;
репозиторий легко можно подменить, например для тестов, или для отладки.
Подобный подход может подойти не всем, и это основной его минус. Часто логика работы с данными зависит в т.ч и от бэк-энда. Основная задача этого поста - донести один из концептов по работе с Core Data. Смотрите демо-проект, допиливайте его под себя, пользуйтесь!
Спасибо за внимание! Легкого кодинга, поменьше багов, побольше фич!