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:

Просмотр Model.xcdatamodeld

Просмотр 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 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)}}}

Теперь, когда что-то меняется (добавляем, обновляем или удаляем какой-то элемент), 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
    }()

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)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)")}

Здесь я храню 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
    }}

Поэтому можно добавлять сущность в контекст, используя его тип и слово self (Car.self). Также добавлен метод fetchAll, работающий с generics. Его используем в методах StoreManager. Теперь наш менеджер готов к работе. Надеюсь испытать и улучшить его при первой необходимости