Hello CoreData!

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 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. Теперь наш менеджер готов к работе. Надеюсь испытать и улучшить его при первой необходимости