Hello CoreData!

Сегодня будем рассматривать ненавистный мне framework от Apple под страшным названием CoreData. Это решения от Apple для работы с SQLite (реляционная база данных). CoreData может сохранять объекты Swift в SQLite, а также выполнять обратную операцию.
Введение
Лично мне больше нравится Realm, так как там мало что нужно делать, чтобы framework заработал — просто устанавливаете и начинаете создавать то, что хотите, плюс на официальном сайте есть прекрасная документация по всем фичам. Но когда вы просматриваете вакансии других компаний (я лично — никогда! А есть другие компании? ;) ) часто попадаете на требования CoreData, и если вы не имеете опыта работы с этим framework, то вас легко могут «завалить» уже в начале интервью, так и не узнав, какие красивые кнопочки вы умеете делать :)
Почему я не любил это framework? Потому что он достаточно тяжелый для понимания новичками, и в нем легко запутаться и допустить ошибки, а чем больше кода у вас написано, тем больше вероятность, что где-то вы ошиблись.
Начиная с ios 10, Apple многое сделали для упрощения работы с CoreData. Например, представили объект NSPersistentContainer
, который берет на себя работу по созданию NSManagedObjectModel
, NSPersistentStoreCoordinator
и NSManagedObjectContext
. Теперь для начала работы достаточно инициализировать объект persistentContainer = NSPersistentContainer(name: "MyModel")
. В самом начале статья задумывалась как обзор сторонних решений для работы с CoreData, но так как Apple переделали framework, многие из этих решений находились или в процессе переделки или, того хуже, написаны на Objective-C. Так что мы рассмотрим три примера. В первом пройдемся по NSFetchedResultsController
и NSPersistentContainer
; во втором, начнем писать свой класс для упрощения работы, а в последнем примере улучшим класс путем добавления generics
.
Пример 1
Проект доступен для изучения и называется MagicalRecord_project
(изначально хотел использовать именно этот framework). Рассмотрим файл Model.xcdatamodeld
:
Для атрибута avatar
(тип которого Binary Data
) выставлено в опциях Allows External Storage
.
Переходим в контроллер:
fileprivate let persistentContainer: NSPersistentContainer = NSPersistentContainer(name: "Model") fileprivate lazy var fetchedResultsController: NSFetchedResultsController<Person> = { let fetchRequest = NSFetchRequest<Person>() fetchRequest.entity = Person.entity() fetchRequest.sortDescriptors = [NSSortDescriptor(key: "name", ascending: true)] let frc = NSFetchedResultsController(fetchRequest: fetchRequest, managedObjectContext: self.persistentContainer.viewContext, sectionNameKeyPath: "name", cacheName: nil) frc.delegate = self return frc }()
Тут мы создаем объекты persistentContainer
и fetchedResultsController
, который будет отвечать за наполнение
func createPersonObject(name: String, about: String, image: UIImage) { let person = NSEntityDescription.insertNewObject(forEntityName: "Person", into: persistentContainer.viewContext) as! Person person.name = name person.about = about person.avatar = UIImageJPEGRepresentation(image, 1.0) as NSData? }
Смотрим, как это все выглядит в viewDidLoad
:
override func viewDidLoad() { super.viewDidLoad() tableView.delegate = self tableView.dataSource = self //create data createPersonObject(name: "Name A", about: "some inform...", image: UIImage(named: "01")!) createPersonObject(name: "Name B", about: "some inform...", image: UIImage(named: "02")!) perform(#selector(insertAfterDelay), with: nil, afterDelay: 2.0) //fetch data in controller do { try fetchedResultsController.performFetch() } catch { print(error) } }
Вот эта строка perform(#selector(insertAfterDelay), with: nil, afterDelay: 2.0)
добавляет третий объект к контексту и NSFetchedResultsController
обновляет tableView
(об этом чуть позже).
fetchedResultsController.performFetch()
передает данные с CoreData в контроллер.
Запускаем приложение.
В начале видим два добавленных объекта, спустя две секунды — три. Создаем extension
для ViewController
:
extension ViewController: UITableViewDelegate, UITableViewDataSource { func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool { return false } func numberOfSections(in tableView: UITableView) -> Int { return fetchedResultsController.sections?.count ?? 0 } func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { return fetchedResultsController.sections?[section].numberOfObjects ?? 0 } func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { let cell = tableView.dequeueReusableCell(withIdentifier: "Cell") as! Cell guard let sections = fetchedResultsController.sections else { fatalError("Sections missing") } let section = sections[indexPath.section] guard let itemsInSection = section.objects as? [Person] else { fatalError("Items missing") } let person = itemsInSection[indexPath.row] cell.aboutLbl.text = person.about cell.nameLbl.text = person.name cell.avatarImView.image = UIImage(data: person.avatar! as Data) return cell } }
Так как вместо массива dataSource
мы имеем fetchedResultsController
, то используем его для получения всех необходимых данных. В переменной fileprivate lazy var fetchedResultsController
мы объявили себя делегатом NSFetchedResultsController
. Реализуем необходимые методы:
extension ViewController: NSFetchedResultsControllerDelegate { func controllerWillChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) { tableView.beginUpdates() } func controllerDidChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) { tableView.endUpdates() } func controller(_ controller: NSFetchedResultsController<NSFetchRequestResult>, didChange anObject: Any, at indexPath: IndexPath?, for type: NSFetchedResultsChangeType, newIndexPath: IndexPath?) { switch type { case .insert: tableView.insertRows(at: [newIndexPath!], with: .automatic) case .delete: tableView.deleteRows(at: [indexPath!], with: .automatic) case .update: tableView.reloadRows(at: [indexPath!], with: .automatic) case .move: tableView.moveRow(at: indexPath!, to: newIndexPath!) } } func controller(_ controller: NSFetchedResultsController<NSFetchRequestResult>, didChange sectionInfo: NSFetchedResultsSectionInfo, atSectionIndex sectionIndex: Int, for type: NSFetchedResultsChangeType) { switch type { case .insert: tableView.insertSections(IndexSet(integer: sectionIndex), with: .automatic) case .delete: tableView.deleteSections(IndexSet(integer: sectionIndex), with: .automatic) case .move, .update: tableView.reloadSections(IndexSet(integer: sectionIndex), with: .automatic) } } }
Теперь, когда что-то меняется (добавляем, обновляем или удаляем какой-то элемент), NSFetchedResultsController
знает об этом и может сделать необходимые коррекции в UITableView
. Подобный механизм реализован и в Realm:
let token = realm.addNotificationBlock { notification, realm in viewController.updateUI() } token = results.addNotificationBlock { [weak self] (changes: RealmCollectionChange) in guard let tableView = self?.tableView else { return } switch changes { case .initial: // Results are now populated and can be accessed without blocking the UI tableView.reloadData() break case .update(_, let deletions, let insertions, let modifications): // Query results have changed, so apply them to the UITableView tableView.beginUpdates() tableView.insertRows(at: insertions.map({ IndexPath(row: $0, section: 0) }), with: .automatic) tableView.deleteRows(at: deletions.map({ IndexPath(row: $0, section: 0)}), with: .automatic) tableView.reloadRows(at: modifications.map({ IndexPath(row: $0, section: 0) }), with: .automatic) tableView.endUpdates() break case .error(let error): // An error occurred while opening the Realm file on the background worker thread fatalError("\(error)") break } } }
К нашему примеру это не относится, но как видите, механизмы обновления UITableView
похожи.
Пример 2
В этом примере будем создавать StoreManager
. Откройте проект StoreManager_project
. StoreManager
создан с использованием патерна Singleton
(одна копия на протяжении жизни программы):
fileprivate override init(){} static let sharedInstance = StoreManager() override func copy() -> Any { fatalError("You are not allowed to use copy method on singleton!") } fileprivate lazy var fetchRequest: NSFetchRequest<Car> = { let request = NSFetchRequest<Car>() request.entity = Car.entity() request.sortDescriptors = [NSSortDescriptor(key: "name", ascending: true)] return request }() fileprivate lazy var persistentContainer: NSPersistentContainer = { let container = NSPersistentContainer(name: "Model") container.loadPersistentStores(completionHandler: { (storeDescription, error) in if let error = error as NSError? { fatalError("Unresolved error \(error), \(error.userInfo)") } }) return container }()
NSPersistentContainer
имеет два свойства, точнее свойство и метод: viewContext
и newBackgroundContext
. Первый ассоциирован с main queue
, второй — privateQueueConcurrencyType
. Когда мы пишем что-то в newBackgroundContext
, он отсылает нотификацию для объекта viewContext
на мердж содержимого. Поэтому выходит, что нам уже не нужно самим подписываться на эту нотификацию. Из документации по viewContext
:
This context is configured to be generational and to automatically consume save notifications from other contexts.
По newBackgroundContext
:
…is set to consume NSManagedObjectContextDidSave broadcasts automatically.
Я добавил несколько методов, которые мы будем модифицировать в третьем примере:
func saveMainContext () { let context = persistentContainer.viewContext if context.hasChanges { do { try context.save() } catch { let nserror = error as NSError fatalError("Unresolved error \(nserror), \(nserror.userInfo)") } } } func clearMainStorage() { var cars: [Car]? do { cars = try persistentContainer.viewContext.fetch(fetchRequest) } catch { print("Storage reading error!") } if let cars = cars { for item in cars { persistentContainer.viewContext.delete(item) } } } func addACar(name: String, hasRoof: Bool, numberOfWheels: Int) { //add data to background context let context = persistentContainer.newBackgroundContext() let car = NSEntityDescription.insertNewObject(forEntityName: "Car", into: context) as! Car car.name = name car.numberOfWheels = Int16(numberOfWheels) car.hasRoof = hasRoof do { try context.save() } catch { print(error) } } func showMainStorage() { var cars: [Car]? do { cars = try persistentContainer.viewContext.fetch(fetchRequest) } catch { print("Storage reading error!") } print("CARS: \(cars?.map { $0.name! })") } viewDidLoad из ViewController: StoreManager.sharedInstance.clearMainStorage() StoreManager.sharedInstance.addACar(name: "BMW", hasRoof: true, numberOfWheels: 4) StoreManager.sharedInstance.addACar(name: "Zapor", hasRoof: false, numberOfWheels: 3) StoreManager.sharedInstance.addACar(name: "Fiat", hasRoof: true, numberOfWheels: 5) StoreManager.sharedInstance.addACar(name: "F1", hasRoof: false, numberOfWheels: 4) perform(#selector(insertAnotherCar), with: nil, afterDelay: 2.0) StoreManager.sharedInstance.showMainStorage()
Сначала чистим хранилище, потом добавляем модели авто. Дальше идет метод, который добавит третье авто, после двух секунд (никогда не используйте performSelector: afterDelay:
— это плохая практика). И два метода showMainStorage()
, которые показывают, как изменилась база данных. Для примера можете раскоментировать следующие куски кода:
NotificationCenter.default.addObserver(self, selector: #selector(storageHasChanged(note:)), name: NSNotification.Name.NSManagedObjectContextObjectsDidChange, object: nil) deinit { NotificationCenter.default.removeObserver(self) } //MARK: Actions func storageHasChanged(note: NSNotification) { let inserted = note.userInfo?["inserted"] let deleted = note.userInfo?["deleted"] let updated = note.userInfo?["updated"] if let insertedSet = inserted as? Set<Car>, insertedSet.count > 0 { print("INSERTED") print(insertedSet.map { $0.name }) } if let deletedSet = deleted as? Set<Car>, deletedSet.count > 0 { print("DELETED") print(deletedSet.map { $0.name }) } if let updatedSet = updated as? Set<Car>, updatedSet.count > 0 { print("UPDATED") print(updatedSet.map { $0.name }) } }
Можете поэкспериментировать с нотификацией на изменение базы данных. Мой результат:
[Optional("Zapor")] INSERTED [Optional("Fiat")] INSERTED [Optional("F1")] DELETED [Optional("Zapor"), Optional("F1"), Optional("Fiat"), Optional("BMW"), Optional("Fiat"), Optional("BMW"), Optional("Alfa Romeo"), Optional("Alfa Romeo"), Optional("BMW"), Optional("Fiat"), Optional("BMW"), Optional("F1"), Optional("F1"), Optional("Alfa Romeo"), Optional("F1"), Optional("Fiat"), Optional("Alfa Romeo"), Optional("Zapor"), Optional("Zapor"), Optional("Zapor")] CARS: Optional(["BMW", "F1", "Fiat", "Zapor"]) INSERTED [Optional("Alfa Romeo")] CARS: Optional(["Alfa Romeo", "BMW", "F1", "Fiat", “Zapor"])
Пример 3
В этом примере я хотел продемонстрировать работу с JSON. Конечно, в реальном проекте я бы использовал что-то для мапинга, возможно SwiftyJSON для простоты. Но для небольшого урока, как в этот, я задал JSON строкой и потом его распарсил:
et json = "{\"cars\":[{\"name\":\"ZaZ\"},{\"name\":\"Lada\"},{\"name\":\"Lexus\"}]}" let dict = try! JSONSerialization.jsonObject(with: json.data(using: .utf8)!, options: JSONSerialization.ReadingOptions.allowFragments) as! [String:Any]
Смотрим на файл StoreManager
:
//to make sure we have only one copy lazy var backgroundContext: NSManagedObjectContext = { return self.persistentContainer.newBackgroundContext() }() fileprivate lazy var persistentContainer: NSPersistentContainer = { let container = NSPersistentContainer(name: "Model") container.loadPersistentStores(completionHandler: { (storeDescription, error) in if let error = error as NSError? { fatalError("Unresolved error \(error), \(error.userInfo)") } }) return container }() func saveContext(type: ContextType) { let context = type == .main ? persistentContainer.viewContext : backgroundContext if context.hasChanges { do { try context.save() } catch { let nserror = error as NSError fatalError("Unresolved error \(nserror), \(nserror.userInfo)") } } } func getMainContextObjects<T: NSManagedObject>(entity: T.Type) -> [NSManagedObject]? { return persistentContainer.viewContext.fetchAll(entity: entity, fetchConfiguration: nil) } func clearStorage<T: NSManagedObject>(type: ContextType, entity: T.Type) { let context = type == .main ? persistentContainer.viewContext : backgroundContext let objects = context.fetchAll(entity: entity, fetchConfiguration: nil) if let objects = objects { for item in objects { context.delete(item) } } } func showStorage<T: NSManagedObject>(type: ContextType, entity: T.Type) { let context = type == .main ? persistentContainer.viewContext : backgroundContext let objects = context.fetchAll(entity: entity, fetchConfiguration: nil) print("OBJECTS: \(objects)") print("COUNT: \(objects?.count)") }
Здесь я храню backgroundContext
как переменную, чтобы всегда был только один backgroundContext
, который в дальнейшем я могу сохранять методом saveContext(type:)
. Остальные методы должны быть понятны. Так как у нас есть enum
...
enum ContextType { case main case background }
...мы всегда знаем, с каким контекстом работаем. Вернемся в ViewController
. Обратите внимание, как я создаю объекты:
for item in dict["cars"] as! [Any] { let car = StoreManager.sharedInstance.backgroundContext.insert(entity: Car.self) car.name = (item as? [String:Any])?["name"] as! String? }
Но в стандартном контексте метода insert(entity:)
нет! Дело в том, что я написал extensions, которые работают с generics
-данными:
//MARK: CoreData extensions extension NSManagedObject { class var entityName : String { let components = NSStringFromClass(self) return components } class func fetchRequestObj() -> NSFetchRequest<NSFetchRequestResult> { let request = NSFetchRequest<NSFetchRequestResult>(entityName: self.entityName) return request } class func fetchRequestWithKey(key: String, ascending: Bool = true) -> NSFetchRequest<NSFetchRequestResult> { let request = fetchRequestObj() request.sortDescriptors = [NSSortDescriptor(key: key, ascending: ascending)] return request } } extension NSManagedObjectContext { func insert<T: NSManagedObject>(entity: T.Type) -> T { let entityName = entity.entityName return NSEntityDescription.insertNewObject(forEntityName: entityName, into: self) as! T } func fetchAll<T: NSManagedObject>(entity: T.Type, fetchConfiguration: ((NSFetchRequest<NSManagedObject>) -> Void)?) -> [NSManagedObject]? { let dataFetchRequest = NSFetchRequest<NSManagedObject>(entityName: entity.entityName) fetchConfiguration?(dataFetchRequest) var result = [NSManagedObject]() do { result = try self.fetch(dataFetchRequest) } catch { print("Failed to fetch feed data, critical error: \(error)") } return result } }
Поэтому можно добавлять сущность в контекст, используя его тип и слово self (Car.self)
. Также добавлен метод fetchAll
, работающий с generics
. Его используем в методах StoreManager
. Теперь наш менеджер готов к работе. Надеюсь испытать и улучшить его при первой необходимости