Всем привет! Данный пост рассчитан на людей, которые имеют
представление о Core Data. Если вы не один из них, прочитайте
краткую информацию тут и
скорее присоединяйтесь. Прежде всего, мне хотелось бы поделиться
своим взглядом на некоторые подходы к организации работы с данными
в IOS приложениях, и в этом вопросе мне пришелся по душе паттерн
репозиторий (далее - репозиторий). Поехали!
Немного про репозиторий
Основная идея репозитория заключается в том, чтобы
абстрагироваться от источников данных, скрыть детали реализации,
ведь, c точки зрения доступа к данным, эта самая реализация - не
играет роли. Предоставить доступ к основным операциям над данными:
сохранить, загрузить или удалить - главная задача репозитория.
Таким образом, к его преимуществам можно отнести:
- отсутствие зависимостей от реализации репозитория. Под капотом
может быть все, что угодно: коллекция в оперативной памяти,
UserDefaults, KeyChain, Core Data, Realm, URLCache, отдельный файл
в tmp и т.п.;
- разделение зон ответственности. Репозиторий выступает
прослойкой между бизнес-логикой и способом хранения данных, отделяя
одно от другого;
- формирование единого, более структурированного подхода в
работе с данными.
В конечном итоге, все это благоприятно сказывается на скорости
разработки, возможностях масштабирования и тестируемости
проектов.
Ближе к деталям
Рассмотрим самый неблагоприятный сценарий использования Core
Data.
Для общего понимания будем использовать класс DataProvider
(далее - DP) - его задача получать данные откуда - либо (сеть, UI)
и класть их в Repository . Также, при необходимости, DP может
достать данные из репозитория, выступая для него фасадом. Под данными будем
подразумевать массив доменных объектов. Именно ими оперирует
репозиторий.
1. Core Data как один большой репозиторий с
NSManagamentObject
Рисунок
1
Идея оперировать NSManagedObject в качестве
доменного объекта самая простая, но не самая удачная. При таком
подходе перед нами встают сразу несколько проблем:
-
Сore Data разрастается по всему проекту, вешая ненужные
зависимости и нарушая зоны ответственности. Раскрываются детали
реализации. Знать на чем завязана реализация репозитория - должен
только репозиторий;
-
Используя единый репозиторий для всех Data Provider, он будет
разрастаться с появлением новых доменных объектов;
-
В худшем случае, логика работы с объектами начнет пересекаться
между собой и это может превратиться в один большой непредсказуемый
magic.
2. Core Data + DB Client
Первое, что приходит на ум, для решения проблем из предыдущего
примера, это вынести логику работы с объектами в отдельный класс
(назовем его DB Client), тогда наш Repository будет только
сохранять и доставать объекты из хранилища, в то время, как вся
логика по работе с объектами ляжет в DB Client. На выходе должно
получиться что-то такое:
Рисунок 2
Обе схемы решают проблему 1. (Core Data ограничивается DB Client
и Repository), и частично могут решить проблему 2 и 3 на небольших
проектах, но не исключают их полностью. Продолжая мысль дальше,
возможно придти к следующей схеме:
Рисунок
3
-
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
В таком случае, хорошо подходит 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
Для работы с выборкой объектов потребуются объект с двумя
свойствами:
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 implementationextension 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. Смотрите демо-проект, допиливайте его под себя,
пользуйтесь!
Спасибо за внимание! Легкого кодинга, поменьше
багов, побольше фич!