Сегодня будем рассматривать ненавистный мне 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 }()</person></person>
Тут мы создаем объекты 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 controllerdo{ 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 {returnfalse} 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)}}} </nsfetchrequestresult></nsfetchrequestresult></nsfetchrequestresult></nsfetchrequestresult>
Теперь, когда что-то меняется (добавляем, обновляем или удаляем какой-то элемент), 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()breakcase .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()breakcase .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)inif let error = error as NSError? { fatalError("Unresolved error \(error), \(error.userInfo)")}})return container }() </car></car>
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 })}} </car></car></car>
Можете поэкспериментировать с нотификацией на изменение базы данных. Мой результат:
[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)inif 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)")} </t:></t:></t:>
Здесь я храню 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 returnNSEntityDescription.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 }} </nsmanagedobject></nsmanagedobject></t:></t:></nsfetchrequestresult></nsfetchrequestresult></nsfetchrequestresult>
Поэтому можно добавлять сущность в контекст, используя его тип и слово self (Car.self)
. Также добавлен метод fetchAll
, работающий с generics
. Его используем в методах StoreManager
. Теперь наш менеджер готов к работе. Надеюсь испытать и улучшить его при первой необходимости